Cuong2004 commited on
Commit
51ba917
·
1 Parent(s): 9e98b5a

fix intent and add plan

Browse files
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 decide tool usage
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
- Analyze message and plan which tools to call.
287
 
288
  Returns list of ToolCall objects with tool_name and arguments.
289
- Returns empty list for simple greetings (no tools needed).
290
  """
291
- # Early exit for greetings - no tools needed
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
- tool_calls.append(ToolCall(
301
  tool_name="retrieve_similar_visuals",
302
  arguments={"image_url": image_url, "limit": 5},
303
- ))
304
-
305
- # Check for social media intent FIRST
306
- social_keywords = ["review", "tin hot", "trend", "tin mới", "tiktok", "facebook", "reddit", "youtube", "mạng xã hội"]
307
- if any(kw in message.lower() for kw in social_keywords):
308
- # Determine freshness
309
- freshness = "pw" # Default past week
310
- if "tháng" in message.lower() or "month" in message.lower():
311
- freshness = "pm"
 
 
312
 
313
- # Determine platforms
314
- platforms = []
315
- for p in ["tiktok", "facebook", "reddit", "youtube", "twitter", "instagram"]:
316
- if p in message.lower():
317
- platforms.append(p)
318
 
319
- tool_calls.append(ToolCall(
320
- tool_name="search_social_media",
321
- arguments={
322
- "query": message,
323
- "limit": 5,
324
- "freshness": freshness,
325
- "platforms": platforms if platforms else None
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=tool_call.arguments.get("lat", DANANG_CENTER[0]),
410
- lng=tool_call.arguments.get("lng", DANANG_CENTER[1]),
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
- __all__ = [
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(self, query: str, limit: int = 10, freshness: str = "pw", platforms: List[str] = None) -> List[SocialSearchResult]:
 
 
 
 
 
 
 
 
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
- # Use general search with social/news filter - don't add complex site: operators
50
- # as they can cause issues with Brave API for non-English queries
51
- full_query = f"{query} review tin tức" # Add context for social/news
 
 
 
 
 
 
52
  else:
53
- # Add platform names as context keywords instead of site: operators
54
- platform_keywords = []
55
  for p in platforms:
56
  p = p.lower()
57
- if "facebook" in p: platform_keywords.append("facebook")
58
- elif "reddit" in p: platform_keywords.append("reddit")
59
- elif "twitter" in p or "x" == p: platform_keywords.append("twitter")
60
- elif "linkedin" in p: platform_keywords.append("linkedin")
61
- elif "tiktok" in p: platform_keywords.append("tiktok")
62
- elif "instagram" in p: platform_keywords.append("instagram")
63
- elif "youtube" in p: platform_keywords.append("youtube")
64
-
65
- if platform_keywords:
66
- full_query = f"{query} {' '.join(platform_keywords)}"
67
- else:
68
- full_query = query
 
 
 
 
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
- # Simple heuristic
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(query: str, limit: int = 10, freshness: str = "pw", platforms: List[str] = None) -> List[SocialSearchResult]:
 
 
 
 
 
 
 
124
  """
