fix intent and add plan
Browse files- app/agent/mmca_agent.py +122 -120
- app/itineraries/router.py +154 -0
- app/mcp/tools/__init__.py +1 -4
- app/mcp/tools/social_tool.py +168 -31
- app/mcp/tools/text_tool.py +2 -22
- app/planner/models.py +60 -0
- app/planner/router.py +124 -0
- app/planner/smart_plan.py +401 -0
- app/shared/prompts/__init__.py +10 -4
- app/shared/prompts/prompts.py +172 -29
- docs/API_REFERENCE.md +129 -0
- tests/react_comparison_report.md +34 -41
- tests/test_react_comparison.py +9 -5
app/agent/mmca_agent.py
CHANGED
|
@@ -24,7 +24,9 @@ from app.shared.logger import agent_logger, AgentWorkflow, WorkflowStep
|
|
| 24 |
from app.shared.prompts import (
|
| 25 |
MMCA_SYSTEM_PROMPT as SYSTEM_PROMPT,
|
| 26 |
GREETING_SYSTEM_PROMPT,
|
|
|
|
| 27 |
build_greeting_prompt,
|
|
|
|
| 28 |
build_synthesis_prompt,
|
| 29 |
)
|
| 30 |
|
|
@@ -133,19 +135,24 @@ class MMCAAgent:
|
|
| 133 |
# Add user message to internal history
|
| 134 |
self.conversation_history.append(ChatMessage(role="user", content=message))
|
| 135 |
|
| 136 |
-
# Step 1: Analyze intent and
|
| 137 |
workflow.add_step(WorkflowStep(
|
| 138 |
step_name="Intent Analysis",
|
| 139 |
purpose="Phân tích câu hỏi để chọn tool phù hợp"
|
| 140 |
))
|
| 141 |
|
| 142 |
agent_logger.workflow_step("Step 1: Intent Analysis", message[:80])
|
| 143 |
-
intent = self._detect_intent(message, image_url)
|
| 144 |
-
workflow.intent_detected = intent
|
| 145 |
-
agent_logger.workflow_step("Intent detected", intent)
|
| 146 |
|
| 147 |
tool_calls = await self._plan_tool_calls(message, image_url)
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
workflow.add_step(WorkflowStep(
|
| 150 |
step_name="Tool Planning",
|
| 151 |
purpose=f"Chọn {len(tool_calls)} tool(s) để thực thi",
|
|
@@ -217,27 +224,6 @@ class MMCAAgent:
|
|
| 217 |
selected_place_ids=selected_place_ids,
|
| 218 |
)
|
| 219 |
|
| 220 |
-
def _detect_intent(self, message: str, image_url: str | None) -> str:
|
| 221 |
-
"""Detect user intent for logging."""
|
| 222 |
-
intents = []
|
| 223 |
-
|
| 224 |
-
if image_url:
|
| 225 |
-
intents.append("visual_search")
|
| 226 |
-
|
| 227 |
-
location_keywords = ["gần", "cách", "nearby", "gần đây", "quanh", "xung quanh"]
|
| 228 |
-
if any(kw in message.lower() for kw in location_keywords):
|
| 229 |
-
intents.append("location_search")
|
| 230 |
-
|
| 231 |
-
if not intents:
|
| 232 |
-
intents.append("text_search")
|
| 233 |
-
|
| 234 |
-
# Social intent detection
|
| 235 |
-
social_keywords = ["review", "tin hot", "trend", "tin mới", "tiktok", "facebook", "reddit", "youtube", "mạng xã hội"]
|
| 236 |
-
if any(kw in message.lower() for kw in social_keywords):
|
| 237 |
-
intents.append("social_search")
|
| 238 |
-
|
| 239 |
-
return " + ".join(intents)
|
| 240 |
-
|
| 241 |
def _get_tool_purpose(self, tool_name: str) -> str:
|
| 242 |
"""Get human-readable purpose for tool."""
|
| 243 |
purposes = {
|
|
@@ -248,116 +234,116 @@ class MMCAAgent:
|
|
| 248 |
}
|
| 249 |
return purposes.get(tool_name, tool_name)
|
| 250 |
|
| 251 |
-
def _is_greeting_or_simple_query(self, message: str) -> bool:
|
| 252 |
-
"""
|
| 253 |
-
Check if message is a simple greeting/small-talk that doesn't need tools.
|
| 254 |
-
|
| 255 |
-
Returns True for greetings, thanks, simple acknowledgments.
|
| 256 |
-
"""
|
| 257 |
-
simple_patterns = [
|
| 258 |
-
# English
|
| 259 |
-
"hello", "hi", "hey", "yo", "sup",
|
| 260 |
-
"thank", "thanks", "bye", "goodbye",
|
| 261 |
-
"ok", "okay", "yes", "no", "good", "great", "nice",
|
| 262 |
-
# Vietnamese
|
| 263 |
-
"xin chào", "chào", "chào bạn", "ê", "alo",
|
| 264 |
-
"cảm ơn", "cám ơn", "thanks", "tạm biệt", "bye",
|
| 265 |
-
"ok", "được", "tốt", "hay", "ừ", "ờ", "vâng", "dạ",
|
| 266 |
-
]
|
| 267 |
-
msg_lower = message.lower().strip()
|
| 268 |
-
|
| 269 |
-
# Very short messages are likely greetings
|
| 270 |
-
if len(msg_lower) < 15:
|
| 271 |
-
for pattern in simple_patterns:
|
| 272 |
-
if pattern in msg_lower:
|
| 273 |
-
return True
|
| 274 |
-
# Also check if message is just a single word greeting
|
| 275 |
-
if msg_lower in simple_patterns:
|
| 276 |
-
return True
|
| 277 |
-
|
| 278 |
-
return False
|
| 279 |
-
|
| 280 |
async def _plan_tool_calls(
|
| 281 |
self,
|
| 282 |
message: str,
|
| 283 |
image_url: str | None = None,
|
| 284 |
) -> list[ToolCall]:
|
| 285 |
"""
|
| 286 |
-
|
| 287 |
|
| 288 |
Returns list of ToolCall objects with tool_name and arguments.
|
| 289 |
-
Returns empty list for
|
| 290 |
"""
|
| 291 |
-
#
|
| 292 |
-
if self._is_greeting_or_simple_query(message) and not image_url:
|
| 293 |
-
agent_logger.workflow_step("Greeting detected", "Skipping tools")
|
| 294 |
-
return []
|
| 295 |
-
|
| 296 |
-
tool_calls = []
|
| 297 |
-
|
| 298 |
-
# If image is provided, always use visual search
|
| 299 |
if image_url:
|
| 300 |
-
|
| 301 |
tool_name="retrieve_similar_visuals",
|
| 302 |
arguments={"image_url": image_url, "limit": 5},
|
| 303 |
-
)
|
| 304 |
-
|
| 305 |
-
#
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
| 312 |
|
| 313 |
-
|
| 314 |
-
platforms = []
|
| 315 |
-
for p in ["tiktok", "facebook", "reddit", "youtube", "twitter", "instagram"]:
|
| 316 |
-
if p in message.lower():
|
| 317 |
-
platforms.append(p)
|
| 318 |
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
))
|
| 328 |
-
|
| 329 |
-
# Analyze message for location/proximity queries
|
| 330 |
-
location_keywords = ["gần", "cách", "nearby", "gần đây", "quanh", "xung quanh"]
|
| 331 |
-
if any(kw in message.lower() for kw in location_keywords):
|
| 332 |
-
# Extract location name from message
|
| 333 |
-
location = self._extract_location(message)
|
| 334 |
-
category = self._extract_category(message)
|
| 335 |
-
|
| 336 |
-
# Get coordinates for the location
|
| 337 |
-
coords = await self.tools.get_location_coordinates(location) if location else None
|
| 338 |
-
lat, lng = coords if coords else DANANG_CENTER
|
| 339 |
-
|
| 340 |
-
tool_calls.append(ToolCall(
|
| 341 |
-
tool_name="find_nearby_places",
|
| 342 |
-
arguments={
|
| 343 |
-
"lat": lat,
|
| 344 |
-
"lng": lng,
|
| 345 |
-
"category": category,
|
| 346 |
-
"max_distance_km": 3.0,
|
| 347 |
-
"limit": 5,
|
| 348 |
-
},
|
| 349 |
-
))
|
| 350 |
-
|
| 351 |
-
# For general queries without location keywords AND NO SOCIAL INTENT, use text search
|
| 352 |
-
# If social search is already triggered, we might skip text search to avoid noise,
|
| 353 |
-
# or keep it if query is mixed. For now, let's keep text search only if no other tools used.
|
| 354 |
-
if not tool_calls:
|
| 355 |
-
tool_calls.append(ToolCall(
|
| 356 |
tool_name="retrieve_context_text",
|
| 357 |
arguments={"query": message, "limit": 5},
|
| 358 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
-
return tool_calls
|
| 361 |
|
| 362 |
async def _execute_tool(
|
| 363 |
self,
|
|
@@ -405,9 +391,25 @@ class MMCAAgent:
|
|
| 405 |
]
|
| 406 |
|
| 407 |
elif tool_call.tool_name == "find_nearby_places":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
results = await self.tools.find_nearby_places(
|
| 409 |
-
lat=
|
| 410 |
-
lng=
|
| 411 |
max_distance_km=tool_call.arguments.get("max_distance_km", 5.0),
|
| 412 |
category=tool_call.arguments.get("category"),
|
| 413 |
limit=tool_call.arguments.get("limit", 10),
|
|
|
|
| 24 |
from app.shared.prompts import (
|
| 25 |
MMCA_SYSTEM_PROMPT as SYSTEM_PROMPT,
|
| 26 |
GREETING_SYSTEM_PROMPT,
|
| 27 |
+
INTENT_SYSTEM_PROMPT,
|
| 28 |
build_greeting_prompt,
|
| 29 |
+
build_intent_prompt,
|
| 30 |
build_synthesis_prompt,
|
| 31 |
)
|
| 32 |
|
|
|
|
| 135 |
# Add user message to internal history
|
| 136 |
self.conversation_history.append(ChatMessage(role="user", content=message))
|
| 137 |
|
| 138 |
+
# Step 1: Analyze intent and plan tools (LLM-based)
|
| 139 |
workflow.add_step(WorkflowStep(
|
| 140 |
step_name="Intent Analysis",
|
| 141 |
purpose="Phân tích câu hỏi để chọn tool phù hợp"
|
| 142 |
))
|
| 143 |
|
| 144 |
agent_logger.workflow_step("Step 1: Intent Analysis", message[:80])
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
tool_calls = await self._plan_tool_calls(message, image_url)
|
| 147 |
|
| 148 |
+
# Set intent based on selected tools (from LLM)
|
| 149 |
+
if not tool_calls:
|
| 150 |
+
intent = "greeting"
|
| 151 |
+
else:
|
| 152 |
+
intent = " + ".join([tc.tool_name for tc in tool_calls])
|
| 153 |
+
workflow.intent_detected = intent
|
| 154 |
+
agent_logger.workflow_step("Intent detected", intent)
|
| 155 |
+
|
| 156 |
workflow.add_step(WorkflowStep(
|
| 157 |
step_name="Tool Planning",
|
| 158 |
purpose=f"Chọn {len(tool_calls)} tool(s) để thực thi",
|
|
|
|
| 224 |
selected_place_ids=selected_place_ids,
|
| 225 |
)
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
def _get_tool_purpose(self, tool_name: str) -> str:
|
| 228 |
"""Get human-readable purpose for tool."""
|
| 229 |
purposes = {
|
|
|
|
| 234 |
}
|
| 235 |
return purposes.get(tool_name, tool_name)
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
async def _plan_tool_calls(
|
| 238 |
self,
|
| 239 |
message: str,
|
| 240 |
image_url: str | None = None,
|
| 241 |
) -> list[ToolCall]:
|
| 242 |
"""
|
| 243 |
+
Use LLM to analyze message and plan which tools to call.
|
| 244 |
|
| 245 |
Returns list of ToolCall objects with tool_name and arguments.
|
| 246 |
+
Returns empty list for greetings/small-talk (LLM detects via is_greeting).
|
| 247 |
"""
|
| 248 |
+
# If image is provided, always use visual search (fast path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
if image_url:
|
| 250 |
+
return [ToolCall(
|
| 251 |
tool_name="retrieve_similar_visuals",
|
| 252 |
arguments={"image_url": image_url, "limit": 5},
|
| 253 |
+
)]
|
| 254 |
+
|
| 255 |
+
# Use LLM to detect intent and select tools
|
| 256 |
+
intent_prompt = build_intent_prompt(message, has_image=bool(image_url))
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
intent_response = await self.llm_client.generate(
|
| 260 |
+
prompt=intent_prompt,
|
| 261 |
+
temperature=0.2, # Low temperature for consistent JSON
|
| 262 |
+
system_instruction=INTENT_SYSTEM_PROMPT,
|
| 263 |
+
)
|
| 264 |
|
| 265 |
+
agent_logger.workflow_step("LLM Intent Detection", intent_response[:200])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
+
# Parse JSON response
|
| 268 |
+
tool_calls = self._parse_intent_response(intent_response, message)
|
| 269 |
+
return tool_calls
|
| 270 |
+
|
| 271 |
+
except Exception as e:
|
| 272 |
+
agent_logger.error(f"Intent detection failed: {e}", None)
|
| 273 |
+
# Fallback to text search
|
| 274 |
+
return [ToolCall(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
tool_name="retrieve_context_text",
|
| 276 |
arguments={"query": message, "limit": 5},
|
| 277 |
+
)]
|
| 278 |
+
|
| 279 |
+
def _parse_intent_response(self, response: str, original_message: str) -> list[ToolCall]:
|
| 280 |
+
"""Parse LLM intent detection response into ToolCall list."""
|
| 281 |
+
try:
|
| 282 |
+
# Extract JSON from response
|
| 283 |
+
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', response, re.DOTALL)
|
| 284 |
+
if json_match:
|
| 285 |
+
response = json_match.group(1)
|
| 286 |
+
|
| 287 |
+
# Find JSON object
|
| 288 |
+
json_start = response.find('{')
|
| 289 |
+
json_end = response.rfind('}')
|
| 290 |
+
if json_start != -1 and json_end != -1:
|
| 291 |
+
response = response[json_start:json_end + 1]
|
| 292 |
+
|
| 293 |
+
data = json.loads(response)
|
| 294 |
+
|
| 295 |
+
# Check if greeting
|
| 296 |
+
if data.get("is_greeting", False):
|
| 297 |
+
return []
|
| 298 |
+
|
| 299 |
+
# Parse tools
|
| 300 |
+
tools = data.get("tools", [])
|
| 301 |
+
tool_calls = []
|
| 302 |
+
|
| 303 |
+
for tool in tools:
|
| 304 |
+
name = tool.get("name")
|
| 305 |
+
arguments = tool.get("arguments", {})
|
| 306 |
+
|
| 307 |
+
# Validate tool name
|
| 308 |
+
valid_tools = ["retrieve_context_text", "find_nearby_places",
|
| 309 |
+
"search_social_media", "retrieve_similar_visuals"]
|
| 310 |
+
if name not in valid_tools:
|
| 311 |
+
continue
|
| 312 |
+
|
| 313 |
+
# Ensure required arguments
|
| 314 |
+
if name == "retrieve_context_text":
|
| 315 |
+
arguments.setdefault("query", original_message)
|
| 316 |
+
arguments.setdefault("limit", 5)
|
| 317 |
+
elif name == "find_nearby_places":
|
| 318 |
+
# Need to geocode location if provided
|
| 319 |
+
location = arguments.get("location", "")
|
| 320 |
+
arguments.setdefault("max_distance_km", 3.0)
|
| 321 |
+
arguments.setdefault("limit", 5)
|
| 322 |
+
# Will handle geocoding in execute step
|
| 323 |
+
elif name == "search_social_media":
|
| 324 |
+
arguments.setdefault("query", original_message)
|
| 325 |
+
arguments.setdefault("limit", 5)
|
| 326 |
+
arguments.setdefault("freshness", "pw")
|
| 327 |
+
|
| 328 |
+
tool_calls.append(ToolCall(tool_name=name, arguments=arguments))
|
| 329 |
+
|
| 330 |
+
# If no tools selected, default to text search
|
| 331 |
+
if not tool_calls:
|
| 332 |
+
tool_calls.append(ToolCall(
|
| 333 |
+
tool_name="retrieve_context_text",
|
| 334 |
+
arguments={"query": original_message, "limit": 5},
|
| 335 |
+
))
|
| 336 |
+
|
| 337 |
+
return tool_calls
|
| 338 |
+
|
| 339 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 340 |
+
agent_logger.error(f"Failed to parse intent JSON: {e}", None)
|
| 341 |
+
# Fallback to text search
|
| 342 |
+
return [ToolCall(
|
| 343 |
+
tool_name="retrieve_context_text",
|
| 344 |
+
arguments={"query": original_message, "limit": 5},
|
| 345 |
+
)]
|
| 346 |
|
|
|
|
| 347 |
|
| 348 |
async def _execute_tool(
|
| 349 |
self,
|
|
|
|
| 391 |
]
|
| 392 |
|
| 393 |
elif tool_call.tool_name == "find_nearby_places":
|
| 394 |
+
# Handle geocoding if location name provided instead of lat/lng
|
| 395 |
+
lat = tool_call.arguments.get("lat")
|
| 396 |
+
lng = tool_call.arguments.get("lng")
|
| 397 |
+
|
| 398 |
+
if lat is None or lng is None:
|
| 399 |
+
# Try to geocode from location name
|
| 400 |
+
location = tool_call.arguments.get("location", "")
|
| 401 |
+
if location:
|
| 402 |
+
coords = await self.tools.get_location_coordinates(location)
|
| 403 |
+
if coords:
|
| 404 |
+
lat, lng = coords
|
| 405 |
+
else:
|
| 406 |
+
lat, lng = DANANG_CENTER
|
| 407 |
+
else:
|
| 408 |
+
lat, lng = DANANG_CENTER
|
| 409 |
+
|
| 410 |
results = await self.tools.find_nearby_places(
|
| 411 |
+
lat=lat,
|
| 412 |
+
lng=lng,
|
| 413 |
max_distance_km=tool_call.arguments.get("max_distance_km", 5.0),
|
| 414 |
category=tool_call.arguments.get("category"),
|
| 415 |
limit=tool_call.arguments.get("limit", 10),
|
app/itineraries/router.py
CHANGED
|
@@ -588,3 +588,157 @@ async def optimize_itinerary_route(
|
|
| 588 |
"stop_count": len(stops),
|
| 589 |
"note": "TSP optimization will be implemented in future update"
|
| 590 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
"stop_count": len(stops),
|
| 589 |
"note": "TSP optimization will be implemented in future update"
|
| 590 |
}
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
# ==================== SMART PLAN ENDPOINT ====================
|
| 594 |
+
|
| 595 |
+
from app.planner.models import (
|
| 596 |
+
GetPlanRequest,
|
| 597 |
+
GetPlanResponse,
|
| 598 |
+
SmartPlanResponse,
|
| 599 |
+
DayPlanResponse,
|
| 600 |
+
PlaceDetailResponse,
|
| 601 |
+
)
|
| 602 |
+
from app.planner.smart_plan import smart_plan_service
|
| 603 |
+
import time as time_module
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
@router.post(
|
| 607 |
+
"/{itinerary_id}/get-plan",
|
| 608 |
+
response_model=GetPlanResponse,
|
| 609 |
+
summary="Generate Smart Plan from Itinerary",
|
| 610 |
+
description="""
|
| 611 |
+
Generates an optimized, enriched plan from an itinerary with:
|
| 612 |
+
- Social media research for each stop
|
| 613 |
+
- Optimal timing (e.g., Dragon Bridge at 21h for fire show)
|
| 614 |
+
- Tips and highlights per place
|
| 615 |
+
- Multi-day organization
|
| 616 |
+
""",
|
| 617 |
+
)
|
| 618 |
+
async def get_itinerary_smart_plan(
|
| 619 |
+
itinerary_id: str,
|
| 620 |
+
request: GetPlanRequest = GetPlanRequest(),
|
| 621 |
+
user_id: str = Query(..., description="User ID"),
|
| 622 |
+
db: AsyncSession = Depends(get_db),
|
| 623 |
+
) -> GetPlanResponse:
|
| 624 |
+
"""
|
| 625 |
+
Generate a smart, enriched travel plan from an itinerary.
|
| 626 |
+
|
| 627 |
+
Uses Social Media Tool to research each place and LLM to optimize
|
| 628 |
+
timing based on Da Nang local knowledge.
|
| 629 |
+
"""
|
| 630 |
+
start_time = time_module.time()
|
| 631 |
+
|
| 632 |
+
# Validate UUIDs
|
| 633 |
+
validate_uuid(user_id, "user_id")
|
| 634 |
+
validate_uuid(itinerary_id, "itinerary_id")
|
| 635 |
+
|
| 636 |
+
# Get itinerary with stops
|
| 637 |
+
result = await db.execute(
|
| 638 |
+
text("""
|
| 639 |
+
SELECT id, title, total_days, start_date
|
| 640 |
+
FROM itineraries
|
| 641 |
+
WHERE id = :id AND user_id = :user_id
|
| 642 |
+
"""),
|
| 643 |
+
{"id": itinerary_id, "user_id": user_id}
|
| 644 |
+
)
|
| 645 |
+
row = result.fetchone()
|
| 646 |
+
|
| 647 |
+
if not row:
|
| 648 |
+
raise HTTPException(status_code=404, detail="Itinerary not found")
|
| 649 |
+
|
| 650 |
+
# Get all stops with snapshots
|
| 651 |
+
stops_result = await db.execute(
|
| 652 |
+
text("""
|
| 653 |
+
SELECT id, place_id, day_index, order_index, snapshot
|
| 654 |
+
FROM itinerary_stops
|
| 655 |
+
WHERE itinerary_id = :itinerary_id
|
| 656 |
+
ORDER BY day_index, order_index
|
| 657 |
+
"""),
|
| 658 |
+
{"itinerary_id": itinerary_id}
|
| 659 |
+
)
|
| 660 |
+
stops = stops_result.fetchall()
|
| 661 |
+
|
| 662 |
+
if len(stops) == 0:
|
| 663 |
+
raise HTTPException(status_code=400, detail="Itinerary has no stops. Add stops first.")
|
| 664 |
+
|
| 665 |
+
# Convert stops to places format
|
| 666 |
+
places = []
|
| 667 |
+
for stop in stops:
|
| 668 |
+
snapshot = stop.snapshot or {}
|
| 669 |
+
places.append({
|
| 670 |
+
"place_id": stop.place_id,
|
| 671 |
+
"name": snapshot.get("name", f"Place {stop.place_id[:8]}"),
|
| 672 |
+
"category": snapshot.get("category", ""),
|
| 673 |
+
"lat": snapshot.get("lat", 0.0),
|
| 674 |
+
"lng": snapshot.get("lng", 0.0),
|
| 675 |
+
"rating": snapshot.get("rating"),
|
| 676 |
+
})
|
| 677 |
+
|
| 678 |
+
# Generate smart plan
|
| 679 |
+
smart_plan = await smart_plan_service.generate_smart_plan(
|
| 680 |
+
places=places,
|
| 681 |
+
title=row.title,
|
| 682 |
+
itinerary_id=str(row.id),
|
| 683 |
+
total_days=row.total_days,
|
| 684 |
+
start_date=row.start_date,
|
| 685 |
+
include_social_research=request.include_social_research,
|
| 686 |
+
freshness=request.freshness,
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
# Count social research results
|
| 690 |
+
research_count = sum(
|
| 691 |
+
len(p.social_mentions)
|
| 692 |
+
for day in smart_plan.days
|
| 693 |
+
for p in day.places
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
generation_time = (time_module.time() - start_time) * 1000
|
| 697 |
+
|
| 698 |
+
# Convert to Pydantic response
|
| 699 |
+
days_response = []
|
| 700 |
+
for day in smart_plan.days:
|
| 701 |
+
places_response = [
|
| 702 |
+
PlaceDetailResponse(
|
| 703 |
+
place_id=p.place_id,
|
| 704 |
+
name=p.name,
|
| 705 |
+
category=p.category,
|
| 706 |
+
lat=p.lat,
|
| 707 |
+
lng=p.lng,
|
| 708 |
+
recommended_time=p.recommended_time,
|
| 709 |
+
suggested_duration_min=p.suggested_duration_min,
|
| 710 |
+
tips=p.tips,
|
| 711 |
+
highlights=p.highlights,
|
| 712 |
+
social_mentions=p.social_mentions,
|
| 713 |
+
order=p.order,
|
| 714 |
+
)
|
| 715 |
+
for p in day.places
|
| 716 |
+
]
|
| 717 |
+
days_response.append(
|
| 718 |
+
DayPlanResponse(
|
| 719 |
+
day_index=day.day_index,
|
| 720 |
+
date=str(day.date) if day.date else None,
|
| 721 |
+
places=places_response,
|
| 722 |
+
day_summary=day.day_summary,
|
| 723 |
+
day_distance_km=day.day_distance_km,
|
| 724 |
+
)
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
plan_response = SmartPlanResponse(
|
| 728 |
+
itinerary_id=smart_plan.itinerary_id,
|
| 729 |
+
title=smart_plan.title,
|
| 730 |
+
total_days=smart_plan.total_days,
|
| 731 |
+
days=days_response,
|
| 732 |
+
summary=smart_plan.summary,
|
| 733 |
+
total_distance_km=smart_plan.total_distance_km,
|
| 734 |
+
estimated_total_duration_min=smart_plan.estimated_total_duration_min,
|
| 735 |
+
generated_at=smart_plan.generated_at,
|
| 736 |
+
)
|
| 737 |
+
|
| 738 |
+
return GetPlanResponse(
|
| 739 |
+
plan=plan_response,
|
| 740 |
+
research_count=research_count,
|
| 741 |
+
generation_time_ms=round(generation_time, 2),
|
| 742 |
+
message=f"Smart plan generated for {row.total_days}-day itinerary with {research_count} social mentions",
|
| 743 |
+
)
|
| 744 |
+
|
app/mcp/tools/__init__.py
CHANGED
|
@@ -9,10 +9,7 @@ Tools:
|
|
| 9 |
from app.mcp.tools.text_tool import (
|
| 10 |
TextSearchResult,
|
| 11 |
retrieve_context_text,
|
| 12 |
-
detect_category_intent,
|
| 13 |
TOOL_DEFINITION as TEXT_TOOL_DEFINITION,
|
| 14 |
-
CATEGORY_KEYWORDS,
|
| 15 |
-
CATEGORY_TO_DB,
|
| 16 |
)
|
| 17 |
from app.mcp.tools.visual_tool import (
|
| 18 |
ImageSearchResult,
|
|
@@ -111,7 +108,7 @@ mcp_tools = MCPTools()
|
|
| 111 |
|
| 112 |
|
| 113 |
# Re-export for convenience
|
| 114 |
-
|
| 115 |
"MCPTools",
|
| 116 |
"mcp_tools",
|
| 117 |
"TextSearchResult",
|
|
|
|
| 9 |
from app.mcp.tools.text_tool import (
|
| 10 |
TextSearchResult,
|
| 11 |
retrieve_context_text,
|
|
|
|
| 12 |
TOOL_DEFINITION as TEXT_TOOL_DEFINITION,
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
from app.mcp.tools.visual_tool import (
|
| 15 |
ImageSearchResult,
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
# Re-export for convenience
|
| 111 |
+
_all_ = [
|
| 112 |
"MCPTools",
|
| 113 |
"mcp_tools",
|
| 114 |
"TextSearchResult",
|
app/mcp/tools/social_tool.py
CHANGED
|
@@ -1,16 +1,8 @@
|
|
| 1 |
-
|
| 2 |
import os
|
| 3 |
import httpx
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from typing import Optional, List, Dict, Any
|
| 6 |
|
| 7 |
-
from dotenv import load_dotenv
|
| 8 |
-
load_dotenv() # Ensure .env is loaded before os.getenv()
|
| 9 |
-
|
| 10 |
-
# Tool definition for agent - imported from centralized prompts
|
| 11 |
-
from app.shared.prompts import SEARCH_SOCIAL_MEDIA_TOOL as TOOL_DEFINITION
|
| 12 |
-
|
| 13 |
-
|
| 14 |
@dataclass
|
| 15 |
class SocialSearchResult:
|
| 16 |
title: str
|
|
@@ -18,6 +10,67 @@ class SocialSearchResult:
|
|
| 18 |
description: str
|
| 19 |
age: str = ""
|
| 20 |
platform: str = "Web"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
class BraveSocialSearch:
|
| 23 |
"""
|
|
@@ -33,11 +86,35 @@ class BraveSocialSearch:
|
|
| 33 |
# Fallback or warning? For now assume it will be provided or env
|
| 34 |
pass
|
| 35 |
|
| 36 |
-
async def search(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
if not self.api_key:
|
| 38 |
print("Warning: BRAVE_API_KEY not found.")
|
| 39 |
return []
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
headers = {
|
| 42 |
"Accept": "application/json",
|
| 43 |
"Accept-Encoding": "gzip",
|
|
@@ -46,26 +123,35 @@ class BraveSocialSearch:
|
|
| 46 |
|
| 47 |
# Default social sites if none provided
|
| 48 |
if not platforms:
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
else:
|
| 53 |
-
|
| 54 |
-
platform_keywords = []
|
| 55 |
for p in platforms:
|
| 56 |
p = p.lower()
|
| 57 |
-
if "facebook" in p:
|
| 58 |
-
elif "reddit" in p:
|
| 59 |
-
elif "twitter" in p or "x" == p:
|
| 60 |
-
elif "linkedin" in p:
|
| 61 |
-
elif "tiktok" in p:
|
| 62 |
-
elif "instagram" in p:
|
| 63 |
-
elif "
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
params = {
|
| 71 |
"q": full_query,
|
|
@@ -97,11 +183,14 @@ class BraveSocialSearch:
|
|
| 97 |
if "profile" in item and "name" in item["profile"]:
|
| 98 |
platform = item["profile"]["name"]
|
| 99 |
else:
|
| 100 |
-
#
|
| 101 |
domain = item.get("url", "").split("//")[-1].split("/")[0]
|
| 102 |
if "reddit" in domain: platform = "Reddit"
|
| 103 |
elif "twitter" in domain or "x.com" in domain: platform = "X (Twitter)"
|
| 104 |
elif "facebook" in domain: platform = "Facebook"
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
results.append(SocialSearchResult(
|
| 107 |
title=item.get("title", ""),
|
|
@@ -117,12 +206,60 @@ class BraveSocialSearch:
|
|
| 117 |
print(f"Error calling Brave Search API: {e}")
|
| 118 |
return []
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
# Singleton instance
|
| 121 |
social_search_tool = BraveSocialSearch()
|
| 122 |
|
| 123 |
-
async def search_social_media(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
"""
|
| 125 |
-
Search for social media content (news, discussions)
|
| 126 |
"""
|
| 127 |
-
return await social_search_tool.search(
|
| 128 |
-
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import httpx
|
| 3 |
from dataclasses import dataclass
|
| 4 |
from typing import Optional, List, Dict, Any
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
@dataclass
|
| 7 |
class SocialSearchResult:
|
| 8 |
title: str
|
|
|
|
| 10 |
description: str
|
| 11 |
age: str = ""
|
| 12 |
platform: str = "Web"
|
| 13 |
+
|
| 14 |
+
# Da Nang & Vietnam Travel Keywords
|
| 15 |
+
DANANG_KEYWORDS = {
|
| 16 |
+
'beaches': ['mỹ khê', 'my khe', 'non nuoc', 'bai bien'],
|
| 17 |
+
'attractions': ['bà nà', 'ba na', 'bana hills', 'cầu vàng', 'golden bridge',
|
| 18 |
+
'hội an', 'hoi an', 'marble mountains', 'ngũ hành sơn'],
|
| 19 |
+
'food': ['mì quảng', 'mi quang', 'bún chả cá', 'banh xeo', 'cao lầu'],
|
| 20 |
+
'activities': ['surfing', 'diving', 'lặn', 'du lịch'],
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
VIETNAM_HASHTAGS = {
|
| 24 |
+
'general': ['#vietnam', '#vietnamtravel', '#travelvietnam', '#explorevietnam'],
|
| 25 |
+
'danang': ['#danang', '#danangcity', '#danangtravel', '#traveldanang', '#danangtrip'],
|
| 26 |
+
'attractions': ['#banahills', '#hoian', '#halongbay', '#hanoi', '#saigon'],
|
| 27 |
+
'travel': ['#travel', '#wanderlust', '#traveltiktok', '#adventure', '#explore'],
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
TIKTOK_TRAVEL_HASHTAGS = [
|
| 31 |
+
'#traveltiktok', '#travelgram', '#instatravel', '#bucketlist',
|
| 32 |
+
'#vietnamtravel', '#southeastasia', '#travel', '#adventure'
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
def enhance_travel_query(
|
| 36 |
+
query: str,
|
| 37 |
+
location: str = "danang",
|
| 38 |
+
platform: str = None,
|
| 39 |
+
enhance: bool = True
|
| 40 |
+
) -> str:
|
| 41 |
+
"""
|
| 42 |
+
Enhance query với travel-specific keywords và hashtags.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
query: Original search query
|
| 46 |
+
location: Primary location (danang, vietnam, hoian)
|
| 47 |
+
platform: Target platform (tiktok, instagram, etc.)
|
| 48 |
+
enhance: Enable/disable auto-enhancement
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Enhanced query string
|
| 52 |
+
"""
|
| 53 |
+
if not enhance:
|
| 54 |
+
return query
|
| 55 |
+
|
| 56 |
+
enhanced = query
|
| 57 |
+
query_lower = query.lower()
|
| 58 |
+
|
| 59 |
+
# Detect location context
|
| 60 |
+
is_danang = any(loc in query_lower for loc in ['đà nẵng', 'da nang', 'danang'])
|
| 61 |
+
is_vietnam = any(loc in query_lower for loc in ['vietnam', 'việt nam', 'viet nam'])
|
| 62 |
+
|
| 63 |
+
# Add location if not present and location parameter provided
|
| 64 |
+
if location == "danang" and not (is_danang or is_vietnam):
|
| 65 |
+
enhanced = f"{enhanced} Da Nang"
|
| 66 |
+
|
| 67 |
+
# Platform-specific enhancements
|
| 68 |
+
if platform and 'tiktok' in platform.lower():
|
| 69 |
+
# Only add #danang hashtag if it's Da Nang context and not already present
|
| 70 |
+
if (is_danang or location == "danang") and '#danang' not in query_lower:
|
| 71 |
+
enhanced = f"{enhanced} #danang"
|
| 72 |
+
|
| 73 |
+
return enhanced
|
| 74 |
|
| 75 |
class BraveSocialSearch:
|
| 76 |
"""
|
|
|
|
| 86 |
# Fallback or warning? For now assume it will be provided or env
|
| 87 |
pass
|
| 88 |
|
| 89 |
+
async def search(
|
| 90 |
+
self,
|
| 91 |
+
query: str,
|
| 92 |
+
limit: int = 10,
|
| 93 |
+
freshness: str = None,
|
| 94 |
+
platforms: List[str] = None,
|
| 95 |
+
enhance: bool = True,
|
| 96 |
+
location: str = "danang"
|
| 97 |
+
) -> List[SocialSearchResult]:
|
| 98 |
if not self.api_key:
|
| 99 |
print("Warning: BRAVE_API_KEY not found.")
|
| 100 |
return []
|
| 101 |
|
| 102 |
+
# Enhancement for travel content
|
| 103 |
+
primary_platform = platforms[0] if platforms else None
|
| 104 |
+
enhanced_query = enhance_travel_query(
|
| 105 |
+
query,
|
| 106 |
+
location=location,
|
| 107 |
+
platform=primary_platform,
|
| 108 |
+
enhance=enhance
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Platform-specific freshness defaults
|
| 112 |
+
if freshness is None:
|
| 113 |
+
if platforms and any('tiktok' in p.lower() for p in platforms):
|
| 114 |
+
freshness = "pm" # Past month for TikTok (trending)
|
| 115 |
+
else:
|
| 116 |
+
freshness = "pw" # Past week for others
|
| 117 |
+
|
| 118 |
headers = {
|
| 119 |
"Accept": "application/json",
|
| 120 |
"Accept-Encoding": "gzip",
|
|
|
|
| 123 |
|
| 124 |
# Default social sites if none provided
|
| 125 |
if not platforms:
|
| 126 |
+
social_sites = [
|
| 127 |
+
'site:twitter.com', 'site:x.com',
|
| 128 |
+
'site:facebook.com',
|
| 129 |
+
'site:reddit.com',
|
| 130 |
+
'site:linkedin.com',
|
| 131 |
+
'site:tiktok.com',
|
| 132 |
+
'site:instagram.com',
|
| 133 |
+
'site:threads.net'
|
| 134 |
+
]
|
| 135 |
else:
|
| 136 |
+
social_sites = []
|
|
|
|
| 137 |
for p in platforms:
|
| 138 |
p = p.lower()
|
| 139 |
+
if "facebook" in p: social_sites.append("site:facebook.com")
|
| 140 |
+
elif "reddit" in p: social_sites.append("site:reddit.com")
|
| 141 |
+
elif "twitter" in p or "x" == p: social_sites.extend(["site:twitter.com", "site:x.com"])
|
| 142 |
+
elif "linkedin" in p: social_sites.append("site:linkedin.com")
|
| 143 |
+
elif "tiktok" in p: social_sites.append("site:tiktok.com")
|
| 144 |
+
elif "instagram" in p: social_sites.append("site:instagram.com")
|
| 145 |
+
elif "site:" in p: social_sites.append(p) # Direct operator
|
| 146 |
+
|
| 147 |
+
# Construct query with site OR operator
|
| 148 |
+
if len(social_sites) > 1:
|
| 149 |
+
sites_query = " OR ".join(social_sites)
|
| 150 |
+
full_query = f"{enhanced_query} ({sites_query})"
|
| 151 |
+
elif len(social_sites) == 1:
|
| 152 |
+
full_query = f"{enhanced_query} {social_sites[0]}"
|
| 153 |
+
else:
|
| 154 |
+
full_query = enhanced_query
|
| 155 |
|
| 156 |
params = {
|
| 157 |
"q": full_query,
|
|
|
|
| 183 |
if "profile" in item and "name" in item["profile"]:
|
| 184 |
platform = item["profile"]["name"]
|
| 185 |
else:
|
| 186 |
+
# Enhanced platform detection
|
| 187 |
domain = item.get("url", "").split("//")[-1].split("/")[0]
|
| 188 |
if "reddit" in domain: platform = "Reddit"
|
| 189 |
elif "twitter" in domain or "x.com" in domain: platform = "X (Twitter)"
|
| 190 |
elif "facebook" in domain: platform = "Facebook"
|
| 191 |
+
elif "tiktok" in domain: platform = "TikTok"
|
| 192 |
+
elif "instagram" in domain: platform = "Instagram"
|
| 193 |
+
elif "linkedin" in domain: platform = "LinkedIn"
|
| 194 |
|
| 195 |
results.append(SocialSearchResult(
|
| 196 |
title=item.get("title", ""),
|
|
|
|
| 206 |
print(f"Error calling Brave Search API: {e}")
|
| 207 |
return []
|
| 208 |
|
| 209 |
+
# Tool definition for agent
|
| 210 |
+
TOOL_DEFINITION = {
|
| 211 |
+
"name": "search_social_media",
|
| 212 |
+
"description": """Tìm kiếm nội dung mạng xã hội về địa điểm, du lịch Đà Nẵng/Vietnam.
|
| 213 |
+
|
| 214 |
+
ĐẶC BIỆT TỐI ƯU CHO TIKTOK - nền tảng #1 cho travel content!
|
| 215 |
+
|
| 216 |
+
Dùng khi:
|
| 217 |
+
- Tìm trending videos, viral content về Đà Nẵng
|
| 218 |
+
- Xem review, tips, tricks từ du khách thực tế
|
| 219 |
+
- Khám phá hidden gems, fun facts về địa điểm
|
| 220 |
+
- Thu thập ý kiến cộng đồng về nhà hàng, khách sạn
|
| 221 |
+
- Tìm kiếm trải nghiệm và hành trình du lịch
|
| 222 |
+
|
| 223 |
+
Tính năng thông minh:
|
| 224 |
+
- Tự động thêm hashtag #danang
|
| 225 |
+
- Query enhancement cho travel content
|
| 226 |
+
- Platform-specific optimization (TikTok, Instagram)
|
| 227 |
+
- Adaptive freshness (trending vs. comprehensive)
|
| 228 |
+
|
| 229 |
+
Nền tảng hỗ trợ:
|
| 230 |
+
- TikTok 🔥 (Ưu tiên cho travel content)
|
| 231 |
+
- Instagram 📸
|
| 232 |
+
- X (Twitter), Facebook, Reddit, LinkedIn, Threads
|
| 233 |
+
|
| 234 |
+
Tips:
|
| 235 |
+
- Để platforms=["tiktok"] cho trending visual content
|
| 236 |
+
- Để platforms=None để tìm trên tất cả nền tảng
|
| 237 |
+
- Set enhance=False nếu muốn query chính xác (không auto-thêm hashtags)""",
|
| 238 |
+
"parameters": {
|
| 239 |
+
"query": "Query tìm kiếm (VD: 'best beaches', 'quán ăn ngon')",
|
| 240 |
+
"limit": "Số kết quả (mặc định 10, tối đa 20)",
|
| 241 |
+
"freshness": "Độ mới: None (auto), 'pw' (week), 'pm' (month), 'py' (year)",
|
| 242 |
+
"platforms": "Platforms: ['tiktok'], ['instagram'], hoặc None (all)",
|
| 243 |
+
"enhance": "Auto-enhance query (default True)",
|
| 244 |
+
"location": "Location context: 'danang', 'hoian', 'vietnam' (default 'danang')",
|
| 245 |
+
},
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
# Singleton instance
|
| 250 |
social_search_tool = BraveSocialSearch()
|
| 251 |
|
| 252 |
+
async def search_social_media(
|
| 253 |
+
query: str,
|
| 254 |
+
limit: int = 10,
|
| 255 |
+
freshness: str = None,
|
| 256 |
+
platforms: List[str] = None,
|
| 257 |
+
enhance: bool = True,
|
| 258 |
+
location: str = "danang"
|
| 259 |
+
) -> List[SocialSearchResult]:
|
| 260 |
"""
|
| 261 |
+
Search for social media content (news, discussions) with travel optimization.
|
| 262 |
"""
|
| 263 |
+
return await social_search_tool.search(
|
| 264 |
+
query, limit, freshness, platforms, enhance, location
|
| 265 |
+
)
|
app/mcp/tools/text_tool.py
CHANGED
|
@@ -27,24 +27,11 @@ class TextSearchResult:
|
|
| 27 |
source_text: str = ""
|
| 28 |
content_type: str = ""
|
| 29 |
|
| 30 |
-
# Category constants - imported from centralized prompts
|
| 31 |
-
from app.shared.prompts import CATEGORY_KEYWORDS, CATEGORY_TO_DB
|
| 32 |
-
|
| 33 |
|
| 34 |
# Tool definition for agent - imported from centralized prompts
|
| 35 |
from app.shared.prompts import RETRIEVE_CONTEXT_TEXT_TOOL as TOOL_DEFINITION
|
| 36 |
|
| 37 |
|
| 38 |
-
def detect_category_intent(query: str) -> Optional[str]:
|
| 39 |
-
"""Detect if query is asking for specific category."""
|
| 40 |
-
query_lower = query.lower()
|
| 41 |
-
|
| 42 |
-
for category, keywords in CATEGORY_KEYWORDS.items():
|
| 43 |
-
if any(kw in query_lower for kw in keywords):
|
| 44 |
-
return category
|
| 45 |
-
return None
|
| 46 |
-
|
| 47 |
-
|
| 48 |
async def retrieve_context_text(
|
| 49 |
db: AsyncSession,
|
| 50 |
query: str,
|
|
@@ -71,9 +58,6 @@ async def retrieve_context_text(
|
|
| 71 |
# Convert to PostgreSQL vector format
|
| 72 |
embedding_str = "[" + ",".join(str(x) for x in query_embedding) + "]"
|
| 73 |
|
| 74 |
-
# Detect category intent for boosting
|
| 75 |
-
category_intent = detect_category_intent(query)
|
| 76 |
-
category_filter = CATEGORY_TO_DB.get(category_intent, []) if category_intent else []
|
| 77 |
|
| 78 |
# Search with JOIN to places_metadata
|
| 79 |
# Note: Use format string for embedding since SQLAlchemy param binding
|
|
@@ -102,16 +86,12 @@ async def retrieve_context_text(
|
|
| 102 |
|
| 103 |
rows = results.fetchall()
|
| 104 |
|
| 105 |
-
# Process and score results with
|
| 106 |
scored_results = []
|
| 107 |
for r in rows:
|
| 108 |
score = float(r.similarity)
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
if category_filter and r.category in category_filter:
|
| 112 |
-
score += 0.15
|
| 113 |
-
|
| 114 |
-
# Rating boost (5% for >= 4.5)
|
| 115 |
if r.rating and r.rating >= 4.5:
|
| 116 |
score += 0.05
|
| 117 |
elif r.rating and r.rating >= 4.0:
|
|
|
|
| 27 |
source_text: str = ""
|
| 28 |
content_type: str = ""
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
# Tool definition for agent - imported from centralized prompts
|
| 32 |
from app.shared.prompts import RETRIEVE_CONTEXT_TEXT_TOOL as TOOL_DEFINITION
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
async def retrieve_context_text(
|
| 36 |
db: AsyncSession,
|
| 37 |
query: str,
|
|
|
|
| 58 |
# Convert to PostgreSQL vector format
|
| 59 |
embedding_str = "[" + ",".join(str(x) for x in query_embedding) + "]"
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Search with JOIN to places_metadata
|
| 63 |
# Note: Use format string for embedding since SQLAlchemy param binding
|
|
|
|
| 86 |
|
| 87 |
rows = results.fetchall()
|
| 88 |
|
| 89 |
+
# Process and score results with rating boost
|
| 90 |
scored_results = []
|
| 91 |
for r in rows:
|
| 92 |
score = float(r.similarity)
|
| 93 |
|
| 94 |
+
# Rating boost (5% for >= 4.5, 2% for >= 4.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
if r.rating and r.rating >= 4.5:
|
| 96 |
score += 0.05
|
| 97 |
elif r.rating and r.rating >= 4.0:
|
app/planner/models.py
CHANGED
|
@@ -98,3 +98,63 @@ class PlanResponse(BaseModel):
|
|
| 98 |
|
| 99 |
plan: Plan
|
| 100 |
message: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
plan: Plan
|
| 100 |
message: str
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# =============================================================================
|
| 104 |
+
# SMART PLAN MODELS
|
| 105 |
+
# =============================================================================
|
| 106 |
+
|
| 107 |
+
class PlaceDetailResponse(BaseModel):
|
| 108 |
+
"""Rich detail for a place in smart plan."""
|
| 109 |
+
|
| 110 |
+
place_id: str = Field(..., description="Place ID")
|
| 111 |
+
name: str = Field(..., description="Place name")
|
| 112 |
+
category: str = Field(default="", description="Category")
|
| 113 |
+
lat: float = Field(default=0.0, description="Latitude")
|
| 114 |
+
lng: float = Field(default=0.0, description="Longitude")
|
| 115 |
+
recommended_time: str = Field(default="", description="Recommended visit time (HH:MM)")
|
| 116 |
+
suggested_duration_min: int = Field(default=60, description="Suggested duration in minutes")
|
| 117 |
+
tips: list[str] = Field(default_factory=list, description="Tips for this place")
|
| 118 |
+
highlights: str = Field(default="", description="Highlights from research")
|
| 119 |
+
social_mentions: list[str] = Field(default_factory=list, description="Social media mentions")
|
| 120 |
+
order: int = Field(default=0, description="Order in day")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class DayPlanResponse(BaseModel):
|
| 124 |
+
"""Single day plan."""
|
| 125 |
+
|
| 126 |
+
day_index: int = Field(..., description="Day number (1-indexed)")
|
| 127 |
+
date: Optional[str] = Field(None, description="Date (YYYY-MM-DD)")
|
| 128 |
+
places: list[PlaceDetailResponse] = Field(default_factory=list)
|
| 129 |
+
day_summary: str = Field(default="", description="Day summary")
|
| 130 |
+
day_distance_km: float = Field(default=0.0, description="Total distance for the day")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class SmartPlanResponse(BaseModel):
|
| 134 |
+
"""Complete optimized plan with enriched details."""
|
| 135 |
+
|
| 136 |
+
itinerary_id: str = Field(..., description="Reference ID")
|
| 137 |
+
title: str = Field(..., description="Plan title")
|
| 138 |
+
total_days: int = Field(..., description="Number of days")
|
| 139 |
+
days: list[DayPlanResponse] = Field(default_factory=list)
|
| 140 |
+
summary: str = Field(default="", description="Plan summary")
|
| 141 |
+
total_distance_km: float = Field(default=0.0, description="Total route distance")
|
| 142 |
+
estimated_total_duration_min: int = Field(default=0, description="Total estimated duration")
|
| 143 |
+
generated_at: datetime = Field(default_factory=datetime.now)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class GetPlanRequest(BaseModel):
|
| 147 |
+
"""Request for smart plan generation."""
|
| 148 |
+
|
| 149 |
+
include_social_research: bool = Field(default=True, description="Include social media research")
|
| 150 |
+
freshness: str = Field(default="pw", description="Social search freshness: pw=past week, pm=past month")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class GetPlanResponse(BaseModel):
|
| 154 |
+
"""Response with smart plan."""
|
| 155 |
+
|
| 156 |
+
plan: SmartPlanResponse
|
| 157 |
+
research_count: int = Field(default=0, description="Number of social results found")
|
| 158 |
+
generation_time_ms: float = Field(default=0, description="Plan generation time in ms")
|
| 159 |
+
message: str = Field(default="Smart plan generated successfully")
|
| 160 |
+
|
app/planner/router.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Trip Planner Router - API endpoints for plan management."""
|
| 2 |
|
|
|
|
| 3 |
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
|
| 5 |
from app.planner.models import (
|
|
@@ -12,8 +13,14 @@ from app.planner.models import (
|
|
| 12 |
ReplaceRequest,
|
| 13 |
OptimizeResponse,
|
| 14 |
PlanResponse,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
from app.planner.service import planner_service
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/planner", tags=["Trip Planner"])
|
|
@@ -251,3 +258,120 @@ async def delete_plan(
|
|
| 251 |
raise HTTPException(status_code=404, detail="Plan not found")
|
| 252 |
|
| 253 |
return {"status": "success", "message": f"Deleted plan {plan_id}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Trip Planner Router - API endpoints for plan management."""
|
| 2 |
|
| 3 |
+
import time
|
| 4 |
from fastapi import APIRouter, HTTPException, Query
|
| 5 |
|
| 6 |
from app.planner.models import (
|
|
|
|
| 13 |
ReplaceRequest,
|
| 14 |
OptimizeResponse,
|
| 15 |
PlanResponse,
|
| 16 |
+
GetPlanRequest,
|
| 17 |
+
GetPlanResponse,
|
| 18 |
+
SmartPlanResponse,
|
| 19 |
+
DayPlanResponse,
|
| 20 |
+
PlaceDetailResponse,
|
| 21 |
)
|
| 22 |
from app.planner.service import planner_service
|
| 23 |
+
from app.planner.smart_plan import smart_plan_service
|
| 24 |
|
| 25 |
|
| 26 |
router = APIRouter(prefix="/planner", tags=["Trip Planner"])
|
|
|
|
| 258 |
raise HTTPException(status_code=404, detail="Plan not found")
|
| 259 |
|
| 260 |
return {"status": "success", "message": f"Deleted plan {plan_id}"}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# =============================================================================
|
| 264 |
+
# SMART PLAN ENDPOINT
|
| 265 |
+
# =============================================================================
|
| 266 |
+
|
| 267 |
+
@router.post(
|
| 268 |
+
"/{plan_id}/get-plan",
|
| 269 |
+
response_model=GetPlanResponse,
|
| 270 |
+
summary="Generate Smart Plan",
|
| 271 |
+
description="""
|
| 272 |
+
Generates an optimized, enriched plan with:
|
| 273 |
+
- Social media research for each place
|
| 274 |
+
- Optimal timing (e.g., Dragon Bridge at 21h for fire show)
|
| 275 |
+
- Tips and highlights per place
|
| 276 |
+
- Route optimization
|
| 277 |
+
""",
|
| 278 |
+
)
|
| 279 |
+
async def get_smart_plan(
|
| 280 |
+
plan_id: str,
|
| 281 |
+
request: GetPlanRequest = GetPlanRequest(),
|
| 282 |
+
user_id: str = Query(default="anonymous", description="User ID"),
|
| 283 |
+
) -> GetPlanResponse:
|
| 284 |
+
"""
|
| 285 |
+
Generate a smart, enriched travel plan.
|
| 286 |
+
|
| 287 |
+
Uses Social Media Tool to research each place and LLM to optimize
|
| 288 |
+
timing based on Da Nang local knowledge.
|
| 289 |
+
"""
|
| 290 |
+
start_time = time.time()
|
| 291 |
+
|
| 292 |
+
# Get the plan
|
| 293 |
+
plan = planner_service.get_plan(user_id, plan_id)
|
| 294 |
+
if not plan:
|
| 295 |
+
raise HTTPException(status_code=404, detail="Plan not found")
|
| 296 |
+
|
| 297 |
+
if len(plan.items) == 0:
|
| 298 |
+
raise HTTPException(status_code=400, detail="Plan is empty. Add places first.")
|
| 299 |
+
|
| 300 |
+
# Convert PlanItems to dict format for smart plan service
|
| 301 |
+
places = [
|
| 302 |
+
{
|
| 303 |
+
"place_id": item.place_id,
|
| 304 |
+
"name": item.name,
|
| 305 |
+
"category": item.category,
|
| 306 |
+
"lat": item.lat,
|
| 307 |
+
"lng": item.lng,
|
| 308 |
+
"rating": item.rating,
|
| 309 |
+
}
|
| 310 |
+
for item in plan.items
|
| 311 |
+
]
|
| 312 |
+
|
| 313 |
+
# Generate smart plan
|
| 314 |
+
smart_plan = await smart_plan_service.generate_smart_plan(
|
| 315 |
+
places=places,
|
| 316 |
+
title=plan.name,
|
| 317 |
+
itinerary_id=plan_id,
|
| 318 |
+
total_days=1, # Planner is single-day by default
|
| 319 |
+
include_social_research=request.include_social_research,
|
| 320 |
+
freshness=request.freshness,
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Count social research results
|
| 324 |
+
research_count = sum(
|
| 325 |
+
len(p.social_mentions)
|
| 326 |
+
for day in smart_plan.days
|
| 327 |
+
for p in day.places
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
generation_time = (time.time() - start_time) * 1000
|
| 331 |
+
|
| 332 |
+
# Convert to Pydantic response
|
| 333 |
+
days_response = []
|
| 334 |
+
for day in smart_plan.days:
|
| 335 |
+
places_response = [
|
| 336 |
+
PlaceDetailResponse(
|
| 337 |
+
place_id=p.place_id,
|
| 338 |
+
name=p.name,
|
| 339 |
+
category=p.category,
|
| 340 |
+
lat=p.lat,
|
| 341 |
+
lng=p.lng,
|
| 342 |
+
recommended_time=p.recommended_time,
|
| 343 |
+
suggested_duration_min=p.suggested_duration_min,
|
| 344 |
+
tips=p.tips,
|
| 345 |
+
highlights=p.highlights,
|
| 346 |
+
social_mentions=p.social_mentions,
|
| 347 |
+
order=p.order,
|
| 348 |
+
)
|
| 349 |
+
for p in day.places
|
| 350 |
+
]
|
| 351 |
+
days_response.append(
|
| 352 |
+
DayPlanResponse(
|
| 353 |
+
day_index=day.day_index,
|
| 354 |
+
date=str(day.date) if day.date else None,
|
| 355 |
+
places=places_response,
|
| 356 |
+
day_summary=day.day_summary,
|
| 357 |
+
day_distance_km=day.day_distance_km,
|
| 358 |
+
)
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
plan_response = SmartPlanResponse(
|
| 362 |
+
itinerary_id=smart_plan.itinerary_id,
|
| 363 |
+
title=smart_plan.title,
|
| 364 |
+
total_days=smart_plan.total_days,
|
| 365 |
+
days=days_response,
|
| 366 |
+
summary=smart_plan.summary,
|
| 367 |
+
total_distance_km=smart_plan.total_distance_km,
|
| 368 |
+
estimated_total_duration_min=smart_plan.estimated_total_duration_min,
|
| 369 |
+
generated_at=smart_plan.generated_at,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
return GetPlanResponse(
|
| 373 |
+
plan=plan_response,
|
| 374 |
+
research_count=research_count,
|
| 375 |
+
generation_time_ms=round(generation_time, 2),
|
| 376 |
+
message=f"Smart plan generated with {research_count} social mentions",
|
| 377 |
+
)
|
app/planner/smart_plan.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smart Plan Service - Generate optimized, enriched travel plans.
|
| 2 |
+
|
| 3 |
+
Uses Social Media Tool for research and LLM for intelligent scheduling.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import time
|
| 10 |
+
from datetime import datetime, date, timedelta
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
|
| 13 |
+
from app.mcp.tools.social_tool import search_social_media, SocialSearchResult
|
| 14 |
+
from app.shared.integrations.gemini_client import GeminiClient
|
| 15 |
+
from app.shared.prompts import SMART_PLAN_SYSTEM_PROMPT, build_smart_plan_prompt
|
| 16 |
+
from app.planner.tsp import optimize_route, haversine
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class PlaceDetail:
|
| 21 |
+
"""Rich detail for a place in smart plan."""
|
| 22 |
+
place_id: str
|
| 23 |
+
name: str
|
| 24 |
+
category: str = ""
|
| 25 |
+
lat: float = 0.0
|
| 26 |
+
lng: float = 0.0
|
| 27 |
+
recommended_time: str = "" # "21:00"
|
| 28 |
+
suggested_duration_min: int = 60 # Default 1 hour
|
| 29 |
+
tips: list[str] = field(default_factory=list)
|
| 30 |
+
highlights: str = "" # Summary from research
|
| 31 |
+
social_mentions: list[str] = field(default_factory=list)
|
| 32 |
+
order: int = 0
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class DayPlan:
|
| 37 |
+
"""Single day plan."""
|
| 38 |
+
day_index: int
|
| 39 |
+
date: date | None = None
|
| 40 |
+
places: list[PlaceDetail] = field(default_factory=list)
|
| 41 |
+
day_summary: str = ""
|
| 42 |
+
day_distance_km: float = 0.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class SmartPlan:
|
| 47 |
+
"""Complete optimized plan with enriched details."""
|
| 48 |
+
itinerary_id: str
|
| 49 |
+
title: str
|
| 50 |
+
total_days: int
|
| 51 |
+
days: list[DayPlan]
|
| 52 |
+
summary: str = ""
|
| 53 |
+
total_distance_km: float = 0.0
|
| 54 |
+
estimated_total_duration_min: int = 0
|
| 55 |
+
generated_at: datetime = field(default_factory=datetime.now)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class SmartPlanService:
|
| 59 |
+
"""Service for generating smart, enriched travel plans."""
|
| 60 |
+
|
| 61 |
+
# Da Nang local knowledge - special timing for attractions
|
| 62 |
+
DANANG_TIMING_HINTS = {
|
| 63 |
+
"cầu rồng": {"time": "21:00", "tip": "Xem cầu rồng phun lửa/nước (T7, CN)"},
|
| 64 |
+
"dragon bridge": {"time": "21:00", "tip": "Fire/water show (Sat, Sun)"},
|
| 65 |
+
"cầu tình yêu": {"time": "19:00", "tip": "Đẹp nhất khi lên đèn"},
|
| 66 |
+
"bà nà hills": {"time": "08:00", "tip": "Đến sớm tránh đông, mát mẻ"},
|
| 67 |
+
"bãi biển mỹ khê": {"time": "05:30", "tip": "Ngắm bình minh tuyệt đẹp"},
|
| 68 |
+
"my khe beach": {"time": "05:30", "tip": "Beautiful sunrise"},
|
| 69 |
+
"chợ hàn": {"time": "07:00", "tip": "Sáng sớm đồ tươi, giá tốt"},
|
| 70 |
+
"chợ cồn": {"time": "06:00", "tip": "Chợ lớn nhất, đi sớm"},
|
| 71 |
+
"sơn trà": {"time": "05:00", "tip": "Săn mây, ngắm voọc"},
|
| 72 |
+
"ngũ hành sơn": {"time": "07:00", "tip": "Mát mẻ, ít đông"},
|
| 73 |
+
"hội an": {"time": "16:00", "tip": "Chiều tối đẹp nhất, thả đèn hoa đăng"},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# Default category timings
|
| 77 |
+
CATEGORY_TIMING = {
|
| 78 |
+
"cafe": {"time": "09:00", "duration": 60},
|
| 79 |
+
"restaurant": {"time": "12:00", "duration": 90},
|
| 80 |
+
"beach": {"time": "06:00", "duration": 120},
|
| 81 |
+
"attraction": {"time": "09:00", "duration": 90},
|
| 82 |
+
"shopping": {"time": "10:00", "duration": 60},
|
| 83 |
+
"nightlife": {"time": "20:00", "duration": 120},
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def __init__(self, model: str = "gemini-3-flash-preview"):
|
| 87 |
+
"""Initialize with Gemini model."""
|
| 88 |
+
self.llm = GeminiClient(model=model)
|
| 89 |
+
|
| 90 |
+
async def research_places(
|
| 91 |
+
self,
|
| 92 |
+
place_names: list[str],
|
| 93 |
+
freshness: str = "pw"
|
| 94 |
+
) -> dict[str, list[SocialSearchResult]]:
|
| 95 |
+
"""
|
| 96 |
+
Use Social Tool to gather info about each place.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
place_names: List of place names to research
|
| 100 |
+
freshness: Brave Search freshness param (pw=past week)
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Dict mapping place_name to list of social results
|
| 104 |
+
"""
|
| 105 |
+
research = {}
|
| 106 |
+
|
| 107 |
+
# Parallel research for all places
|
| 108 |
+
async def research_one(name: str):
|
| 109 |
+
query = f"{name} Đà Nẵng review"
|
| 110 |
+
results = await search_social_media(
|
| 111 |
+
query=query,
|
| 112 |
+
limit=5,
|
| 113 |
+
freshness=freshness
|
| 114 |
+
)
|
| 115 |
+
return name, results
|
| 116 |
+
|
| 117 |
+
tasks = [research_one(name) for name in place_names]
|
| 118 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 119 |
+
|
| 120 |
+
for result in results:
|
| 121 |
+
if isinstance(result, tuple):
|
| 122 |
+
name, social_results = result
|
| 123 |
+
research[name] = social_results
|
| 124 |
+
else:
|
| 125 |
+
# Exception occurred, skip
|
| 126 |
+
pass
|
| 127 |
+
|
| 128 |
+
return research
|
| 129 |
+
|
| 130 |
+
def _get_timing_hint(self, place_name: str, category: str) -> tuple[str, str]:
|
| 131 |
+
"""Get recommended time and tip for a place."""
|
| 132 |
+
name_lower = place_name.lower()
|
| 133 |
+
|
| 134 |
+
# Check specific place hints first
|
| 135 |
+
for keyword, hints in self.DANANG_TIMING_HINTS.items():
|
| 136 |
+
if keyword in name_lower:
|
| 137 |
+
return hints["time"], hints["tip"]
|
| 138 |
+
|
| 139 |
+
# Fall back to category timing
|
| 140 |
+
cat_lower = category.lower() if category else ""
|
| 141 |
+
for cat_key, timing in self.CATEGORY_TIMING.items():
|
| 142 |
+
if cat_key in cat_lower:
|
| 143 |
+
return timing["time"], ""
|
| 144 |
+
|
| 145 |
+
# Default
|
| 146 |
+
return "10:00", ""
|
| 147 |
+
|
| 148 |
+
def _format_social_mentions(self, results: list[SocialSearchResult]) -> list[str]:
|
| 149 |
+
"""Format social results as readable mentions."""
|
| 150 |
+
mentions = []
|
| 151 |
+
for r in results[:3]: # Top 3
|
| 152 |
+
platform = r.platform if r.platform != "Web" else ""
|
| 153 |
+
if platform:
|
| 154 |
+
mentions.append(f"[{platform}] {r.title[:80]}...")
|
| 155 |
+
else:
|
| 156 |
+
mentions.append(r.title[:80])
|
| 157 |
+
return mentions
|
| 158 |
+
|
| 159 |
+
async def generate_smart_plan(
|
| 160 |
+
self,
|
| 161 |
+
places: list[dict], # List of {place_id, name, category, lat, lng, ...}
|
| 162 |
+
title: str = "My Trip",
|
| 163 |
+
itinerary_id: str = "",
|
| 164 |
+
total_days: int = 1,
|
| 165 |
+
start_date: date | None = None,
|
| 166 |
+
include_social_research: bool = True,
|
| 167 |
+
freshness: str = "pw"
|
| 168 |
+
) -> SmartPlan:
|
| 169 |
+
"""
|
| 170 |
+
Generate optimized smart plan with enriched details.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
places: List of places with basic info
|
| 174 |
+
title: Plan title
|
| 175 |
+
itinerary_id: Reference ID
|
| 176 |
+
total_days: Number of days
|
| 177 |
+
start_date: Optional start date
|
| 178 |
+
include_social_research: Whether to search social media
|
| 179 |
+
freshness: Social search freshness
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
SmartPlan with optimized timing and enriched details
|
| 183 |
+
"""
|
| 184 |
+
start_time = time.time()
|
| 185 |
+
|
| 186 |
+
# Step 1: Research places (if enabled)
|
| 187 |
+
research = {}
|
| 188 |
+
if include_social_research:
|
| 189 |
+
place_names = [p.get("name", "") for p in places if p.get("name")]
|
| 190 |
+
research = await self.research_places(place_names, freshness)
|
| 191 |
+
|
| 192 |
+
# Step 2: Get timing hints and build place details
|
| 193 |
+
place_details = []
|
| 194 |
+
for i, place in enumerate(places):
|
| 195 |
+
name = place.get("name", f"Place {i+1}")
|
| 196 |
+
category = place.get("category", "")
|
| 197 |
+
|
| 198 |
+
rec_time, tip = self._get_timing_hint(name, category)
|
| 199 |
+
|
| 200 |
+
# Get social mentions
|
| 201 |
+
social_results = research.get(name, [])
|
| 202 |
+
social_mentions = self._format_social_mentions(social_results)
|
| 203 |
+
|
| 204 |
+
# Build highlights from social research
|
| 205 |
+
highlights = ""
|
| 206 |
+
if social_results:
|
| 207 |
+
descriptions = [r.description for r in social_results[:2] if r.description]
|
| 208 |
+
if descriptions:
|
| 209 |
+
highlights = " ".join(descriptions)[:300]
|
| 210 |
+
|
| 211 |
+
tips = [tip] if tip else []
|
| 212 |
+
|
| 213 |
+
detail = PlaceDetail(
|
| 214 |
+
place_id=place.get("place_id", ""),
|
| 215 |
+
name=name,
|
| 216 |
+
category=category,
|
| 217 |
+
lat=place.get("lat", 0.0),
|
| 218 |
+
lng=place.get("lng", 0.0),
|
| 219 |
+
recommended_time=rec_time,
|
| 220 |
+
suggested_duration_min=self.CATEGORY_TIMING.get(category.lower(), {}).get("duration", 60),
|
| 221 |
+
tips=tips,
|
| 222 |
+
highlights=highlights,
|
| 223 |
+
social_mentions=social_mentions,
|
| 224 |
+
order=i + 1
|
| 225 |
+
)
|
| 226 |
+
place_details.append(detail)
|
| 227 |
+
|
| 228 |
+
# Step 3: Use LLM to optimize and enhance
|
| 229 |
+
enhanced_plan = await self._llm_enhance_plan(
|
| 230 |
+
place_details=place_details,
|
| 231 |
+
total_days=total_days,
|
| 232 |
+
research=research
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Step 4: Organize into days
|
| 236 |
+
days = self._organize_into_days(
|
| 237 |
+
place_details=enhanced_plan if enhanced_plan else place_details,
|
| 238 |
+
total_days=total_days,
|
| 239 |
+
start_date=start_date
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Step 5: Calculate distances
|
| 243 |
+
total_distance = 0.0
|
| 244 |
+
for day in days:
|
| 245 |
+
day.day_distance_km = self._calculate_day_distance(day.places)
|
| 246 |
+
total_distance += day.day_distance_km
|
| 247 |
+
|
| 248 |
+
# Step 6: Build summary
|
| 249 |
+
summary = await self._generate_summary(title, days, research)
|
| 250 |
+
|
| 251 |
+
generation_time = (time.time() - start_time) * 1000
|
| 252 |
+
|
| 253 |
+
return SmartPlan(
|
| 254 |
+
itinerary_id=itinerary_id,
|
| 255 |
+
title=title,
|
| 256 |
+
total_days=total_days,
|
| 257 |
+
days=days,
|
| 258 |
+
summary=summary,
|
| 259 |
+
total_distance_km=round(total_distance, 2),
|
| 260 |
+
estimated_total_duration_min=sum(p.suggested_duration_min for d in days for p in d.places),
|
| 261 |
+
generated_at=datetime.now()
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
async def _llm_enhance_plan(
|
| 265 |
+
self,
|
| 266 |
+
place_details: list[PlaceDetail],
|
| 267 |
+
total_days: int,
|
| 268 |
+
research: dict
|
| 269 |
+
) -> list[PlaceDetail] | None:
|
| 270 |
+
"""Use LLM to enhance timing and tips."""
|
| 271 |
+
try:
|
| 272 |
+
# Build context for LLM
|
| 273 |
+
places_info = []
|
| 274 |
+
for p in place_details:
|
| 275 |
+
info = {
|
| 276 |
+
"name": p.name,
|
| 277 |
+
"category": p.category,
|
| 278 |
+
"current_time": p.recommended_time,
|
| 279 |
+
"social_info": p.highlights[:200] if p.highlights else ""
|
| 280 |
+
}
|
| 281 |
+
places_info.append(info)
|
| 282 |
+
|
| 283 |
+
prompt = build_smart_plan_prompt(places_info, total_days)
|
| 284 |
+
|
| 285 |
+
response = await self.llm.generate(
|
| 286 |
+
prompt=prompt,
|
| 287 |
+
system_instruction=SMART_PLAN_SYSTEM_PROMPT,
|
| 288 |
+
temperature=0.7
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
# Parse LLM response and update place details
|
| 292 |
+
import json
|
| 293 |
+
import re
|
| 294 |
+
|
| 295 |
+
# Extract JSON from response
|
| 296 |
+
json_match = re.search(r'\{[\s\S]*\}', response)
|
| 297 |
+
if json_match:
|
| 298 |
+
llm_result = json.loads(json_match.group())
|
| 299 |
+
|
| 300 |
+
# Update place details with LLM suggestions
|
| 301 |
+
if "places" in llm_result:
|
| 302 |
+
for llm_place in llm_result["places"]:
|
| 303 |
+
for p in place_details:
|
| 304 |
+
if p.name.lower() == llm_place.get("name", "").lower():
|
| 305 |
+
if "time" in llm_place:
|
| 306 |
+
p.recommended_time = llm_place["time"]
|
| 307 |
+
if "tips" in llm_place:
|
| 308 |
+
p.tips = llm_place["tips"]
|
| 309 |
+
if "duration" in llm_place:
|
| 310 |
+
p.suggested_duration_min = llm_place["duration"]
|
| 311 |
+
break
|
| 312 |
+
|
| 313 |
+
return place_details
|
| 314 |
+
|
| 315 |
+
except Exception as e:
|
| 316 |
+
print(f"LLM enhancement failed: {e}")
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
def _organize_into_days(
|
| 320 |
+
self,
|
| 321 |
+
place_details: list[PlaceDetail],
|
| 322 |
+
total_days: int,
|
| 323 |
+
start_date: date | None
|
| 324 |
+
) -> list[DayPlan]:
|
| 325 |
+
"""Organize places into days based on timing."""
|
| 326 |
+
if total_days <= 0:
|
| 327 |
+
total_days = 1
|
| 328 |
+
|
| 329 |
+
# Sort by recommended time
|
| 330 |
+
sorted_places = sorted(place_details, key=lambda p: p.recommended_time)
|
| 331 |
+
|
| 332 |
+
# Distribute across days
|
| 333 |
+
places_per_day = max(1, len(sorted_places) // total_days)
|
| 334 |
+
|
| 335 |
+
days = []
|
| 336 |
+
for day_idx in range(total_days):
|
| 337 |
+
start_idx = day_idx * places_per_day
|
| 338 |
+
end_idx = start_idx + places_per_day if day_idx < total_days - 1 else len(sorted_places)
|
| 339 |
+
|
| 340 |
+
day_places = sorted_places[start_idx:end_idx]
|
| 341 |
+
|
| 342 |
+
# Sort day places by time
|
| 343 |
+
day_places = sorted(day_places, key=lambda p: p.recommended_time)
|
| 344 |
+
|
| 345 |
+
# Update order within day
|
| 346 |
+
for i, p in enumerate(day_places):
|
| 347 |
+
p.order = i + 1
|
| 348 |
+
|
| 349 |
+
day_date = None
|
| 350 |
+
if start_date:
|
| 351 |
+
day_date = start_date + timedelta(days=day_idx)
|
| 352 |
+
|
| 353 |
+
day = DayPlan(
|
| 354 |
+
day_index=day_idx + 1,
|
| 355 |
+
date=day_date,
|
| 356 |
+
places=day_places,
|
| 357 |
+
day_summary=f"Ngày {day_idx + 1}: {len(day_places)} địa điểm"
|
| 358 |
+
)
|
| 359 |
+
days.append(day)
|
| 360 |
+
|
| 361 |
+
return days
|
| 362 |
+
|
| 363 |
+
def _calculate_day_distance(self, places: list[PlaceDetail]) -> float:
|
| 364 |
+
"""Calculate total distance for a day's route."""
|
| 365 |
+
if len(places) < 2:
|
| 366 |
+
return 0.0
|
| 367 |
+
|
| 368 |
+
total = 0.0
|
| 369 |
+
for i in range(len(places) - 1):
|
| 370 |
+
p1, p2 = places[i], places[i + 1]
|
| 371 |
+
if p1.lat and p1.lng and p2.lat and p2.lng:
|
| 372 |
+
total += haversine(p1.lat, p1.lng, p2.lat, p2.lng)
|
| 373 |
+
|
| 374 |
+
return round(total, 2)
|
| 375 |
+
|
| 376 |
+
async def _generate_summary(
|
| 377 |
+
self,
|
| 378 |
+
title: str,
|
| 379 |
+
days: list[DayPlan],
|
| 380 |
+
research: dict
|
| 381 |
+
) -> str:
|
| 382 |
+
"""Generate a brief summary of the plan."""
|
| 383 |
+
total_places = sum(len(d.places) for d in days)
|
| 384 |
+
|
| 385 |
+
# Get highlights from research
|
| 386 |
+
highlights = []
|
| 387 |
+
for results in research.values():
|
| 388 |
+
for r in results[:1]:
|
| 389 |
+
if r.description:
|
| 390 |
+
highlights.append(r.description[:100])
|
| 391 |
+
|
| 392 |
+
summary = f"Lịch trình {title} với {total_places} địa điểm trong {len(days)} ngày."
|
| 393 |
+
|
| 394 |
+
if highlights:
|
| 395 |
+
summary += f" Điểm nhấn: {'; '.join(highlights[:2])}"
|
| 396 |
+
|
| 397 |
+
return summary
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# Global service instance
|
| 401 |
+
smart_plan_service = SmartPlanService()
|
app/shared/prompts/__init__.py
CHANGED
|
@@ -5,6 +5,9 @@ from app.shared.prompts.prompts import (
|
|
| 5 |
REACT_SYSTEM_PROMPT,
|
| 6 |
GREETING_SYSTEM_PROMPT,
|
| 7 |
SYNTHESIS_SYSTEM_PROMPT,
|
|
|
|
|
|
|
|
|
|
| 8 |
TOOL_DEFINITIONS,
|
| 9 |
TOOL_PURPOSES,
|
| 10 |
# Tool-specific definitions
|
|
@@ -14,12 +17,12 @@ from app.shared.prompts.prompts import (
|
|
| 14 |
SEARCH_SOCIAL_MEDIA_TOOL,
|
| 15 |
# Database constants
|
| 16 |
AVAILABLE_CATEGORIES,
|
| 17 |
-
CATEGORY_KEYWORDS,
|
| 18 |
-
CATEGORY_TO_DB,
|
| 19 |
# Prompt builders
|
| 20 |
build_greeting_prompt,
|
|
|
|
| 21 |
build_synthesis_prompt,
|
| 22 |
build_reasoning_prompt,
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
__all__ = [
|
|
@@ -27,6 +30,9 @@ __all__ = [
|
|
| 27 |
"REACT_SYSTEM_PROMPT",
|
| 28 |
"GREETING_SYSTEM_PROMPT",
|
| 29 |
"SYNTHESIS_SYSTEM_PROMPT",
|
|
|
|
|
|
|
|
|
|
| 30 |
"TOOL_DEFINITIONS",
|
| 31 |
"TOOL_PURPOSES",
|
| 32 |
"FIND_NEARBY_PLACES_TOOL",
|
|
@@ -34,9 +40,9 @@ __all__ = [
|
|
| 34 |
"RETRIEVE_SIMILAR_VISUALS_TOOL",
|
| 35 |
"SEARCH_SOCIAL_MEDIA_TOOL",
|
| 36 |
"AVAILABLE_CATEGORIES",
|
| 37 |
-
"CATEGORY_KEYWORDS",
|
| 38 |
-
"CATEGORY_TO_DB",
|
| 39 |
"build_greeting_prompt",
|
|
|
|
| 40 |
"build_synthesis_prompt",
|
| 41 |
"build_reasoning_prompt",
|
|
|
|
| 42 |
]
|
|
|
|
| 5 |
REACT_SYSTEM_PROMPT,
|
| 6 |
GREETING_SYSTEM_PROMPT,
|
| 7 |
SYNTHESIS_SYSTEM_PROMPT,
|
| 8 |
+
INTENT_SYSTEM_PROMPT,
|
| 9 |
+
INTENT_DETECTION_PROMPT,
|
| 10 |
+
SMART_PLAN_SYSTEM_PROMPT,
|
| 11 |
TOOL_DEFINITIONS,
|
| 12 |
TOOL_PURPOSES,
|
| 13 |
# Tool-specific definitions
|
|
|
|
| 17 |
SEARCH_SOCIAL_MEDIA_TOOL,
|
| 18 |
# Database constants
|
| 19 |
AVAILABLE_CATEGORIES,
|
|
|
|
|
|
|
| 20 |
# Prompt builders
|
| 21 |
build_greeting_prompt,
|
| 22 |
+
build_intent_prompt,
|
| 23 |
build_synthesis_prompt,
|
| 24 |
build_reasoning_prompt,
|
| 25 |
+
build_smart_plan_prompt,
|
| 26 |
)
|
| 27 |
|
| 28 |
__all__ = [
|
|
|
|
| 30 |
"REACT_SYSTEM_PROMPT",
|
| 31 |
"GREETING_SYSTEM_PROMPT",
|
| 32 |
"SYNTHESIS_SYSTEM_PROMPT",
|
| 33 |
+
"INTENT_SYSTEM_PROMPT",
|
| 34 |
+
"INTENT_DETECTION_PROMPT",
|
| 35 |
+
"SMART_PLAN_SYSTEM_PROMPT",
|
| 36 |
"TOOL_DEFINITIONS",
|
| 37 |
"TOOL_PURPOSES",
|
| 38 |
"FIND_NEARBY_PLACES_TOOL",
|
|
|
|
| 40 |
"RETRIEVE_SIMILAR_VISUALS_TOOL",
|
| 41 |
"SEARCH_SOCIAL_MEDIA_TOOL",
|
| 42 |
"AVAILABLE_CATEGORIES",
|
|
|
|
|
|
|
| 43 |
"build_greeting_prompt",
|
| 44 |
+
"build_intent_prompt",
|
| 45 |
"build_synthesis_prompt",
|
| 46 |
"build_reasoning_prompt",
|
| 47 |
+
"build_smart_plan_prompt",
|
| 48 |
]
|
app/shared/prompts/prompts.py
CHANGED
|
@@ -107,6 +107,7 @@ Với mỗi bước, bạn phải:
|
|
| 107 |
```
|
| 108 |
|
| 109 |
**Quan trọng:**
|
|
|
|
| 110 |
- Nếu cần biết vị trí → dùng get_location_coordinates trước
|
| 111 |
- Nếu tìm theo khoảng cách → dùng find_nearby_places
|
| 112 |
- Nếu tìm review/trend MXH → dùng search_social_media
|
|
@@ -121,10 +122,74 @@ GREETING_SYSTEM_PROMPT = "Bạn là LocalMate - trợ lý du lịch thân thiệ
|
|
| 121 |
SYNTHESIS_SYSTEM_PROMPT = "Bạn là trợ lý du lịch thông minh cho Đà Nẵng. Trả lời format JSON."
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
# =============================================================================
|
| 125 |
# PROMPT TEMPLATES
|
| 126 |
# =============================================================================
|
| 127 |
|
|
|
|
| 128 |
def build_greeting_prompt(message: str, history: str | None = None) -> str:
|
| 129 |
"""
|
| 130 |
Build prompt for greeting/simple message response.
|
|
@@ -145,6 +210,29 @@ def build_greeting_prompt(message: str, history: str | None = None) -> str:
|
|
| 145 |
Hãy trả lời thân thiện bằng tiếng Việt. Đây là lời chào hoặc tin nhắn đơn giản, không cần tìm kiếm địa điểm."""
|
| 146 |
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def build_synthesis_prompt(
|
| 149 |
message: str,
|
| 150 |
context: str,
|
|
@@ -186,11 +274,17 @@ Câu hỏi hiện tại: {message}
|
|
| 186 |
|
| 187 |
{context}
|
| 188 |
|
| 189 |
-
**QUAN TRỌNG
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
```json
|
| 191 |
{{
|
| 192 |
-
"response": "Câu trả lời tiếng Việt
|
| 193 |
-
"selected_place_ids": ["place_id_1", "place_id_2"
|
| 194 |
}}
|
| 195 |
```
|
| 196 |
|
|
@@ -362,31 +456,75 @@ AVAILABLE_CATEGORIES = [
|
|
| 362 |
"Vietnamese restaurant",
|
| 363 |
]
|
| 364 |
|
| 365 |
-
# Category keywords for intent detection (user query -> category)
|
| 366 |
-
CATEGORY_KEYWORDS = {
|
| 367 |
-
'cafe': ['cafe', 'cà phê', 'coffee', 'caphe', 'caphê'],
|
| 368 |
-
'pho': ['phở', 'pho'],
|
| 369 |
-
'banh_mi': ['bánh mì', 'banh mi', 'bread'],
|
| 370 |
-
'seafood': ['hải sản', 'hai san', 'seafood', 'cá', 'tôm', 'cua'],
|
| 371 |
-
'restaurant': ['nhà hàng', 'restaurant', 'quán ăn', 'ăn'],
|
| 372 |
-
'bar': ['bar', 'pub', 'cocktail', 'beer', 'bia'],
|
| 373 |
-
'hotel': ['hotel', 'khách sạn', 'resort', 'villa'],
|
| 374 |
-
'japanese': ['nhật', 'japan', 'sushi', 'ramen'],
|
| 375 |
-
'korean': ['hàn', 'korea', 'bbq'],
|
| 376 |
-
}
|
| 377 |
|
| 378 |
-
#
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
|
| 392 |
# =============================================================================
|
|
@@ -409,10 +547,15 @@ __all__ = [
|
|
| 409 |
"SEARCH_SOCIAL_MEDIA_TOOL",
|
| 410 |
# Database constants
|
| 411 |
"AVAILABLE_CATEGORIES",
|
| 412 |
-
|
| 413 |
-
"
|
|
|
|
|
|
|
| 414 |
# Prompt builders
|
| 415 |
"build_greeting_prompt",
|
| 416 |
"build_synthesis_prompt",
|
| 417 |
"build_reasoning_prompt",
|
|
|
|
|
|
|
|
|
|
| 418 |
]
|
|
|
|
| 107 |
```
|
| 108 |
|
| 109 |
**Quan trọng:**
|
| 110 |
+
- Nếu là lời chào/small talk → action = "finish" với response thân thiện
|
| 111 |
- Nếu cần biết vị trí → dùng get_location_coordinates trước
|
| 112 |
- Nếu tìm theo khoảng cách → dùng find_nearby_places
|
| 113 |
- Nếu tìm review/trend MXH → dùng search_social_media
|
|
|
|
| 122 |
SYNTHESIS_SYSTEM_PROMPT = "Bạn là trợ lý du lịch thông minh cho Đà Nẵng. Trả lời format JSON."
|
| 123 |
|
| 124 |
|
| 125 |
+
# Intent detection prompt for LLM-based tool selection
|
| 126 |
+
INTENT_SYSTEM_PROMPT = "Bạn là AI phân tích intent. Trả lời CHÍNH XÁC format JSON, không giải thích thêm."
|
| 127 |
+
|
| 128 |
+
INTENT_DETECTION_PROMPT = """Phân tích câu hỏi của user và chọn tool(s) phù hợp nhất.
|
| 129 |
+
|
| 130 |
+
**Tools có sẵn:**
|
| 131 |
+
1. `retrieve_context_text` - Tìm kiếm semantic trong văn bản (review, mô tả, đặc điểm)
|
| 132 |
+
- Dùng khi: hỏi về chất lượng, đặc điểm, menu, giá cả, không khí
|
| 133 |
+
- Ví dụ: "quán cafe view đẹp", "phở ngon giá rẻ", "nơi lãng mạn"
|
| 134 |
+
|
| 135 |
+
2. `find_nearby_places` - Tìm địa điểm theo vị trí/khoảng cách
|
| 136 |
+
- Dùng khi: hỏi về vị trí, khoảng cách, "gần X", "quanh Y"
|
| 137 |
+
- Ví dụ: "quán gần Cầu Rồng", "cafe gần bãi biển"
|
| 138 |
+
- **Category mapping** (QUAN TRỌNG - dùng tiếng Anh):
|
| 139 |
+
- nhà hàng/quán ăn → "Restaurant"
|
| 140 |
+
- cafe/cà phê → "Coffee shop"
|
| 141 |
+
- bar/pub → "Bar"
|
| 142 |
+
- hotel/khách sạn → "Hotel"
|
| 143 |
+
- phở → "Pho restaurant"
|
| 144 |
+
- hải sản → "Seafood restaurant"
|
| 145 |
+
- Nhật/sushi → "Japanese restaurant"
|
| 146 |
+
- Hàn/BBQ → "Korean restaurant"
|
| 147 |
+
|
| 148 |
+
3. `search_social_media` - Tìm review/trend từ mạng xã hội
|
| 149 |
+
- Dùng khi: hỏi về review, tin hot, trend, viral, TikTok, Facebook
|
| 150 |
+
- Ví dụ: "review quán ăn trên TikTok", "tin hot tuần này"
|
| 151 |
+
|
| 152 |
+
4. `retrieve_similar_visuals` - Tìm theo hình ảnh tương tự
|
| 153 |
+
- Dùng khi: user gửi ảnh, hoặc mô tả về decor/không gian
|
| 154 |
+
- Ví dụ: "quán có không gian giống ảnh này"
|
| 155 |
+
|
| 156 |
+
**Trả lời theo format JSON:**
|
| 157 |
+
```json
|
| 158 |
+
{
|
| 159 |
+
"tools": [
|
| 160 |
+
{
|
| 161 |
+
"name": "tool_name",
|
| 162 |
+
"arguments": {"param": "value"},
|
| 163 |
+
"reason": "lý do ngắn gọn"
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"is_greeting": false
|
| 167 |
+
}
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
**Quy tắc QUAN TRỌNG:**
|
| 171 |
+
1. **Greeting/Small talk** (chào, cảm ơn, ok, được, tốt, bye):
|
| 172 |
+
→ `{"tools": [], "is_greeting": true}`
|
| 173 |
+
|
| 174 |
+
2. **Câu hỏi vị trí** (gần, quanh, cách):
|
| 175 |
+
→ `find_nearby_places` với location và category (DÙNG TIẾNG ANH cho category!)
|
| 176 |
+
|
| 177 |
+
3. **Câu hỏi chất lượng/đặc điểm** (ngon, đẹp, rẻ, view):
|
| 178 |
+
→ `retrieve_context_text`
|
| 179 |
+
|
| 180 |
+
4. **Review/Trend MXH** (tiktok, facebook, viral):
|
| 181 |
+
→ `search_social_media`
|
| 182 |
+
|
| 183 |
+
5. Có thể chọn **NHIỀU tool** nếu query phức tạp
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
|
| 188 |
# =============================================================================
|
| 189 |
# PROMPT TEMPLATES
|
| 190 |
# =============================================================================
|
| 191 |
|
| 192 |
+
|
| 193 |
def build_greeting_prompt(message: str, history: str | None = None) -> str:
|
| 194 |
"""
|
| 195 |
Build prompt for greeting/simple message response.
|
|
|
|
| 210 |
Hãy trả lời thân thiện bằng tiếng Việt. Đây là lời chào hoặc tin nhắn đơn giản, không cần tìm kiếm địa điểm."""
|
| 211 |
|
| 212 |
|
| 213 |
+
def build_intent_prompt(message: str, has_image: bool = False) -> str:
|
| 214 |
+
"""
|
| 215 |
+
Build prompt for LLM-based intent detection.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
message: User's query
|
| 219 |
+
has_image: Whether user provided an image
|
| 220 |
+
|
| 221 |
+
Returns:
|
| 222 |
+
Formatted prompt for intent detection
|
| 223 |
+
"""
|
| 224 |
+
image_hint = ""
|
| 225 |
+
if has_image:
|
| 226 |
+
image_hint = "\n\n**Lưu ý:** User đã gửi kèm ảnh → nên dùng `retrieve_similar_visuals`"
|
| 227 |
+
|
| 228 |
+
return f"""{INTENT_DETECTION_PROMPT}
|
| 229 |
+
{image_hint}
|
| 230 |
+
**Câu hỏi của user:** "{message}"
|
| 231 |
+
|
| 232 |
+
Trả lời JSON:"""
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
|
| 236 |
def build_synthesis_prompt(
|
| 237 |
message: str,
|
| 238 |
context: str,
|
|
|
|
| 274 |
|
| 275 |
{context}
|
| 276 |
|
| 277 |
+
**QUAN TRỌNG - ĐỌC KỸ:**
|
| 278 |
+
1. KHÔNG viết code, KHÔNG gọi tool, KHÔNG output tool_code
|
| 279 |
+
2. Chỉ trả lời bằng văn bản tiếng Việt thân thiện
|
| 280 |
+
3. Giới thiệu 2-3 địa điểm phù hợp nhất từ kết quả trên
|
| 281 |
+
4. Nếu không có kết quả phù hợp, thông báo và đề xuất thử cách khác
|
| 282 |
+
|
| 283 |
+
**Trả lời theo format JSON:**
|
| 284 |
```json
|
| 285 |
{{
|
| 286 |
+
"response": "Câu trả lời tiếng Việt thân thiện, giới thiệu địa điểm cụ thể với tên, rating, mô tả ngắn.",
|
| 287 |
+
"selected_place_ids": ["place_id_1", "place_id_2"]
|
| 288 |
}}
|
| 289 |
```
|
| 290 |
|
|
|
|
| 456 |
"Vietnamese restaurant",
|
| 457 |
]
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
|
| 460 |
+
# =============================================================================
|
| 461 |
+
# SMART PLAN PROMPTS
|
| 462 |
+
# =============================================================================
|
| 463 |
+
|
| 464 |
+
SMART_PLAN_SYSTEM_PROMPT = """Bạn là chuyên gia lập lịch trình du lịch Đà Nẵng.
|
| 465 |
+
|
| 466 |
+
**Kiến thức đặc biệt về Đà Nẵng:**
|
| 467 |
+
- Cầu Rồng phun lửa/nước: 21h Thứ 7, Chủ Nhật
|
| 468 |
+
- Bãi biển Mỹ Khê: 5-7h sáng (bình minh đẹp) hoặc 16-18h (hoàng hôn)
|
| 469 |
+
- Chợ Hàn: Sáng sớm 6-9h (đồ tươi, giá tốt) hoặc chiều tối 16-20h
|
| 470 |
+
- Bà Nà Hills: Đến sớm 8h để tránh đông, mát mẻ hơn
|
| 471 |
+
- Sơn Trà: Sáng sớm 5-6h để săn mây, ngắm voọc
|
| 472 |
+
- Ngũ Hành Sơn: Sớm 7-8h, mát và ít đông
|
| 473 |
+
- Hội An: Chiều tối 16-20h đẹp nhất, thả đèn hoa đăng tối
|
| 474 |
+
|
| 475 |
+
**Nhiệm vụ:**
|
| 476 |
+
1. Sắp xếp thời gian tối ưu cho từng địa điểm
|
| 477 |
+
2. Thêm tips hữu ích dựa trên kiến thức địa phương
|
| 478 |
+
3. Ước tính thời gian hợp lý tại mỗi nơi
|
| 479 |
+
|
| 480 |
+
Trả lời format JSON."""
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def build_smart_plan_prompt(places: list[dict], total_days: int = 1) -> str:
|
| 484 |
+
"""
|
| 485 |
+
Build prompt for LLM to optimize smart plan timing.
|
| 486 |
+
|
| 487 |
+
Args:
|
| 488 |
+
places: List of place info dicts with name, category, social_info
|
| 489 |
+
total_days: Number of days for the trip
|
| 490 |
+
|
| 491 |
+
Returns:
|
| 492 |
+
Formatted prompt string
|
| 493 |
+
"""
|
| 494 |
+
places_text = ""
|
| 495 |
+
for i, p in enumerate(places, 1):
|
| 496 |
+
places_text += f"{i}. {p.get('name', 'Unknown')}"
|
| 497 |
+
if p.get('category'):
|
| 498 |
+
places_text += f" [{p['category']}]"
|
| 499 |
+
if p.get('social_info'):
|
| 500 |
+
places_text += f"\n Social: {p['social_info'][:150]}"
|
| 501 |
+
places_text += "\n"
|
| 502 |
+
|
| 503 |
+
return f"""Hãy tối ưu lịch trình {total_days} ngày với các địa điểm sau:
|
| 504 |
+
|
| 505 |
+
{places_text}
|
| 506 |
+
|
| 507 |
+
**Yêu cầu:**
|
| 508 |
+
1. Gợi ý thời gian đến (time) tối ưu cho mỗi địa điểm
|
| 509 |
+
2. Thêm tips hữu ích (dựa trên kiến thức địa phương Đà Nẵng)
|
| 510 |
+
3. Ước tính thời gian ở (duration) tính bằng phút
|
| 511 |
+
|
| 512 |
+
**Trả lời JSON:**
|
| 513 |
+
```json
|
| 514 |
+
{{
|
| 515 |
+
"places": [
|
| 516 |
+
{{
|
| 517 |
+
"name": "Tên địa điểm",
|
| 518 |
+
"time": "HH:MM",
|
| 519 |
+
"duration": 60,
|
| 520 |
+
"tips": ["Tip 1", "Tip 2"]
|
| 521 |
+
}}
|
| 522 |
+
],
|
| 523 |
+
"day_summaries": [
|
| 524 |
+
"Ngày 1: Khám phá bãi biển và ẩm thực"
|
| 525 |
+
]
|
| 526 |
+
}}
|
| 527 |
+
```"""
|
| 528 |
|
| 529 |
|
| 530 |
# =============================================================================
|
|
|
|
| 547 |
"SEARCH_SOCIAL_MEDIA_TOOL",
|
| 548 |
# Database constants
|
| 549 |
"AVAILABLE_CATEGORIES",
|
| 550 |
+
# Intent detection
|
| 551 |
+
"INTENT_SYSTEM_PROMPT",
|
| 552 |
+
"INTENT_DETECTION_PROMPT",
|
| 553 |
+
"build_intent_prompt",
|
| 554 |
# Prompt builders
|
| 555 |
"build_greeting_prompt",
|
| 556 |
"build_synthesis_prompt",
|
| 557 |
"build_reasoning_prompt",
|
| 558 |
+
# Smart Plan
|
| 559 |
+
"SMART_PLAN_SYSTEM_PROMPT",
|
| 560 |
+
"build_smart_plan_prompt",
|
| 561 |
]
|
docs/API_REFERENCE.md
CHANGED
|
@@ -541,6 +541,99 @@ Delete a plan.
|
|
| 541 |
|
| 542 |
---
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
## Utility Endpoints
|
| 545 |
|
| 546 |
### GET `/health`
|
|
@@ -604,6 +697,42 @@ interface WorkflowStep {
|
|
| 604 |
}
|
| 605 |
```
|
| 606 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
---
|
| 608 |
|
| 609 |
## Usage Examples
|
|
|
|
| 541 |
|
| 542 |
---
|
| 543 |
|
| 544 |
+
### POST `/planner/{plan_id}/get-plan`
|
| 545 |
+
|
| 546 |
+
Generate an optimized Smart Plan with social media research and optimal timing.
|
| 547 |
+
|
| 548 |
+
**Query:** `?user_id=user_123`
|
| 549 |
+
|
| 550 |
+
**Request:**
|
| 551 |
+
```json
|
| 552 |
+
{
|
| 553 |
+
"include_social_research": true,
|
| 554 |
+
"freshness": "pw"
|
| 555 |
+
}
|
| 556 |
+
```
|
| 557 |
+
|
| 558 |
+
| Field | Type | Description |
|
| 559 |
+
|-------|------|-------------|
|
| 560 |
+
| `include_social_research` | boolean | Enable Brave Search for reviews (default: true) |
|
| 561 |
+
| `freshness` | string | "pw" = past week, "pm" = past month |
|
| 562 |
+
|
| 563 |
+
**Response:**
|
| 564 |
+
```json
|
| 565 |
+
{
|
| 566 |
+
"plan": {
|
| 567 |
+
"itinerary_id": "plan_abc123",
|
| 568 |
+
"title": "Da Nang Test Trip",
|
| 569 |
+
"total_days": 1,
|
| 570 |
+
"days": [
|
| 571 |
+
{
|
| 572 |
+
"day_index": 1,
|
| 573 |
+
"date": null,
|
| 574 |
+
"places": [
|
| 575 |
+
{
|
| 576 |
+
"place_id": "my_khe_1",
|
| 577 |
+
"name": "Bãi biển Mỹ Khê",
|
| 578 |
+
"category": "beach",
|
| 579 |
+
"lat": 16.0544,
|
| 580 |
+
"lng": 108.245,
|
| 581 |
+
"recommended_time": "05:30",
|
| 582 |
+
"suggested_duration_min": 90,
|
| 583 |
+
"tips": [
|
| 584 |
+
"Đón bình minh đẹp nhất vào khoảng 5h30 - 6h00 sáng.",
|
| 585 |
+
"Xem ngư dân kéo lưới và mua hải sản tươi sống."
|
| 586 |
+
],
|
| 587 |
+
"highlights": "Summary from social research...",
|
| 588 |
+
"social_mentions": ["[TikTok] Review Mỹ Khê..."],
|
| 589 |
+
"order": 1
|
| 590 |
+
},
|
| 591 |
+
{
|
| 592 |
+
"place_id": "cau_rong_1",
|
| 593 |
+
"name": "Cầu Rồng",
|
| 594 |
+
"category": "attraction",
|
| 595 |
+
"recommended_time": "20:30",
|
| 596 |
+
"suggested_duration_min": 60,
|
| 597 |
+
"tips": [
|
| 598 |
+
"Nếu đi vào Thứ 7 hoặc Chủ Nhật, Rồng sẽ phun lửa và nước vào lúc 21:00.",
|
| 599 |
+
"Đứng ở phía đầu Rồng để xem rõ nhất."
|
| 600 |
+
],
|
| 601 |
+
"order": 2
|
| 602 |
+
}
|
| 603 |
+
],
|
| 604 |
+
"day_summary": "Ngày 1: 2 địa điểm",
|
| 605 |
+
"day_distance_km": 3.57
|
| 606 |
+
}
|
| 607 |
+
],
|
| 608 |
+
"summary": "Lịch trình với 2 địa điểm trong 1 ngày.",
|
| 609 |
+
"total_distance_km": 3.57,
|
| 610 |
+
"estimated_total_duration_min": 150,
|
| 611 |
+
"generated_at": "2025-12-20T06:20:59"
|
| 612 |
+
},
|
| 613 |
+
"research_count": 5,
|
| 614 |
+
"generation_time_ms": 9486.47,
|
| 615 |
+
"message": "Smart plan generated with 5 social mentions"
|
| 616 |
+
}
|
| 617 |
+
```
|
| 618 |
+
|
| 619 |
+
> **Features:**
|
| 620 |
+
> - **Optimal Timing**: Da Nang local knowledge (Dragon Bridge 21h, Mỹ Khê sunrise)
|
| 621 |
+
> - **Social Research**: Pulls reviews from TikTok, Facebook, Reddit via Brave API
|
| 622 |
+
> - **LLM Enhancement**: Uses Gemini 3 Flash for tips and scheduling
|
| 623 |
+
> - **Distance Calculation**: Haversine formula for route distances
|
| 624 |
+
|
| 625 |
+
---
|
| 626 |
+
|
| 627 |
+
### POST `/itineraries/{itinerary_id}/get-plan`
|
| 628 |
+
|
| 629 |
+
Same as above, but for multi-day itineraries (persistent storage).
|
| 630 |
+
|
| 631 |
+
**Query:** `?user_id=uuid-here`
|
| 632 |
+
|
| 633 |
+
**Request/Response:** Same format as planner get-plan
|
| 634 |
+
|
| 635 |
+
---
|
| 636 |
+
|
| 637 |
## Utility Endpoints
|
| 638 |
|
| 639 |
### GET `/health`
|
|
|
|
| 697 |
}
|
| 698 |
```
|
| 699 |
|
| 700 |
+
### SmartPlan (Get Plan Response)
|
| 701 |
+
```typescript
|
| 702 |
+
interface SmartPlan {
|
| 703 |
+
itinerary_id: string;
|
| 704 |
+
title: string;
|
| 705 |
+
total_days: number;
|
| 706 |
+
days: DayPlan[];
|
| 707 |
+
summary: string;
|
| 708 |
+
total_distance_km: number;
|
| 709 |
+
estimated_total_duration_min: number;
|
| 710 |
+
generated_at: string;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
interface DayPlan {
|
| 714 |
+
day_index: number;
|
| 715 |
+
date?: string;
|
| 716 |
+
places: PlaceDetail[];
|
| 717 |
+
day_summary: string;
|
| 718 |
+
day_distance_km: number;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
interface PlaceDetail {
|
| 722 |
+
place_id: string;
|
| 723 |
+
name: string;
|
| 724 |
+
category: string;
|
| 725 |
+
lat: number;
|
| 726 |
+
lng: number;
|
| 727 |
+
recommended_time: string; // "05:30", "21:00"
|
| 728 |
+
suggested_duration_min: number;
|
| 729 |
+
tips: string[]; // ["Xem cầu rồng phun lửa..."]
|
| 730 |
+
highlights: string; // Summary from social research
|
| 731 |
+
social_mentions: string[]; // ["[TikTok] Review..."]
|
| 732 |
+
order: number;
|
| 733 |
+
}
|
| 734 |
+
```
|
| 735 |
+
|
| 736 |
---
|
| 737 |
|
| 738 |
## Usage Examples
|
tests/react_comparison_report.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
# LocalMate Agent Comprehensive Test Report
|
| 2 |
|
| 3 |
-
**Generated:** 2025-12-
|
| 4 |
-
**Provider:**
|
| 5 |
-
**Model:**
|
| 6 |
|
| 7 |
---
|
| 8 |
|
|
@@ -11,8 +11,8 @@
|
|
| 11 |
| Metric | Single Mode | ReAct Mode |
|
| 12 |
|--------|:-----------:|:----------:|
|
| 13 |
| Success Rate | 5/5 | 5/5 |
|
| 14 |
-
| Avg Duration |
|
| 15 |
-
| Unique Tools | 3 |
|
| 16 |
|
| 17 |
### Tools Covered
|
| 18 |
|
|
@@ -20,7 +20,7 @@
|
|
| 20 |
|------|:-----------:|:----------:|
|
| 21 |
| `retrieve_context_text` | ✅ | ✅ |
|
| 22 |
| `find_nearby_places` | ✅ | ✅ |
|
| 23 |
-
| `search_social_media` | ✅ |
|
| 24 |
| No tools (greeting) | ✅ | ✅ |
|
| 25 |
|
| 26 |
---
|
|
@@ -30,10 +30,10 @@
|
|
| 30 |
| ID | Description | Single Tools | ReAct Tools | Match |
|
| 31 |
|----|-------------|--------------|-------------|-------|
|
| 32 |
| 1 | Greeting - No tools expected | ∅ (none) | ∅ (none) | ✅ Match/✅ Match |
|
| 33 |
-
| 2 | Text search - Semantic search | retrieve_context_text | retrieve_context_text
|
| 34 |
| 3 | Location search - Neo4j spatia | find_nearby_places | get_location_coordinates, find_nearby_places | ✅ Match/⚠️ Extra tools |
|
| 35 |
| 4 | Social search - Brave API news | search_social_media | ∅ (none) | ✅ Match/❌ Mismatch |
|
| 36 |
-
| 5 | Complex query - Multiple tools |
|
| 37 |
|
| 38 |
---
|
| 39 |
|
|
@@ -46,12 +46,12 @@
|
|
| 46 |
|
| 47 |
| Mode | Status | Duration | Tools Used | Places |
|
| 48 |
|------|--------|----------|------------|--------|
|
| 49 |
-
| Single | ✅ |
|
| 50 |
-
| ReAct | ✅ |
|
| 51 |
|
| 52 |
-
**Single Response:**
|
| 53 |
|
| 54 |
-
**ReAct Response:**
|
| 55 |
|
| 56 |
---
|
| 57 |
|
|
@@ -62,18 +62,16 @@
|
|
| 62 |
|
| 63 |
| Mode | Status | Duration | Tools Used | Places |
|
| 64 |
|------|--------|----------|------------|--------|
|
| 65 |
-
| Single | ✅ |
|
| 66 |
-
| ReAct | ✅ |
|
| 67 |
|
| 68 |
-
**Single Response:** Chào bạn!
|
| 69 |
|
| 70 |
-
|
| 71 |
-
- View đẹp với không gian lãng mạn, view ...
|
| 72 |
|
| 73 |
-
**ReAct Response:** Chào bạn!
|
| 74 |
|
| 75 |
-
|
| 76 |
-
- Quán cafe với không gian retro ấm cú...
|
| 77 |
|
| 78 |
---
|
| 79 |
|
|
@@ -84,17 +82,16 @@
|
|
| 84 |
|
| 85 |
| Mode | Status | Duration | Tools Used | Places |
|
| 86 |
|------|--------|----------|------------|--------|
|
| 87 |
-
| Single | ✅ |
|
| 88 |
-
| ReAct | ✅ |
|
| 89 |
|
| 90 |
-
**Single Response:** Chào bạn!
|
| 91 |
|
| 92 |
-
|
| 93 |
|
| 94 |
-
**ReAct Response:** Chào bạn!
|
| 95 |
|
| 96 |
-
|
| 97 |
-
- Nhà hàng pizza với đ...
|
| 98 |
|
| 99 |
---
|
| 100 |
|
|
@@ -105,15 +102,12 @@
|
|
| 105 |
|
| 106 |
| Mode | Status | Duration | Tools Used | Places |
|
| 107 |
|------|--------|----------|------------|--------|
|
| 108 |
-
| Single | ✅ |
|
| 109 |
-
| ReAct | ✅ |
|
| 110 |
|
| 111 |
-
**Single Response:** Chào bạn!
|
| 112 |
|
| 113 |
-
|
| 114 |
-
- Đang được cộng đồng Tik...
|
| 115 |
-
|
| 116 |
-
**ReAct Response:** Xin lỗi, đã xảy ra lỗi: Unknown error...
|
| 117 |
|
| 118 |
---
|
| 119 |
|
|
@@ -124,15 +118,14 @@
|
|
| 124 |
|
| 125 |
| Mode | Status | Duration | Tools Used | Places |
|
| 126 |
|------|--------|----------|------------|--------|
|
| 127 |
-
| Single | ✅ |
|
| 128 |
-
| ReAct | ✅ |
|
| 129 |
|
| 130 |
-
**Single Response:** Chào bạn!
|
| 131 |
|
| 132 |
-
|
| 133 |
-
- Quán cafe acousti...
|
| 134 |
|
| 135 |
-
**ReAct Response:** Xin lỗi, đã xảy ra lỗi:
|
| 136 |
|
| 137 |
---
|
| 138 |
|
|
@@ -141,7 +134,7 @@
|
|
| 141 |
| Criteria | Single Mode | ReAct Mode |
|
| 142 |
|----------|:-----------:|:----------:|
|
| 143 |
| All tests passed | ✅ | ✅ |
|
| 144 |
-
| All 3 search tools covered | ✅ |
|
| 145 |
| Greeting detection works | ✅ | ✅ |
|
| 146 |
|
| 147 |
**Overall:** 🎉 ALL TESTS PASSED!
|
|
|
|
| 1 |
# LocalMate Agent Comprehensive Test Report
|
| 2 |
|
| 3 |
+
**Generated:** 2025-12-20 06:39:19
|
| 4 |
+
**Provider:** Google
|
| 5 |
+
**Model:** gemini-2.5-flash
|
| 6 |
|
| 7 |
---
|
| 8 |
|
|
|
|
| 11 |
| Metric | Single Mode | ReAct Mode |
|
| 12 |
|--------|:-----------:|:----------:|
|
| 13 |
| Success Rate | 5/5 | 5/5 |
|
| 14 |
+
| Avg Duration | 11740ms | 3264ms |
|
| 15 |
+
| Unique Tools | 3 | 3 |
|
| 16 |
|
| 17 |
### Tools Covered
|
| 18 |
|
|
|
|
| 20 |
|------|:-----------:|:----------:|
|
| 21 |
| `retrieve_context_text` | ✅ | ✅ |
|
| 22 |
| `find_nearby_places` | ✅ | ✅ |
|
| 23 |
+
| `search_social_media` | ✅ | ❌ |
|
| 24 |
| No tools (greeting) | ✅ | ✅ |
|
| 25 |
|
| 26 |
---
|
|
|
|
| 30 |
| ID | Description | Single Tools | ReAct Tools | Match |
|
| 31 |
|----|-------------|--------------|-------------|-------|
|
| 32 |
| 1 | Greeting - No tools expected | ∅ (none) | ∅ (none) | ✅ Match/✅ Match |
|
| 33 |
+
| 2 | Text search - Semantic search | retrieve_context_text | retrieve_context_text | ✅ Match/✅ Match |
|
| 34 |
| 3 | Location search - Neo4j spatia | find_nearby_places | get_location_coordinates, find_nearby_places | ✅ Match/⚠️ Extra tools |
|
| 35 |
| 4 | Social search - Brave API news | search_social_media | ∅ (none) | ✅ Match/❌ Mismatch |
|
| 36 |
+
| 5 | Complex query - Multiple tools | find_nearby_places, retrieve_context_text | ∅ (none) | ✅ Match/❌ Mismatch |
|
| 37 |
|
| 38 |
---
|
| 39 |
|
|
|
|
| 46 |
|
| 47 |
| Mode | Status | Duration | Tools Used | Places |
|
| 48 |
|------|--------|----------|------------|--------|
|
| 49 |
+
| Single | ✅ | 2855ms | None | 0 |
|
| 50 |
+
| ReAct | ✅ | 1598ms | None | 0 |
|
| 51 |
|
| 52 |
+
**Single Response:** Chào bạn! Mình là LocalMate, rất vui được hỗ trợ bạn khám phá Đà Nẵng. 😊...
|
| 53 |
|
| 54 |
+
**ReAct Response:** Chào bạn! Mình là trợ lý du lịch thông minh của Đà Nẵng, rất vui được hỗ trợ bạn. Bạn đang muốn tìm kiếm thông tin gì về Đà Nẵng ạ? Hãy cho mình biết ...
|
| 55 |
|
| 56 |
---
|
| 57 |
|
|
|
|
| 62 |
|
| 63 |
| Mode | Status | Duration | Tools Used | Places |
|
| 64 |
|------|--------|----------|------------|--------|
|
| 65 |
+
| Single | ✅ | 14155ms | retrieve_context_text | 3 |
|
| 66 |
+
| ReAct | ✅ | 5694ms | retrieve_context_text | 2 |
|
| 67 |
|
| 68 |
+
**Single Response:** Chào bạn! Với yêu cầu tìm quán cà phê view đẹp ở Đà Nẵng, mình có một vài gợi ý tuyệt vời dành cho bạn đây:
|
| 69 |
|
| 70 |
+
1. **Nhớ Một Người**: Quán này có rating...
|
|
|
|
| 71 |
|
| 72 |
+
**ReAct Response:** Chào bạn! Mình đã tìm thấy một vài quán cà phê có thể có "view đẹp" ở Đà Nẵng để bạn tham khảo đây:
|
| 73 |
|
| 74 |
+
1. **FIRGUN CORNER COFFEE**: Với rating 4.5/5, ...
|
|
|
|
| 75 |
|
| 76 |
---
|
| 77 |
|
|
|
|
| 82 |
|
| 83 |
| Mode | Status | Duration | Tools Used | Places |
|
| 84 |
|------|--------|----------|------------|--------|
|
| 85 |
+
| Single | ✅ | 19588ms | find_nearby_places | 3 |
|
| 86 |
+
| ReAct | ✅ | 7989ms | get_location_coordinates, find_nearby_places | 3 |
|
| 87 |
|
| 88 |
+
**Single Response:** Chào bạn! Mình đã tìm thấy một vài nhà hàng ngon gần Cầu Rồng cho bạn đây:
|
| 89 |
|
| 90 |
+
1. **Cơm Gà Lan**: Nổi tiếng với các món cơm gà, quán này có rating khá c...
|
| 91 |
|
| 92 |
+
**ReAct Response:** Chào bạn! Gần Cầu Rồng có khá nhiều nhà hàng ngon để bạn lựa chọn đây:
|
| 93 |
|
| 94 |
+
1. **Pizza 4P's Hoang Van Thu**: Quán pizza nổi tiếng này chỉ cách Cầu Rồng k...
|
|
|
|
| 95 |
|
| 96 |
---
|
| 97 |
|
|
|
|
| 102 |
|
| 103 |
| Mode | Status | Duration | Tools Used | Places |
|
| 104 |
|------|--------|----------|------------|--------|
|
| 105 |
+
| Single | ✅ | 7301ms | search_social_media | 0 |
|
| 106 |
+
| ReAct | ✅ | 507ms | None | 0 |
|
| 107 |
|
| 108 |
+
**Single Response:** Chào bạn! Hiện tại mình chưa có thông tin cụ thể về các quán ăn đang hot trên TikTok ở Đà Nẵng ngay lúc này. Bạn có muốn mình thử tìm kiếm các quán ăn...
|
| 109 |
|
| 110 |
+
**ReAct Response:** Xin lỗi, đã xảy ra lỗi: 429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'You exceeded your current quota, please check your plan and billin...
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
---
|
| 113 |
|
|
|
|
| 118 |
|
| 119 |
| Mode | Status | Duration | Tools Used | Places |
|
| 120 |
|------|--------|----------|------------|--------|
|
| 121 |
+
| Single | ✅ | 14802ms | find_nearby_places, retrieve_context_text | 3 |
|
| 122 |
+
| ReAct | ✅ | 531ms | None | 0 |
|
| 123 |
|
| 124 |
+
**Single Response:** Chào bạn! Mình đã tìm thấy một vài quán cà phê có không gian đẹp và được đánh giá tốt, lại còn gần biển Mỹ Khê nữa đây:
|
| 125 |
|
| 126 |
+
1. **VUN**: Quán này chỉ các...
|
|
|
|
| 127 |
|
| 128 |
+
**ReAct Response:** Xin lỗi, đã xảy ra lỗi: 429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'You exceeded your current quota, please check your plan and billin...
|
| 129 |
|
| 130 |
---
|
| 131 |
|
|
|
|
| 134 |
| Criteria | Single Mode | ReAct Mode |
|
| 135 |
|----------|:-----------:|:----------:|
|
| 136 |
| All tests passed | ✅ | ✅ |
|
| 137 |
+
| All 3 search tools covered | ✅ | ❌ |
|
| 138 |
| Greeting detection works | ✅ | ✅ |
|
| 139 |
|
| 140 |
**Overall:** 🎉 ALL TESTS PASSED!
|
tests/test_react_comparison.py
CHANGED
|
@@ -22,18 +22,22 @@ import httpx
|
|
| 22 |
# =============================================================================
|
| 23 |
|
| 24 |
# API Settings - Use localhost for local testing
|
|
|
|
| 25 |
API_BASE = "http://localhost:8000/api/v1"
|
| 26 |
USER_ID = "test_comprehensive"
|
| 27 |
|
| 28 |
# Delay Settings (in seconds)
|
| 29 |
-
SINGLE_MODE_DELAY =
|
| 30 |
-
REACT_MODE_DELAY =
|
| 31 |
-
MODE_SWITCH_DELAY =
|
| 32 |
REQUEST_TIMEOUT = 60 # Timeout for each API request
|
| 33 |
|
| 34 |
# Provider settings
|
| 35 |
-
PROVIDER = "MegaLLM"
|
| 36 |
-
MODEL = "deepseek-ai/deepseek-v3.1-terminus"
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
# =============================================================================
|
| 39 |
# 5 TEST CASES - Covering ALL tools
|
|
|
|
| 22 |
# =============================================================================
|
| 23 |
|
| 24 |
# API Settings - Use localhost for local testing
|
| 25 |
+
# API_BASE = "https://cuong2004-localmate.hf.space/api/v1"
|
| 26 |
API_BASE = "http://localhost:8000/api/v1"
|
| 27 |
USER_ID = "test_comprehensive"
|
| 28 |
|
| 29 |
# Delay Settings (in seconds)
|
| 30 |
+
SINGLE_MODE_DELAY = 20 # Delay between queries in single mode
|
| 31 |
+
REACT_MODE_DELAY = 60 # Delay between queries in ReAct mode
|
| 32 |
+
MODE_SWITCH_DELAY = 60 # Delay between switching modes
|
| 33 |
REQUEST_TIMEOUT = 60 # Timeout for each API request
|
| 34 |
|
| 35 |
# Provider settings
|
| 36 |
+
# PROVIDER = "MegaLLM"
|
| 37 |
+
# MODEL = "deepseek-ai/deepseek-v3.1-terminus"
|
| 38 |
+
PROVIDER = "Google"
|
| 39 |
+
# MODEL = "gemini-3-flash-preview"
|
| 40 |
+
MODEL = "gemini-2.5-flash"
|
| 41 |
|
| 42 |
# =============================================================================
|
| 43 |
# 5 TEST CASES - Covering ALL tools
|