gg auth and new tool
Browse files- .env.example +10 -0
- app/agent/mmca_agent.py +61 -3
- app/agent/react_agent.py +15 -4
- app/agent/reasoning.py +8 -0
- app/auth/controls.py +13 -5
- app/core/config.py +4 -0
- app/mcp/tools/__init__.py +13 -1
- app/mcp/tools/social_tool.py +135 -0
- docs/PROMPT_REPORT.md +148 -0
- scripts/verify_social.py +30 -0
.env.example
CHANGED
|
@@ -16,9 +16,19 @@ NEO4J_PASSWORD=CHANGE_ME
|
|
| 16 |
# Google AI (Gemini)
|
| 17 |
GOOGLE_API_KEY=your_google_api_key
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# MegaLLM (OpenAI-compatible - DeepSeek)
|
| 20 |
MEGALLM_API_KEY=your_megallm_api_key
|
| 21 |
MEGALLM_BASE_URL=https://ai.megallm.io/v1
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# CLIP (optional - for image embeddings)
|
| 24 |
HUGGINGFACE_API_KEY=your_hf_api_key
|
|
|
|
| 16 |
# Google AI (Gemini)
|
| 17 |
GOOGLE_API_KEY=your_google_api_key
|
| 18 |
|
| 19 |
+
# Google OAuth
|
| 20 |
+
GOOGLE_CLIENT_ID=your_google_api_key
|
| 21 |
+
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
| 22 |
+
|
| 23 |
# MegaLLM (OpenAI-compatible - DeepSeek)
|
| 24 |
MEGALLM_API_KEY=your_megallm_api_key
|
| 25 |
MEGALLM_BASE_URL=https://ai.megallm.io/v1
|
| 26 |
|
| 27 |
+
# Brave Social Search
|
| 28 |
+
BRAVE_API_KEY=your_brave_api_key
|
| 29 |
+
|
| 30 |
+
# Google OAuth
|
| 31 |
+
GOOGLE_CLIENT_ID=your_google_client_id
|
| 32 |
+
|
| 33 |
# CLIP (optional - for image embeddings)
|
| 34 |
HUGGINGFACE_API_KEY=your_hf_api_key
|
app/agent/mmca_agent.py
CHANGED
|
@@ -43,16 +43,22 @@ SYSTEM_PROMPT = """Bạn là trợ lý du lịch thông minh cho Đà Nẵng. B
|
|
| 43 |
- Ví dụ: "Quán cafe gần Cầu Rồng", "Nhà hàng gần bãi biển Mỹ Khê"
|
| 44 |
- Đặc biệt: Có thể lấy chi tiết đầy đủ với photos, reviews
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
**Quy tắc quan trọng:**
|
| 47 |
1. Phân tích intent để chọn ĐÚNG tool (không chỉ dùng 1 tool)
|
| 48 |
2. Với câu hỏi tổng quát ("quán cafe ngon") → dùng retrieve_context_text
|
| 49 |
3. Với câu hỏi vị trí ("gần X", "quanh Y") → dùng find_nearby_places
|
| 50 |
-
4. Với
|
| 51 |
-
5.
|
| 52 |
6. Trả lời tiếng Việt, thân thiện, cung cấp thông tin cụ thể (tên, rating, khoảng cách)
|
| 53 |
"""
|
| 54 |
|
| 55 |
|
|
|
|
| 56 |
@dataclass
|
| 57 |
class ChatMessage:
|
| 58 |
"""Chat message model."""
|
|
@@ -244,6 +250,11 @@ class MMCAAgent:
|
|
| 244 |
if not intents:
|
| 245 |
intents.append("text_search")
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
return " + ".join(intents)
|
| 248 |
|
| 249 |
def _get_tool_purpose(self, tool_name: str) -> str:
|
|
@@ -252,6 +263,7 @@ class MMCAAgent:
|
|
| 252 |
"retrieve_context_text": "Tìm kiếm semantic trong văn bản (review, mô tả)",
|
| 253 |
"retrieve_similar_visuals": "Tìm địa điểm có hình ảnh tương tự",
|
| 254 |
"find_nearby_places": "Tìm địa điểm gần vị trí được nhắc đến",
|
|
|
|
| 255 |
}
|
| 256 |
return purposes.get(tool_name, tool_name)
|
| 257 |
|
|
@@ -274,6 +286,30 @@ class MMCAAgent:
|
|
| 274 |
arguments={"image_url": image_url, "limit": 5},
|
| 275 |
))
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
# Analyze message for location/proximity queries
|
| 278 |
location_keywords = ["gần", "cách", "nearby", "gần đây", "quanh", "xung quanh"]
|
| 279 |
if any(kw in message.lower() for kw in location_keywords):
|
|
@@ -296,13 +332,16 @@ class MMCAAgent:
|
|
| 296 |
},
|
| 297 |
))
|
| 298 |
|
| 299 |
-
# For general queries without location keywords, use text search
|
|
|
|
|
|
|
| 300 |
if not tool_calls:
|
| 301 |
tool_calls.append(ToolCall(
|
| 302 |
tool_name="retrieve_context_text",
|
| 303 |
arguments={"query": message, "limit": 5},
|
| 304 |
))
|
| 305 |
|
|
|
|
| 306 |
return tool_calls
|
| 307 |
|
| 308 |
async def _execute_tool(
|
|
@@ -370,6 +409,25 @@ class MMCAAgent:
|
|
| 370 |
for r in results
|
| 371 |
]
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
except Exception as e:
|
| 374 |
agent_logger.error(f"Tool execution failed: {tool_call.tool_name}", e)
|
| 375 |
tool_call.result = [{"error": str(e)}]
|
|
|
|
| 43 |
- Ví dụ: "Quán cafe gần Cầu Rồng", "Nhà hàng gần bãi biển Mỹ Khê"
|
| 44 |
- Đặc biệt: Có thể lấy chi tiết đầy đủ với photos, reviews
|
| 45 |
|
| 46 |
+
**4. search_social_media** - Tìm kiếm mạng xã hội và tin tức
|
| 47 |
+
- Khi nào: Hỏi về "review", "tin hot", "trend", "tiktok", "facebook", "tin mới"
|
| 48 |
+
- Ví dụ: "Review quán ăn ngon Đà Nẵng trên TikTok", "Tin hot tuần qua"
|
| 49 |
+
- Tham số: query, freshness ("pw": tuần, "pm": tháng), platforms (["tiktok", "facebook", "reddit"])
|
| 50 |
+
|
| 51 |
**Quy tắc quan trọng:**
|
| 52 |
1. Phân tích intent để chọn ĐÚNG tool (không chỉ dùng 1 tool)
|
| 53 |
2. Với câu hỏi tổng quát ("quán cafe ngon") → dùng retrieve_context_text
|
| 54 |
3. Với câu hỏi vị trí ("gần X", "quanh Y") → dùng find_nearby_places
|
| 55 |
+
4. Với câu hỏi trend/review từ MXH -> dùng search_social_media
|
| 56 |
+
5. Với ảnh → dùng retrieve_similar_visuals
|
| 57 |
6. Trả lời tiếng Việt, thân thiện, cung cấp thông tin cụ thể (tên, rating, khoảng cách)
|
| 58 |
"""
|
| 59 |
|
| 60 |
|
| 61 |
+
|
| 62 |
@dataclass
|
| 63 |
class ChatMessage:
|
| 64 |
"""Chat message model."""
|
|
|
|
| 250 |
if not intents:
|
| 251 |
intents.append("text_search")
|
| 252 |
|
| 253 |
+
# Social intent detection
|
| 254 |
+
social_keywords = ["review", "tin hot", "trend", "tin mới", "tiktok", "facebook", "reddit", "youtube", "mạng xã hội"]
|
| 255 |
+
if any(kw in message.lower() for kw in social_keywords):
|
| 256 |
+
intents.append("social_search")
|
| 257 |
+
|
| 258 |
return " + ".join(intents)
|
| 259 |
|
| 260 |
def _get_tool_purpose(self, tool_name: str) -> str:
|
|
|
|
| 263 |
"retrieve_context_text": "Tìm kiếm semantic trong văn bản (review, mô tả)",
|
| 264 |
"retrieve_similar_visuals": "Tìm địa điểm có hình ảnh tương tự",
|
| 265 |
"find_nearby_places": "Tìm địa điểm gần vị trí được nhắc đến",
|
| 266 |
+
"search_social_media": "Tìm kiếm thông tin từ mạng xã hội (news, trends)",
|
| 267 |
}
|
| 268 |
return purposes.get(tool_name, tool_name)
|
| 269 |
|
|
|
|
| 286 |
arguments={"image_url": image_url, "limit": 5},
|
| 287 |
))
|
| 288 |
|
| 289 |
+
# Check for social media intent FIRST
|
| 290 |
+
social_keywords = ["review", "tin hot", "trend", "tin mới", "tiktok", "facebook", "reddit", "youtube", "mạng xã hội"]
|
| 291 |
+
if any(kw in message.lower() for kw in social_keywords):
|
| 292 |
+
# Determine freshness
|
| 293 |
+
freshness = "pw" # Default past week
|
| 294 |
+
if "tháng" in message.lower() or "month" in message.lower():
|
| 295 |
+
freshness = "pm"
|
| 296 |
+
|
| 297 |
+
# Determine platforms
|
| 298 |
+
platforms = []
|
| 299 |
+
for p in ["tiktok", "facebook", "reddit", "youtube", "twitter", "instagram"]:
|
| 300 |
+
if p in message.lower():
|
| 301 |
+
platforms.append(p)
|
| 302 |
+
|
| 303 |
+
tool_calls.append(ToolCall(
|
| 304 |
+
tool_name="search_social_media",
|
| 305 |
+
arguments={
|
| 306 |
+
"query": message,
|
| 307 |
+
"limit": 5,
|
| 308 |
+
"freshness": freshness,
|
| 309 |
+
"platforms": platforms if platforms else None
|
| 310 |
+
}
|
| 311 |
+
))
|
| 312 |
+
|
| 313 |
# Analyze message for location/proximity queries
|
| 314 |
location_keywords = ["gần", "cách", "nearby", "gần đây", "quanh", "xung quanh"]
|
| 315 |
if any(kw in message.lower() for kw in location_keywords):
|
|
|
|
| 332 |
},
|
| 333 |
))
|
| 334 |
|
| 335 |
+
# For general queries without location keywords AND NO SOCIAL INTENT, use text search
|
| 336 |
+
# If social search is already triggered, we might skip text search to avoid noise,
|
| 337 |
+
# or keep it if query is mixed. For now, let's keep text search only if no other tools used.
|
| 338 |
if not tool_calls:
|
| 339 |
tool_calls.append(ToolCall(
|
| 340 |
tool_name="retrieve_context_text",
|
| 341 |
arguments={"query": message, "limit": 5},
|
| 342 |
))
|
| 343 |
|
| 344 |
+
|
| 345 |
return tool_calls
|
| 346 |
|
| 347 |
async def _execute_tool(
|
|
|
|
| 409 |
for r in results
|
| 410 |
]
|
| 411 |
|
| 412 |
+
elif tool_call.tool_name == "search_social_media":
|
| 413 |
+
results = await self.tools.search_social_media(
|
| 414 |
+
query=tool_call.arguments.get("query", ""),
|
| 415 |
+
limit=tool_call.arguments.get("limit", 10),
|
| 416 |
+
freshness=tool_call.arguments.get("freshness", "pw"),
|
| 417 |
+
platforms=tool_call.arguments.get("platforms"),
|
| 418 |
+
)
|
| 419 |
+
tool_call.result = [
|
| 420 |
+
{
|
| 421 |
+
"title": r.title,
|
| 422 |
+
"url": r.url,
|
| 423 |
+
"description": r.description,
|
| 424 |
+
"age": r.age,
|
| 425 |
+
"platform": r.platform,
|
| 426 |
+
}
|
| 427 |
+
for r in results
|
| 428 |
+
]
|
| 429 |
+
|
| 430 |
+
|
| 431 |
except Exception as e:
|
| 432 |
agent_logger.error(f"Tool execution failed: {tool_call.tool_name}", e)
|
| 433 |
tool_call.result = [{"error": str(e)}]
|
app/agent/react_agent.py
CHANGED
|
@@ -259,16 +259,27 @@ class ReActAgent:
|
|
| 259 |
db=db,
|
| 260 |
image_url=url,
|
| 261 |
limit=action_input.get("limit", 5),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
)
|
| 263 |
return [
|
| 264 |
{
|
| 265 |
-
"
|
| 266 |
-
"
|
| 267 |
-
"
|
| 268 |
-
"
|
| 269 |
}
|
| 270 |
for r in results
|
| 271 |
]
|
|
|
|
| 272 |
|
| 273 |
else:
|
| 274 |
return {"error": f"Unknown tool: {action}"}
|
|
|
|
| 259 |
db=db,
|
| 260 |
image_url=url,
|
| 261 |
limit=action_input.get("limit", 5),
|
| 262 |
+
)
|
| 263 |
+
for r in results
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
+
elif action == "search_social_media":
|
| 267 |
+
results = await self.tools.search_social_media(
|
| 268 |
+
query=action_input.get("query", ""),
|
| 269 |
+
limit=action_input.get("limit", 5),
|
| 270 |
+
freshness=action_input.get("freshness", "pw"),
|
| 271 |
+
platforms=action_input.get("platforms"),
|
| 272 |
)
|
| 273 |
return [
|
| 274 |
{
|
| 275 |
+
"title": r.title,
|
| 276 |
+
"url": r.url,
|
| 277 |
+
"age": r.age,
|
| 278 |
+
"platform": r.platform,
|
| 279 |
}
|
| 280 |
for r in results
|
| 281 |
]
|
| 282 |
+
|
| 283 |
|
| 284 |
else:
|
| 285 |
return {"error": f"Unknown tool: {action}"}
|
app/agent/reasoning.py
CHANGED
|
@@ -28,6 +28,10 @@ REACT_SYSTEM_PROMPT = """Bạn là agent du lịch thông minh cho Đà Nẵng v
|
|
| 28 |
- Input: {"image_url": "..."}
|
| 29 |
- Output: [{name, similarity, image_url}]
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
**Quy trình:**
|
| 32 |
Với mỗi bước, bạn phải:
|
| 33 |
1. **Thought**: Suy nghĩ về bước tiếp theo cần làm
|
|
@@ -46,11 +50,13 @@ Với mỗi bước, bạn phải:
|
|
| 46 |
**Quan trọng:**
|
| 47 |
- Nếu cần biết vị trí → dùng get_location_coordinates trước
|
| 48 |
- Nếu tìm theo khoảng cách → dùng find_nearby_places
|
|
|
|
| 49 |
- Nếu cần lọc theo đặc điểm (view, không gian, giá) → dùng retrieve_context_text
|
| 50 |
- Khi đủ thông tin → action = "finish"
|
| 51 |
"""
|
| 52 |
|
| 53 |
|
|
|
|
| 54 |
@dataclass
|
| 55 |
class ReasoningResult:
|
| 56 |
"""Result from LLM reasoning step."""
|
|
@@ -157,6 +163,8 @@ def get_tool_purpose(action: str) -> str:
|
|
| 157 |
"find_nearby_places": "Tìm địa điểm gần vị trí",
|
| 158 |
"retrieve_context_text": "Tìm theo văn bản (reviews, mô tả)",
|
| 159 |
"retrieve_similar_visuals": "Tìm theo hình ảnh tương tự",
|
|
|
|
| 160 |
"finish": "Hoàn thành và tổng hợp kết quả",
|
| 161 |
}
|
|
|
|
| 162 |
return purposes.get(action, action)
|
|
|
|
| 28 |
- Input: {"image_url": "..."}
|
| 29 |
- Output: [{name, similarity, image_url}]
|
| 30 |
|
| 31 |
+
5. `search_social_media` - Tìm kiếm mạng xã hội và tin tức
|
| 32 |
+
- Input: {"query": "review quán ăn", "freshness": "pw", "platforms": ["tiktok"]}
|
| 33 |
+
- Output: [{title, url, age, platform}]
|
| 34 |
+
|
| 35 |
**Quy trình:**
|
| 36 |
Với mỗi bước, bạn phải:
|
| 37 |
1. **Thought**: Suy nghĩ về bước tiếp theo cần làm
|
|
|
|
| 50 |
**Quan trọng:**
|
| 51 |
- Nếu cần biết vị trí → dùng get_location_coordinates trước
|
| 52 |
- Nếu tìm theo khoảng cách → dùng find_nearby_places
|
| 53 |
+
- Nếu tìm review/trend MXH → dùng search_social_media
|
| 54 |
- Nếu cần lọc theo đặc điểm (view, không gian, giá) → dùng retrieve_context_text
|
| 55 |
- Khi đủ thông tin → action = "finish"
|
| 56 |
"""
|
| 57 |
|
| 58 |
|
| 59 |
+
|
| 60 |
@dataclass
|
| 61 |
class ReasoningResult:
|
| 62 |
"""Result from LLM reasoning step."""
|
|
|
|
| 163 |
"find_nearby_places": "Tìm địa điểm gần vị trí",
|
| 164 |
"retrieve_context_text": "Tìm theo văn bản (reviews, mô tả)",
|
| 165 |
"retrieve_similar_visuals": "Tìm theo hình ảnh tương tự",
|
| 166 |
+
"search_social_media": "Tìm kiếm mạng xã hội và tin tức",
|
| 167 |
"finish": "Hoàn thành và tổng hợp kết quả",
|
| 168 |
}
|
| 169 |
+
|
| 170 |
return purposes.get(action, action)
|
app/auth/controls.py
CHANGED
|
@@ -8,12 +8,12 @@ from datetime import datetime, timedelta
|
|
| 8 |
import jwt
|
| 9 |
import os
|
| 10 |
from uuid import uuid4
|
|
|
|
| 11 |
|
| 12 |
# Google OAuth verification URL
|
| 13 |
-
GOOGLE_VERIFY_URL = "https://www.googleapis.com/oauth2/v3/userinfo
|
| 14 |
|
| 15 |
-
# JWT settings
|
| 16 |
-
JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key-change-in-production")
|
| 17 |
JWT_ALGORITHM = "HS256"
|
| 18 |
JWT_EXPIRATION_HOURS = 24
|
| 19 |
|
|
@@ -43,7 +43,11 @@ async def login_control(access_token: str, db: AsyncSession) -> dict:
|
|
| 43 |
# Verify token with Google
|
| 44 |
async with httpx.AsyncClient() as client:
|
| 45 |
try:
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
if response.status_code != 200:
|
| 49 |
raise HTTPException(
|
|
@@ -53,6 +57,10 @@ async def login_control(access_token: str, db: AsyncSession) -> dict:
|
|
| 53 |
|
| 54 |
google_user_info = response.json()
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
except httpx.RequestError as e:
|
| 57 |
raise HTTPException(
|
| 58 |
status_code=500,
|
|
@@ -133,7 +141,7 @@ async def login_control(access_token: str, db: AsyncSession) -> dict:
|
|
| 133 |
"email": email,
|
| 134 |
"exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
|
| 135 |
}
|
| 136 |
-
token = jwt.encode(token_payload,
|
| 137 |
|
| 138 |
return {
|
| 139 |
"user_id": user_id,
|
|
|
|
| 8 |
import jwt
|
| 9 |
import os
|
| 10 |
from uuid import uuid4
|
| 11 |
+
from app.core.config import settings
|
| 12 |
|
| 13 |
# Google OAuth verification URL
|
| 14 |
+
GOOGLE_VERIFY_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
|
| 15 |
|
| 16 |
+
# JWT settings
|
|
|
|
| 17 |
JWT_ALGORITHM = "HS256"
|
| 18 |
JWT_EXPIRATION_HOURS = 24
|
| 19 |
|
|
|
|
| 43 |
# Verify token with Google
|
| 44 |
async with httpx.AsyncClient() as client:
|
| 45 |
try:
|
| 46 |
+
# Get user info using access token
|
| 47 |
+
response = await client.get(
|
| 48 |
+
GOOGLE_VERIFY_URL,
|
| 49 |
+
headers={"Authorization": f"Bearer {access_token}"}
|
| 50 |
+
)
|
| 51 |
|
| 52 |
if response.status_code != 200:
|
| 53 |
raise HTTPException(
|
|
|
|
| 57 |
|
| 58 |
google_user_info = response.json()
|
| 59 |
|
| 60 |
+
# Verify the token was issued for our client
|
| 61 |
+
# Note: For access tokens from Token Client, we trust Google's validation
|
| 62 |
+
# The token is already validated by Google if we get a 200 response
|
| 63 |
+
|
| 64 |
except httpx.RequestError as e:
|
| 65 |
raise HTTPException(
|
| 66 |
status_code=500,
|
|
|
|
| 141 |
"email": email,
|
| 142 |
"exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
|
| 143 |
}
|
| 144 |
+
token = jwt.encode(token_payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
|
| 145 |
|
| 146 |
return {
|
| 147 |
"user_id": user_id,
|
app/core/config.py
CHANGED
|
@@ -23,6 +23,10 @@ class Settings(BaseSettings):
|
|
| 23 |
|
| 24 |
# Google AI
|
| 25 |
google_api_key: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# MegaLLM (OpenAI-compatible)
|
| 28 |
megallm_api_key: str | None = None
|
|
|
|
| 23 |
|
| 24 |
# Google AI
|
| 25 |
google_api_key: str
|
| 26 |
+
|
| 27 |
+
# Google OAuth
|
| 28 |
+
google_client_id: str
|
| 29 |
+
jwt_secret: str
|
| 30 |
|
| 31 |
# MegaLLM (OpenAI-compatible)
|
| 32 |
megallm_api_key: str | None = None
|
app/mcp/tools/__init__.py
CHANGED
|
@@ -31,10 +31,15 @@ from app.mcp.tools.graph_tool import (
|
|
| 31 |
get_place_details,
|
| 32 |
get_nearby_by_relationship,
|
| 33 |
get_same_category_places,
|
| 34 |
-
geocode_location,
|
| 35 |
get_location_coordinates,
|
| 36 |
TOOL_DEFINITION as GRAPH_TOOL_DEFINITION,
|
| 37 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
# Combined tool definitions for agent
|
|
@@ -91,6 +96,13 @@ class MCPTools:
|
|
| 91 |
async def get_location_coordinates(self, location_name):
|
| 92 |
"""Get coordinates for a location (Neo4j + OSM fallback)."""
|
| 93 |
return await get_location_coordinates(location_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
# Global MCP tools instance
|
|
|
|
| 31 |
get_place_details,
|
| 32 |
get_nearby_by_relationship,
|
| 33 |
get_same_category_places,
|
|
|
|
| 34 |
get_location_coordinates,
|
| 35 |
TOOL_DEFINITION as GRAPH_TOOL_DEFINITION,
|
| 36 |
)
|
| 37 |
+
from app.mcp.tools.social_tool import (
|
| 38 |
+
SocialSearchResult,
|
| 39 |
+
search_social_media,
|
| 40 |
+
# TODO: Define TOOL_DEFINITION in social_tool if needed for agent JSON schema
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
|
| 44 |
|
| 45 |
# Combined tool definitions for agent
|
|
|
|
| 96 |
async def get_location_coordinates(self, location_name):
|
| 97 |
"""Get coordinates for a location (Neo4j + OSM fallback)."""
|
| 98 |
return await get_location_coordinates(location_name)
|
| 99 |
+
|
| 100 |
+
# Social Tool
|
| 101 |
+
async def search_social_media(self, query: str, limit: int = 10, freshness: str = "pw", platforms: list[str] = None) -> list[SocialSearchResult]:
|
| 102 |
+
"""Search for social media content (news, trends)."""
|
| 103 |
+
return await search_social_media(query, limit, freshness, platforms)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
|
| 107 |
|
| 108 |
# Global MCP tools instance
|
app/mcp/tools/social_tool.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import httpx
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Optional, List, Dict, Any
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class SocialSearchResult:
|
| 9 |
+
title: str
|
| 10 |
+
url: str
|
| 11 |
+
description: str
|
| 12 |
+
age: str = ""
|
| 13 |
+
platform: str = "Web"
|
| 14 |
+
|
| 15 |
+
class BraveSocialSearch:
|
| 16 |
+
"""
|
| 17 |
+
Native Python implementation of Brave Social Search.
|
| 18 |
+
Wraps Brave Search API with social media specific filters.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
BASE_URL = "https://api.search.brave.com/res/v1/web/search"
|
| 22 |
+
|
| 23 |
+
def __init__(self, api_key: str = None):
|
| 24 |
+
self.api_key = api_key or os.getenv("BRAVE_API_KEY")
|
| 25 |
+
if not self.api_key:
|
| 26 |
+
# Fallback or warning? For now assume it will be provided or env
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
async def search(self, query: str, limit: int = 10, freshness: str = "pw", platforms: List[str] = None) -> List[SocialSearchResult]:
|
| 30 |
+
if not self.api_key:
|
| 31 |
+
print("Warning: BRAVE_API_KEY not found.")
|
| 32 |
+
return []
|
| 33 |
+
|
| 34 |
+
headers = {
|
| 35 |
+
"Accept": "application/json",
|
| 36 |
+
"Accept-Encoding": "gzip",
|
| 37 |
+
"X-Subscription-Token": self.api_key
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Default social sites if none provided
|
| 41 |
+
if not platforms:
|
| 42 |
+
social_sites = [
|
| 43 |
+
'site:twitter.com', 'site:x.com',
|
| 44 |
+
'site:facebook.com',
|
| 45 |
+
'site:reddit.com',
|
| 46 |
+
'site:linkedin.com',
|
| 47 |
+
'site:tiktok.com',
|
| 48 |
+
'site:instagram.com',
|
| 49 |
+
'site:threads.net'
|
| 50 |
+
]
|
| 51 |
+
else:
|
| 52 |
+
# Map friendly names to site operators if needed, or assume raw site: checks
|
| 53 |
+
# Simplest is to assume user passes ["facebook", "reddit"] -> ["site:facebook.com", "site:reddit.com"]
|
| 54 |
+
# But let's be robust:
|
| 55 |
+
social_sites = []
|
| 56 |
+
for p in platforms:
|
| 57 |
+
p = p.lower()
|
| 58 |
+
if "facebook" in p: social_sites.append("site:facebook.com")
|
| 59 |
+
elif "reddit" in p: social_sites.append("site:reddit.com")
|
| 60 |
+
elif "twitter" in p or "x" == p: social_sites.extend(["site:twitter.com", "site:x.com"])
|
| 61 |
+
elif "linkedin" in p: social_sites.append("site:linkedin.com")
|
| 62 |
+
elif "tiktok" in p: social_sites.append("site:tiktok.com")
|
| 63 |
+
elif "instagram" in p: social_sites.append("site:instagram.com")
|
| 64 |
+
elif "site:" in p: social_sites.append(p) # Direct operator
|
| 65 |
+
|
| 66 |
+
# Construct query with site OR operator
|
| 67 |
+
if len(social_sites) > 1:
|
| 68 |
+
sites_query = " OR ".join(social_sites)
|
| 69 |
+
full_query = f"{query} ({sites_query})"
|
| 70 |
+
elif len(social_sites) == 1:
|
| 71 |
+
full_query = f"{query} {social_sites[0]}"
|
| 72 |
+
else:
|
| 73 |
+
full_query = query
|
| 74 |
+
|
| 75 |
+
params = {
|
| 76 |
+
"q": full_query,
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
"count": min(limit, 20),
|
| 80 |
+
"freshness": freshness,
|
| 81 |
+
"result_filter": "web,news,discussions",
|
| 82 |
+
"text_decorations": 0,
|
| 83 |
+
"spellcheck": 1
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async with httpx.AsyncClient() as client:
|
| 87 |
+
try:
|
| 88 |
+
response = await client.get(
|
| 89 |
+
self.BASE_URL,
|
| 90 |
+
headers=headers,
|
| 91 |
+
params=params,
|
| 92 |
+
timeout=10.0
|
| 93 |
+
)
|
| 94 |
+
response.raise_for_status()
|
| 95 |
+
data = response.json()
|
| 96 |
+
|
| 97 |
+
results = []
|
| 98 |
+
|
| 99 |
+
# Parse 'web' results (most common)
|
| 100 |
+
if "web" in data and "results" in data["web"]:
|
| 101 |
+
for item in data["web"]["results"]:
|
| 102 |
+
# Extract platform from profile or url
|
| 103 |
+
platform = "Web"
|
| 104 |
+
if "profile" in item and "name" in item["profile"]:
|
| 105 |
+
platform = item["profile"]["name"]
|
| 106 |
+
else:
|
| 107 |
+
# Simple heuristic
|
| 108 |
+
domain = item.get("url", "").split("//")[-1].split("/")[0]
|
| 109 |
+
if "reddit" in domain: platform = "Reddit"
|
| 110 |
+
elif "twitter" in domain or "x.com" in domain: platform = "X (Twitter)"
|
| 111 |
+
elif "facebook" in domain: platform = "Facebook"
|
| 112 |
+
|
| 113 |
+
results.append(SocialSearchResult(
|
| 114 |
+
title=item.get("title", ""),
|
| 115 |
+
url=item.get("url", ""),
|
| 116 |
+
description=item.get("description", ""),
|
| 117 |
+
age=item.get("age", ""),
|
| 118 |
+
platform=platform
|
| 119 |
+
))
|
| 120 |
+
|
| 121 |
+
return results
|
| 122 |
+
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"Error calling Brave Search API: {e}")
|
| 125 |
+
return []
|
| 126 |
+
|
| 127 |
+
# Singleton instance
|
| 128 |
+
social_search_tool = BraveSocialSearch()
|
| 129 |
+
|
| 130 |
+
async def search_social_media(query: str, limit: int = 10, freshness: str = "pw", platforms: List[str] = None) -> List[SocialSearchResult]:
|
| 131 |
+
"""
|
| 132 |
+
Search for social media content (news, discussions) about a topic.
|
| 133 |
+
"""
|
| 134 |
+
return await social_search_tool.search(query, limit, freshness, platforms)
|
| 135 |
+
|
docs/PROMPT_REPORT.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LocalMate Backend - Prompt Documentation Report
|
| 2 |
+
|
| 3 |
+
This document contains a comprehensive report of all LLM prompts used within the `localmate-danang-backend-v2` project.
|
| 4 |
+
|
| 5 |
+
## 1. MMCA Agent (Main Chatbot)
|
| 6 |
+
|
| 7 |
+
**File:** `app/agent/mmca_agent.py`
|
| 8 |
+
|
| 9 |
+
### System Prompt (`SYSTEM_PROMPT`)
|
| 10 |
+
This is the core instruction for the Multi-Modal Contextual Agent. It defines the available tools and decision-making rules.
|
| 11 |
+
|
| 12 |
+
```python
|
| 13 |
+
Bạn là trợ lý du lịch thông minh cho Đà Nẵng. Bạn có 3 công cụ tìm kiếm:
|
| 14 |
+
|
| 15 |
+
**1. retrieve_context_text** - Tìm kiếm văn bản thông minh
|
| 16 |
+
- Khi nào: Hỏi về menu, review, mô tả, đặc điểm, phong cách
|
| 17 |
+
- Ví dụ: "Phở ngon giá rẻ", "Quán cafe view đẹp", "Nơi lãng mạn hẹn hò"
|
| 18 |
+
- Đặc biệt: Tự động phát hiện category (cafe, pho, seafood...) và boost kết quả
|
| 19 |
+
|
| 20 |
+
**2. retrieve_similar_visuals** - Tìm theo hình ảnh
|
| 21 |
+
- Khi nào: Người dùng gửi ảnh hoặc mô tả về không gian, decor
|
| 22 |
+
- Scene filter: food, interior, exterior, view
|
| 23 |
+
- Ví dụ: "Quán có không gian giống ảnh này"
|
| 24 |
+
|
| 25 |
+
**3. find_nearby_places** - Tìm theo vị trí
|
| 26 |
+
- Khi nào: Hỏi về khoảng cách, "gần đây", "gần X", "quanh Y"
|
| 27 |
+
- Ví dụ: "Quán cafe gần Cầu Rồng", "Nhà hàng gần bãi biển Mỹ Khê"
|
| 28 |
+
- Đặc biệt: Có thể lấy chi tiết đầy đủ với photos, reviews
|
| 29 |
+
|
| 30 |
+
**4. search_social_media** - Tìm kiếm mạng xã hội và tin tức
|
| 31 |
+
- Khi nào: Hỏi về "review", "tin hot", "trend", "tiktok", "facebook", "tin mới"
|
| 32 |
+
- Ví dụ: "Review quán ăn ngon Đà Nẵng trên TikTok", "Tin hot tuần qua"
|
| 33 |
+
- Tham số: query, freshness ("pw": tuần, "pm": tháng), platforms (["tiktok", "facebook", "reddit"])
|
| 34 |
+
|
| 35 |
+
**Quy tắc quan trọng:**
|
| 36 |
+
1. Phân tích intent để chọn ĐÚNG tool (không chỉ dùng 1 tool)
|
| 37 |
+
2. Với câu hỏi tổng quát ("quán cafe ngon") → dùng retrieve_context_text
|
| 38 |
+
3. Với câu hỏi vị trí ("gần X", "quanh Y") → dùng find_nearby_places
|
| 39 |
+
4. Với câu hỏi trend/review từ MXH -> dùng search_social_media
|
| 40 |
+
5. Với ảnh → dùng retrieve_similar_visuals
|
| 41 |
+
6. Trả lời tiếng Việt, thân thiện, cung cấp thông tin cụ thể (tên, rating, khoảng cách)
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### Synthesis Prompt (`_synthesize_response`)
|
| 45 |
+
Used to generate the final natural language response based on tool outputs.
|
| 46 |
+
|
| 47 |
+
```python
|
| 48 |
+
{history_section}Dựa trên kết quả tìm kiếm sau, hãy trả lời câu hỏi của người dùng một cách tự nhiên và hữu ích.
|
| 49 |
+
|
| 50 |
+
Câu hỏi hiện tại: {message}
|
| 51 |
+
|
| 52 |
+
{context}
|
| 53 |
+
|
| 54 |
+
Hãy trả lời bằng tiếng Việt, thân thiện. Nếu có nhiều kết quả, hãy giới thiệu top 2-3 địa điểm phù hợp nhất.
|
| 55 |
+
Nếu có lịch sử hội thoại, hãy cân nhắc ngữ cảnh trước đó khi trả lời.
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## 2. ReAct Agent (Reasoning Engine)
|
| 61 |
+
|
| 62 |
+
**Files:** `app/agent/react_agent.py` and `app/agent/reasoning.py`
|
| 63 |
+
|
| 64 |
+
### System Prompt (`REACT_SYSTEM_PROMPT`)
|
| 65 |
+
**File:** `app/agent/reasoning.py`
|
| 66 |
+
Defines the multi-step reasoning capability.
|
| 67 |
+
|
| 68 |
+
````python
|
| 69 |
+
Bạn là agent du lịch thông minh cho Đà Nẵng với khả năng suy luận multi-step.
|
| 70 |
+
|
| 71 |
+
**Tools có sẵn:**
|
| 72 |
+
1. `get_location_coordinates` - Lấy tọa độ từ tên địa điểm
|
| 73 |
+
- Input: {"location_name": "Dragon Bridge"}
|
| 74 |
+
- Output: {"lat": 16.06, "lng": 108.22}
|
| 75 |
+
|
| 76 |
+
2. `find_nearby_places` - Tìm địa điểm gần vị trí
|
| 77 |
+
- Input: {"lat": 16.06, "lng": 108.22, "category": "cafe", "max_distance_km": 3}
|
| 78 |
+
- Output: [{name, category, distance_km, rating}]
|
| 79 |
+
|
| 80 |
+
3. `retrieve_context_text` - Tìm semantic trong reviews/descriptions
|
| 81 |
+
- Input: {"query": "cafe view đẹp", "limit": 5}
|
| 82 |
+
- Output: [{name, category, rating, source_text}]
|
| 83 |
+
|
| 84 |
+
4. `retrieve_similar_visuals` - Tìm địa điểm có hình ảnh tương tự
|
| 85 |
+
- Input: {"image_url": "..."}
|
| 86 |
+
- Output: [{name, similarity, image_url}]
|
| 87 |
+
|
| 88 |
+
5. `search_social_media` - Tìm kiếm mạng xã hội và tin tức
|
| 89 |
+
- Input: {"query": "review quán ăn", "freshness": "pw", "platforms": ["tiktok"]}
|
| 90 |
+
- Output: [{title, url, age, platform}]
|
| 91 |
+
|
| 92 |
+
**Quy trình:**
|
| 93 |
+
Với mỗi bước, bạn phải:
|
| 94 |
+
1. **Thought**: Suy nghĩ về bước tiếp theo cần làm
|
| 95 |
+
2. **Action**: Chọn tool hoặc "finish" nếu đủ thông tin
|
| 96 |
+
3. **Action Input**: JSON parameters cho tool
|
| 97 |
+
|
| 98 |
+
**Trả lời CHÍNH XÁC theo format JSON:**
|
| 99 |
+
```json
|
| 100 |
+
{
|
| 101 |
+
"thought": "Suy nghĩ của bạn...",
|
| 102 |
+
"action": "tool_name hoặc finish",
|
| 103 |
+
"action_input": {"param1": "value1"}
|
| 104 |
+
}
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
**Quan trọng:**
|
| 108 |
+
- Nếu cần biết vị trí → dùng get_location_coordinates trước
|
| 109 |
+
- Nếu tìm theo khoảng cách → dùng find_nearby_places
|
| 110 |
+
- Nếu tìm review/trend MXH → dùng search_social_media
|
| 111 |
+
- Nếu cần lọc theo đặc điểm (view, không gian, giá) → dùng retrieve_context_text
|
| 112 |
+
- Khi đủ thông tin → action = "finish"
|
| 113 |
+
````
|
| 114 |
+
|
| 115 |
+
### Reasoning Step Prompt (`build_reasoning_prompt`)
|
| 116 |
+
**File:** `app/agent/reasoning.py`
|
| 117 |
+
Dynamic prompt constructed at each step of the ReAct loop.
|
| 118 |
+
|
| 119 |
+
````python
|
| 120 |
+
**Câu hỏi của user:** {query}
|
| 121 |
+
{image_text}
|
| 122 |
+
{context_summary}
|
| 123 |
+
{steps_text}
|
| 124 |
+
**Bước tiếp theo là gì?**
|
| 125 |
+
|
| 126 |
+
Trả lời theo format JSON:
|
| 127 |
+
```json
|
| 128 |
+
{{
|
| 129 |
+
"thought": "...",
|
| 130 |
+
"action": "tool_name hoặc finish",
|
| 131 |
+
"action_input": {{...}}
|
| 132 |
+
}}
|
| 133 |
+
```
|
| 134 |
+
````
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 3. Tool Documentation
|
| 139 |
+
|
| 140 |
+
This section provides a reference for all available tools in the project.
|
| 141 |
+
|
| 142 |
+
| Tool Name | Description | Arguments | Recommended Use |
|
| 143 |
+
| :--- | :--- | :--- | :--- |
|
| 144 |
+
| **`retrieve_context_text`** | Semantic text search using vector embeddings. | `query` (str), `limit` (int) | General queries about place descriptions, reviews, or vague characteristics (e.g., "romance", "good for work"). |
|
| 145 |
+
| **`retrieve_similar_visuals`** | Visual similarity search using CLIP embeddings. | `image_url` (str) or `image_bytes`, `limit` (int) | When the user provides an image or asks to find places looking like X. |
|
| 146 |
+
| **`find_nearby_places`** | Spatial search using Neo4j and Haversine distance. | `lat` (float), `lng` (float), `max_distance_km` (float), `category` (str) | Proximity queries (e.g., "near Dragon Bridge", "around here"). |
|
| 147 |
+
| **`get_location_coordinates`** | Geocoding service (Nominatim + Neo4j fallback). | `location_name` (str) | To convert a location string to lat/lng before searching nearby. |
|
| 148 |
+
| **`search_social_media`** | **[NEW]** Real-time social media and news search via Brave API. | `query` (str), `freshness` (str: "pw", "pm"), `platforms` (list[str]) | Retrieving recent reviews, trending topics, or content from specific platforms like TikTok, Reddit, Facebook. |
|
scripts/verify_social.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Add app to path
|
| 8 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 9 |
+
|
| 10 |
+
from app.mcp.tools import mcp_tools
|
| 11 |
+
|
| 12 |
+
async def main():
|
| 13 |
+
api_key = os.getenv("BRAVE_API_KEY")
|
| 14 |
+
if not api_key:
|
| 15 |
+
print("Error: BRAVE_API_KEY not set")
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
print("Testing Social Search Integration...")
|
| 19 |
+
try:
|
| 20 |
+
results = await mcp_tools.search_social_media("AI Trends", limit=3)
|
| 21 |
+
print(f"Found {len(results)} results:")
|
| 22 |
+
for r in results:
|
| 23 |
+
print(f"- [{r.platform}] {r.title} ({r.age})")
|
| 24 |
+
print(f" Url: {r.url}")
|
| 25 |
+
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"Error: {e}")
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
asyncio.run(main())
|