125
- Search for social media content (news, discussions) about a topic.
126
  """
127
- return await social_search_tool.search(query, limit, freshness, platforms)
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 category boosting
106
  scored_results = []
107
  for r in rows:
108
  score = float(r.similarity)
109
 
110
- # Category boost (15%)
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:** Trả lời theo format JSON:
 
 
 
 
 
 
190
  ```json
191
  {{
192
- "response": "Câu trả lời tiếng Việt, thân thiện. Giới thiệu top 2-3 địa điểm phù hợp nhất.",
193
- "selected_place_ids": ["place_id_1", "place_id_2", "place_id_3"]
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
- # Category keyword -> Database category name mapping
379
- CATEGORY_TO_DB = {
380
- 'cafe': ['Coffee shop', 'Cafe', 'Coffee house', 'Espresso bar'],
381
- 'pho': ['Pho restaurant', 'Bistro', 'Restaurant', 'Vietnamese restaurant'],
382
- 'banh_mi': ['Bakery', 'Tiffin center', 'Restaurant'],
383
- 'seafood': ['Seafood restaurant', 'Restaurant', 'Asian restaurant'],
384
- 'restaurant': ['Restaurant', 'Vietnamese restaurant', 'Asian restaurant'],
385
- 'bar': ['Bar', 'Cocktail bar', 'Pub', 'Night club', 'Live music bar'],
386
- 'hotel': ['Hotel', 'Resort', 'Apartment', 'Villa', 'Holiday apartment rental'],
387
- 'japanese': ['Japanese restaurant', 'Sushi restaurant', 'Ramen restaurant'],
388
- 'korean': ['Korean restaurant', 'Korean barbecue restaurant'],
389
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
 
392
  # =============================================================================
@@ -409,10 +547,15 @@ __all__ = [
409
  "SEARCH_SOCIAL_MEDIA_TOOL",
410
  # Database constants
411
  "AVAILABLE_CATEGORIES",
412
- "CATEGORY_KEYWORDS",
413
- "CATEGORY_TO_DB",
 
 
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 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-19 21:59:06
4
- **Provider:** MegaLLM
5
- **Model:** deepseek-ai/deepseek-v3.1-terminus
6
 
7
  ---
8
 
@@ -11,8 +11,8 @@
11
  | Metric | Single Mode | ReAct Mode |
12
  |--------|:-----------:|:----------:|
13
  | Success Rate | 5/5 | 5/5 |
14
- | Avg Duration | 13255ms | 24551ms |
15
- | Unique Tools | 3 | 4 |
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, get_location_coordinates, find_nearby_places | ✅ Match/⚠️ Extra tools |
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 | search_social_media, find_nearby_places | get_location_coordinates, find_nearby_places, retrieve_context_text, search_social_media | ⚠️ Partial/⚠️ Extra tools |
37
 
38
  ---
39
 
@@ -46,12 +46,12 @@
46
 
47
  | Mode | Status | Duration | Tools Used | Places |
48
  |------|--------|----------|------------|--------|
49
- | Single | ✅ | 3818ms | None | 0 |
50
- | ReAct | ✅ | 3504ms | None | 0 |
51
 
52
- **Single Response:** Xin chào! ❤️ Rất vui được gặp bạn! Mình là LocalMate - trợ du lịch Đà Nẵng của bạn. Cần mình giúp gì về ăn uống, vui chơi, hay địa điểm check-in nà...
53
 
54
- **ReAct Response:** Xin chào bạn! 😊 Mình là trợ lý du lịch thông minh cho Đà Nẵng. Mình thể giúp bạn tìm kiếm nhà hàng ngon, quán cafe đẹp, điểm vui chơi nhiều địa ...
55
 
56
  ---
57
 
@@ -62,18 +62,16 @@
62
 
63
  | Mode | Status | Duration | Tools Used | Places |
64
  |------|--------|----------|------------|--------|
65
- | Single | ✅ | 24590ms | retrieve_context_text | 3 |
66
- | ReAct | ✅ | 31407ms | retrieve_context_text, get_location_coordinates, find_nearby_places | 3 |
67
 
68
- **Single Response:** Chào bạn! 😊 Mình tìm được một số quán cafe view đẹp ở Đà Nẵng cho bạn đây:
69
 
70
- 🌟 **Nhớ Một Người** - Rating 4.9
71
- - View đẹp với không gian lãng mạn, view ...
72
 
73
- **ReAct Response:** Chào bạn! 😊 Mình tìm được những quán cafe có view đẹp ở Đà Nẵng cho bạn đây:
74
 
75
- 🌟 **NAM house Cafe** - Rating 4.7
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 | ✅ | 12070ms | find_nearby_places | 3 |
88
- | ReAct | ✅ | 23010ms | get_location_coordinates, find_nearby_places | 3 |
89
 
90
- **Single Response:** Chào bạn! 😊 Dựa trên tìm kiếm của bạn về các nhà hàng gần Cầu Rồng, mình gợi ý cho bạn những địa điểm này:
91
 
92
- 🍕 **Pizza 4P's Hoang Van Thu** - Rating 4....
93
 
94
- **ReAct Response:** Chào bạn! ��� Dưới đây những nhà hàng ngon gần Cầu Rồng mình tìm được cho bạn:
95
 
96
- 🌟 **Pizza 4P's Hoang Van Thu** - Rating 4.8
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 | ✅ | 15918ms | search_social_media | 0 |
109
- | ReAct | ✅ | 15744ms | None | 0 |
110
 
111
- **Single Response:** Chào bạn! 😊 Mình tìm được một số quán ăn đang hot trên TikTok Đà Nẵng cho bạn đây:
112
 
113
- 🌟 ** Mèn Quán Đà Nẵng** - Trend TikTok
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 | ✅ | 9881ms | search_social_media, find_nearby_places | 2 |
128
- | ReAct | ✅ | 49088ms | get_location_coordinates, find_nearby_places, retrieve_context_text, search_social_media | 0 |
129
 
130
- **Single Response:** Chào bạn! 😊 Mình tìm được những quán cafe không gian đẹp gần biển Mỹ Khê review tốt cho bạn đây:
131
 
132
- 🌟 **Sound Cafe** - Rating 4.7
133
- - Quán cafe acousti...
134
 
135
- **ReAct Response:** Xin lỗi, đã xảy ra lỗi: Unknown error...
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 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 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 phê 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 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 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 phê có không gian đẹp đượ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 = 5 # Delay between queries in single mode
30
- REACT_MODE_DELAY = 20 # Delay between queries in ReAct mode
31
- MODE_SWITCH_DELAY = 5 # Delay between switching modes
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