Cuong2004 commited on
Commit
ca7a2c2
·
0 Parent(s):

Initial HF deployment

Browse files
Files changed (48) hide show
  1. .dockerignore +36 -0
  2. .env.example +24 -0
  3. .gitignore +8 -0
  4. DEPLOYMENT.md +45 -0
  5. Dockerfile +64 -0
  6. README.md +56 -0
  7. app/__init__.py +1 -0
  8. app/agent/__init__.py +1 -0
  9. app/agent/mmca_agent.py +478 -0
  10. app/agent/react_agent.py +335 -0
  11. app/agent/reasoning.py +162 -0
  12. app/agent/state.py +96 -0
  13. app/api/__init__.py +1 -0
  14. app/api/router.py +469 -0
  15. app/core/__init__.py +1 -0
  16. app/core/config.py +45 -0
  17. app/main.py +101 -0
  18. app/mcp/__init__.py +1 -0
  19. app/mcp/tools/__init__.py +118 -0
  20. app/mcp/tools/graph_tool.py +433 -0
  21. app/mcp/tools/text_tool.py +173 -0
  22. app/mcp/tools/visual_tool.py +169 -0
  23. app/planner/__init__.py +1 -0
  24. app/planner/models.py +100 -0
  25. app/planner/router.py +253 -0
  26. app/planner/service.py +297 -0
  27. app/planner/tsp.py +200 -0
  28. app/shared/__init__.py +1 -0
  29. app/shared/chat_history.py +161 -0
  30. app/shared/db/__init__.py +1 -0
  31. app/shared/db/session.py +30 -0
  32. app/shared/integrations/__init__.py +1 -0
  33. app/shared/integrations/embedding_client.py +120 -0
  34. app/shared/integrations/gemini_client.py +127 -0
  35. app/shared/integrations/megallm_client.py +133 -0
  36. app/shared/integrations/neo4j_client.py +52 -0
  37. app/shared/integrations/siglip_client.py +146 -0
  38. app/shared/integrations/supabase_client.py +11 -0
  39. app/shared/logger.py +208 -0
  40. app/shared/models/__init__.py +1 -0
  41. app/shared/models/base.py +26 -0
  42. docs/TRIP_PLANNER_PLAN.md +426 -0
  43. docs/neo4j_test_report.md +88 -0
  44. docs/phase1.md +86 -0
  45. docs/schema_supabase.txt +110 -0
  46. pyproject.toml +45 -0
  47. tests/react_comparison_report.md +85 -0
  48. tests/test_react_comparison.py +346 -0
.dockerignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore virtual environment
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Ignore git
7
+ .git/
8
+ .gitignore
9
+
10
+ # Ignore environment files (secrets in HF Settings)
11
+ .env
12
+ .env.*
13
+
14
+ # Ignore Python cache
15
+ *.pyc
16
+ *.pyo
17
+ __pycache__/
18
+ *.egg-info/
19
+ .eggs/
20
+
21
+ # Ignore tests and docs
22
+ tests/
23
+ docs/
24
+ *.md
25
+ !README.md
26
+
27
+ # Ignore IDE
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ *.swo
32
+
33
+ # Ignore local files
34
+ *.log
35
+ *.sqlite
36
+ .DS_Store
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI
2
+ APP_ENV=local
3
+ APP_DEBUG=true
4
+
5
+ # Supabase
6
+ SUPABASE_URL=https://xxxxx.supabase.co
7
+ SUPABASE_ANON_KEY=your_anon_key
8
+ SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
9
+ DATABASE_URL=postgresql+asyncpg://postgres:[email protected]:5432/postgres
10
+
11
+ # Neo4j
12
+ NEO4J_URI=neo4j+s://xxxxx.databases.neo4j.io
13
+ NEO4J_USERNAME=neo4j
14
+ NEO4J_PASSWORD=CHANGE_ME
15
+
16
+ # Google AI (Gemini)
17
+ GOOGLE_API_KEY=your_google_api_key
18
+
19
+ # MegaLLM (OpenAI-compatible - DeepSeek)
20
+ MEGALLM_API_KEY=your_megallm_api_key
21
+ MEGALLM_BASE_URL=https://ai.megallm.io/v1
22
+
23
+ # CLIP (optional - for image embeddings)
24
+ HUGGINGFACE_API_KEY=your_hf_api_key
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ dist/
8
+ build/
DEPLOYMENT.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LocalMate HF Deployment Guide
2
+
3
+ ## ✅ Files Created
4
+ - `Dockerfile` - Multi-stage build with SigLIP pre-cached
5
+ - `.dockerignore` - Excludes .venv, tests, docs
6
+ - `README.md` - Updated with HF Spaces header
7
+
8
+ ## 🚀 Deployment Checklist
9
+
10
+ ### Step 1: Create HF Space
11
+ 1. Go to [huggingface.co/new-space](https://huggingface.co/new-space)
12
+ 2. **Name:** `LocalMate-API`
13
+ 3. **SDK:** Select **Docker**
14
+ 4. **Visibility:** Public or Private
15
+
16
+ ### Step 2: Configure Secrets
17
+ Go to **Space Settings > Repository secrets** and add:
18
+
19
+ | Secret | Description |
20
+ |--------|-------------|
21
+ | `DATABASE_URL` | `postgresql+asyncpg://user:pass@host:5432/db` |
22
+ | `NEO4J_URI` | `neo4j+s://xxx.databases.neo4j.io` |
23
+ | `NEO4J_USER` | `neo4j` |
24
+ | `NEO4J_PASSWORD` | Neo4j password |
25
+ | `MEGALLM_API_KEY` | MegaLLM API key |
26
+ | `GOOGLE_API_KEY` | Gemini API key |
27
+
28
+ ### Step 3: Push Code
29
+ ```bash
30
+ # Add HF Space as remote
31
+ git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/LocalMate-API
32
+
33
+ # Push to HF
34
+ git push hf main
35
+ ```
36
+
37
+ ### Step 4: Verify
38
+ - Wait for build (5-10 min for first build with torch)
39
+ - Check: `https://YOUR_USERNAME-localmate-api.hf.space/docs`
40
+ - Test `/api/v1/chat` endpoint
41
+
42
+ ## ⚠️ Notes
43
+ - First build takes ~10 min (downloading torch + SigLIP)
44
+ - Cold start ~30s after space sleeps
45
+ - SigLIP model is pre-cached in Docker image
Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # LocalMate Backend - Hugging Face Spaces Docker Deployment
3
+ # =============================================================================
4
+ # Multi-stage build with SigLIP model pre-cached for optimal cold start
5
+
6
+ # Stage 1: Build dependencies
7
+ FROM python:3.11-slim as builder
8
+
9
+ WORKDIR /app
10
+
11
+ # Install build dependencies
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ build-essential \
14
+ git \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy dependency file
18
+ COPY pyproject.toml .
19
+
20
+ # Install Python dependencies
21
+ RUN pip install --no-cache-dir --upgrade pip && \
22
+ pip install --no-cache-dir .
23
+
24
+ # =============================================================================
25
+ # Stage 2: Production image
26
+ # =============================================================================
27
+ FROM python:3.11-slim
28
+
29
+ WORKDIR /app
30
+
31
+ # Install runtime dependencies
32
+ RUN apt-get update && apt-get install -y --no-install-recommends \
33
+ libgomp1 \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ # Copy installed packages from builder
37
+ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
38
+ COPY --from=builder /usr/local/bin /usr/local/bin
39
+
40
+ # Copy application code
41
+ COPY app ./app
42
+
43
+ # Pre-download SigLIP model during build (cache in image)
44
+ # This avoids downloading on every cold start
45
+ RUN python -c "from open_clip import create_model_and_transforms; \
46
+ model, _, preprocess = create_model_and_transforms('ViT-B-16-SigLIP', pretrained='webli'); \
47
+ print('✅ SigLIP model cached')"
48
+
49
+ # Create cache directory for HF models
50
+ RUN mkdir -p /root/.cache/huggingface
51
+
52
+ # Environment variables
53
+ ENV PYTHONUNBUFFERED=1
54
+ ENV PYTHONDONTWRITEBYTECODE=1
55
+
56
+ # HF Spaces expects port 7860
57
+ EXPOSE 7860
58
+
59
+ # Health check
60
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
61
+ CMD python -c "import httpx; httpx.get('http://localhost:7860/health')" || exit 1
62
+
63
+ # Run with uvicorn
64
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LocalMate API
3
+ emoji: 🌴
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # LocalMate Da Nang V2
12
+
13
+ Multi-Modal Contextual Agent (MMCA) API for Da Nang Tourism.
14
+
15
+ ## Architecture
16
+
17
+ Based on **Model Context Protocol (MCP)** with 3 tools:
18
+ - **retrieve_context_text** - RAG Text search (pgvector)
19
+ - **retrieve_similar_visuals** - RAG Image search (CLIP)
20
+ - **find_nearby_places** - Graph Spatial (Neo4j)
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Install dependencies
26
+ pip install -e ".[dev]"
27
+
28
+ # Copy env file
29
+ cp .env.example .env
30
+ # Edit .env with your credentials
31
+
32
+ # Run dev server
33
+ pkill -f "uvicorn app.main:app"
34
+ uvicorn app.main:app --reload --port 8000
35
+
36
+ # Open Swagger UI
37
+ open http://localhost:8000/docs
38
+ ```
39
+
40
+ ## Testing
41
+
42
+ Use the `/api/v1/chat` endpoint in Swagger to test:
43
+
44
+ ```json
45
+ {
46
+ "message": "Tìm quán cafe gần bãi biển Mỹ Khê"
47
+ }
48
+ ```
49
+
50
+ ## Tech Stack
51
+
52
+ - **Framework**: FastAPI
53
+ - **Database**: Supabase (PostgreSQL + pgvector)
54
+ - **Graph DB**: Neo4j Aura
55
+ - **LLM**: Google Gemini 2.5 Flash or deepseek-ai/deepseek-v3.1-terminus
56
+ - **Embeddings**: text-embedding-004 + CLIP
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """LocalMate v2 - Multi-Modal Contextual Agent."""
app/agent/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agent module - Multi-Modal Contextual Agent."""
app/agent/mmca_agent.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-Modal Contextual Agent (MMCA) - ReAct Agent with MCP Tools.
2
+
3
+ Implements the Agent-Centric Orchestration pattern:
4
+ 1. Parse user intent
5
+ 2. Select appropriate MCP tool(s)
6
+ 3. Execute tool(s) with logging
7
+ 4. Synthesize final response with workflow trace
8
+
9
+ Supports multiple LLM providers: Google (Gemini) and MegaLLM (DeepSeek).
10
+ """
11
+
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from app.mcp.tools import mcp_tools
20
+ from app.shared.integrations.gemini_client import GeminiClient
21
+ from app.shared.integrations.megallm_client import MegaLLMClient
22
+ from app.shared.logger import agent_logger, AgentWorkflow, WorkflowStep
23
+
24
+
25
+ # Default coordinates for Da Nang (if no location specified)
26
+ DANANG_CENTER = (16.0544, 108.2022)
27
+
28
+ # System prompt for the agent - balanced for all 3 tools
29
+ SYSTEM_PROMPT = """Bạn là trợ lý du lịch thông minh cho Đà Nẵng. Bạn có 3 công cụ tìm kiếm:
30
+
31
+ **1. retrieve_context_text** - Tìm kiếm văn bản thông minh
32
+ - Khi nào: Hỏi về menu, review, mô tả, đặc điểm, phong cách
33
+ - Ví dụ: "Phở ngon giá rẻ", "Quán cafe view đẹp", "Nơi lãng mạn hẹn hò"
34
+ - Đặc biệt: Tự động phát hiện category (cafe, pho, seafood...) và boost kết quả
35
+
36
+ **2. retrieve_similar_visuals** - Tìm theo hình ảnh
37
+ - Khi nào: Người dùng gửi ảnh hoặc mô tả về không gian, decor
38
+ - Scene filter: food, interior, exterior, view
39
+ - Ví dụ: "Quán có không gian giống ảnh này"
40
+
41
+ **3. find_nearby_places** - Tìm theo vị trí
42
+ - Khi nào: Hỏi về khoảng cách, "gần đây", "gần X", "quanh Y"
43
+ - Ví dụ: "Quán cafe gần Cầu Rồng", "Nhà hàng gần bãi biển Mỹ Khê"
44
+ - Đặc biệt: Có thể lấy chi tiết đầy đủ với photos, reviews
45
+
46
+ **Quy tắc quan trọng:**
47
+ 1. Phân tích intent để chọn ĐÚNG tool (không chỉ dùng 1 tool)
48
+ 2. Với câu hỏi tổng quát ("quán cafe ngon") → dùng retrieve_context_text
49
+ 3. Với câu hỏi vị trí ("gần X", "quanh Y") → dùng find_nearby_places
50
+ 4. Với ảnh → dùng retrieve_similar_visuals
51
+ 5. Có thể kết hợp nhiều tools để có kết quả tốt nhất
52
+ 6. Trả lời tiếng Việt, thân thiện, cung cấp thông tin cụ thể (tên, rating, khoảng cách)
53
+ """
54
+
55
+
56
+ @dataclass
57
+ class ChatMessage:
58
+ """Chat message model."""
59
+ role: str # "user" or "assistant"
60
+ content: str
61
+
62
+
63
+ @dataclass
64
+ class ToolCall:
65
+ """Tool call with arguments and results."""
66
+ tool_name: str
67
+ arguments: dict
68
+ result: list | None = None
69
+ duration_ms: float = 0
70
+
71
+
72
+ @dataclass
73
+ class ChatResult:
74
+ """Complete chat result with response and workflow."""
75
+ response: str
76
+ workflow: AgentWorkflow
77
+ tools_used: list[str] = field(default_factory=list)
78
+ total_duration_ms: float = 0
79
+
80
+
81
+ class MMCAAgent:
82
+ """
83
+ Multi-Modal Contextual Agent with Logging and Workflow Tracing.
84
+
85
+ Implements ReAct (Reasoning + Acting) pattern:
86
+ 1. Observe: Parse user message and intent
87
+ 2. Think: Decide which tool(s) to use
88
+ 3. Act: Execute MCP tools
89
+ 4. Synthesize: Generate final response
90
+
91
+ Supports multiple LLM providers:
92
+ - Google: Gemini models
93
+ - MegaLLM: DeepSeek models (OpenAI-compatible)
94
+ """
95
+
96
+ def __init__(self, provider: str = "MegaLLM", model: str | None = None):
97
+ """
98
+ Initialize agent with LLM provider and model.
99
+
100
+ Args:
101
+ provider: "Google" or "MegaLLM"
102
+ model: Model name (uses default if None)
103
+ """
104
+ self.provider = provider
105
+ self.model = model
106
+ self.tools = mcp_tools
107
+ self.conversation_history: list[ChatMessage] = []
108
+
109
+ # Initialize LLM client based on provider
110
+ if provider == "Google":
111
+ self.llm_client = GeminiClient(model=model)
112
+ else:
113
+ self.llm_client = MegaLLMClient(model=model)
114
+
115
+ agent_logger.workflow_step("Agent initialized", f"Provider: {provider}, Model: {model}")
116
+
117
+ async def chat(
118
+ self,
119
+ message: str,
120
+ db: AsyncSession,
121
+ image_url: str | None = None,
122
+ history: str | None = None,
123
+ ) -> ChatResult:
124
+ """
125
+ Process a chat message and return response with workflow trace.
126
+
127
+ Args:
128
+ message: User's natural language message
129
+ db: Database session for pgvector queries
130
+ image_url: Optional image URL for visual search
131
+ history: Optional conversation history string
132
+
133
+ Returns:
134
+ ChatResult with response, workflow, and metadata
135
+ """
136
+ start_time = time.time()
137
+
138
+ # Initialize workflow tracking
139
+ workflow = AgentWorkflow(query=message)
140
+
141
+ # Log incoming request
142
+ agent_logger.api_request(
143
+ endpoint="/chat",
144
+ method="POST",
145
+ body={"message": message[:100], "has_image": bool(image_url), "has_history": bool(history)}
146
+ )
147
+
148
+ # Add user message to internal history
149
+ self.conversation_history.append(ChatMessage(role="user", content=message))
150
+
151
+ # Step 1: Analyze intent and decide tool usage
152
+ workflow.add_step(WorkflowStep(
153
+ step_name="Intent Analysis",
154
+ purpose="Phân tích câu hỏi để chọn tool phù hợp"
155
+ ))
156
+
157
+ agent_logger.workflow_step("Step 1: Intent Analysis", message[:80])
158
+ intent = self._detect_intent(message, image_url)
159
+ workflow.intent_detected = intent
160
+ agent_logger.workflow_step("Intent detected", intent)
161
+
162
+ tool_calls = await self._plan_tool_calls(message, image_url)
163
+
164
+ workflow.add_step(WorkflowStep(
165
+ step_name="Tool Planning",
166
+ purpose=f"Chọn {len(tool_calls)} tool(s) để thực thi",
167
+ output_summary=", ".join([tc.tool_name for tc in tool_calls])
168
+ ))
169
+
170
+ # Step 2: Execute tools
171
+ agent_logger.workflow_step("Step 2: Execute Tools", f"{len(tool_calls)} tool(s)")
172
+ tool_results = []
173
+
174
+ for tool_call in tool_calls:
175
+ tool_start = time.time()
176
+
177
+ agent_logger.tool_call(tool_call.tool_name, tool_call.arguments)
178
+
179
+ result = await self._execute_tool(tool_call, db)
180
+ result.duration_ms = (time.time() - tool_start) * 1000
181
+
182
+ result_count = len(result.result) if result.result else 0
183
+ agent_logger.tool_result(
184
+ tool_call.tool_name,
185
+ result_count,
186
+ result.result[0] if result.result else None
187
+ )
188
+
189
+ # Add to workflow
190
+ workflow.add_step(WorkflowStep(
191
+ step_name=f"Execute {tool_call.tool_name}",
192
+ tool_name=tool_call.tool_name,
193
+ purpose=self._get_tool_purpose(tool_call.tool_name),
194
+ input_summary=json.dumps(tool_call.arguments, ensure_ascii=False)[:100],
195
+ result_count=result_count,
196
+ duration_ms=result.duration_ms
197
+ ))
198
+
199
+ tool_results.append(result)
200
+
201
+ # Step 3: Synthesize response with history context
202
+ agent_logger.workflow_step("Step 3: Synthesize Response")
203
+
204
+ llm_start = time.time()
205
+ response = await self._synthesize_response(message, tool_results, image_url, history)
206
+ llm_duration = (time.time() - llm_start) * 1000
207
+
208
+ agent_logger.llm_response(self.provider, response[:100], tokens=None)
209
+
210
+ workflow.add_step(WorkflowStep(
211
+ step_name="LLM Synthesis",
212
+ purpose="Tổng hợp kết quả và tạo phản hồi",
213
+ duration_ms=llm_duration
214
+ ))
215
+
216
+ # Add assistant response to internal history
217
+ self.conversation_history.append(ChatMessage(role="assistant", content=response))
218
+
219
+ # Calculate total duration
220
+ total_duration = (time.time() - start_time) * 1000
221
+ workflow.total_duration_ms = total_duration
222
+
223
+ # Log complete
224
+ agent_logger.api_response("/chat", 200, {"response_len": len(response)}, total_duration)
225
+
226
+ return ChatResult(
227
+ response=response,
228
+ workflow=workflow,
229
+ tools_used=workflow.tools_used,
230
+ total_duration_ms=total_duration
231
+ )
232
+
233
+ def _detect_intent(self, message: str, image_url: str | None) -> str:
234
+ """Detect user intent for logging."""
235
+ intents = []
236
+
237
+ if image_url:
238
+ intents.append("visual_search")
239
+
240
+ location_keywords = ["gần", "cách", "nearby", "gần đây", "quanh", "xung quanh"]
241
+ if any(kw in message.lower() for kw in location_keywords):
242
+ intents.append("location_search")
243
+
244
+ if not intents:
245
+ intents.append("text_search")
246
+
247
+ return " + ".join(intents)
248
+
249
+ def _get_tool_purpose(self, tool_name: str) -> str:
250
+ """Get human-readable purpose for tool."""
251
+ purposes = {
252
+ "retrieve_context_text": "Tìm kiếm semantic trong văn bản (review, mô tả)",
253
+ "retrieve_similar_visuals": "Tìm địa điểm có hình ảnh tương tự",
254
+ "find_nearby_places": "Tìm địa điểm gần vị trí được nhắc đến",
255
+ }
256
+ return purposes.get(tool_name, tool_name)
257
+
258
+ async def _plan_tool_calls(
259
+ self,
260
+ message: str,
261
+ image_url: str | None = None,
262
+ ) -> list[ToolCall]:
263
+ """
264
+ Analyze message and plan which tools to call.
265
+
266
+ Returns list of ToolCall objects with tool_name and arguments.
267
+ """
268
+ tool_calls = []
269
+
270
+ # If image is provided, always use visual search
271
+ if image_url:
272
+ tool_calls.append(ToolCall(
273
+ tool_name="retrieve_similar_visuals",
274
+ arguments={"image_url": image_url, "limit": 5},
275
+ ))
276
+
277
+ # Analyze message for location/proximity queries
278
+ location_keywords = ["gần", "cách", "nearby", "gần đây", "quanh", "xung quanh"]
279
+ if any(kw in message.lower() for kw in location_keywords):
280
+ # Extract location name from message
281
+ location = self._extract_location(message)
282
+ category = self._extract_category(message)
283
+
284
+ # Get coordinates for the location
285
+ coords = await self.tools.get_location_coordinates(location) if location else None
286
+ lat, lng = coords if coords else DANANG_CENTER
287
+
288
+ tool_calls.append(ToolCall(
289
+ tool_name="find_nearby_places",
290
+ arguments={
291
+ "lat": lat,
292
+ "lng": lng,
293
+ "category": category,
294
+ "max_distance_km": 3.0,
295
+ "limit": 5,
296
+ },
297
+ ))
298
+
299
+ # For general queries without location keywords, use text search
300
+ if not tool_calls:
301
+ tool_calls.append(ToolCall(
302
+ tool_name="retrieve_context_text",
303
+ arguments={"query": message, "limit": 5},
304
+ ))
305
+
306
+ return tool_calls
307
+
308
+ async def _execute_tool(
309
+ self,
310
+ tool_call: ToolCall,
311
+ db: AsyncSession,
312
+ ) -> ToolCall:
313
+ """Execute a single tool and return results."""
314
+ try:
315
+ if tool_call.tool_name == "retrieve_context_text":
316
+ results = await self.tools.retrieve_context_text(
317
+ db=db,
318
+ query=tool_call.arguments.get("query", ""),
319
+ limit=tool_call.arguments.get("limit", 10),
320
+ )
321
+ tool_call.result = [
322
+ {
323
+ "place_id": r.place_id,
324
+ "name": r.name,
325
+ "category": r.category,
326
+ "rating": r.rating,
327
+ "similarity": r.similarity,
328
+ "description": r.description,
329
+ "source_text": r.source_text,
330
+ }
331
+ for r in results
332
+ ]
333
+
334
+ elif tool_call.tool_name == "retrieve_similar_visuals":
335
+ results = await self.tools.retrieve_similar_visuals(
336
+ db=db,
337
+ image_url=tool_call.arguments.get("image_url"),
338
+ limit=tool_call.arguments.get("limit", 10),
339
+ )
340
+ tool_call.result = [
341
+ {
342
+ "place_id": r.place_id,
343
+ "name": r.name,
344
+ "category": r.category,
345
+ "rating": r.rating,
346
+ "similarity": r.similarity,
347
+ "matched_images": r.matched_images,
348
+ "image_url": r.image_url,
349
+ }
350
+ for r in results
351
+ ]
352
+
353
+ elif tool_call.tool_name == "find_nearby_places":
354
+ results = await self.tools.find_nearby_places(
355
+ lat=tool_call.arguments.get("lat", DANANG_CENTER[0]),
356
+ lng=tool_call.arguments.get("lng", DANANG_CENTER[1]),
357
+ max_distance_km=tool_call.arguments.get("max_distance_km", 5.0),
358
+ category=tool_call.arguments.get("category"),
359
+ limit=tool_call.arguments.get("limit", 10),
360
+ )
361
+ tool_call.result = [
362
+ {
363
+ "place_id": r.place_id,
364
+ "name": r.name,
365
+ "category": r.category,
366
+ "distance_km": r.distance_km,
367
+ "rating": r.rating,
368
+ "description": r.description,
369
+ }
370
+ for r in results
371
+ ]
372
+
373
+ except Exception as e:
374
+ agent_logger.error(f"Tool execution failed: {tool_call.tool_name}", e)
375
+ tool_call.result = [{"error": str(e)}]
376
+
377
+ return tool_call
378
+
379
+ async def _synthesize_response(
380
+ self,
381
+ message: str,
382
+ tool_results: list[ToolCall],
383
+ image_url: str | None = None,
384
+ history: str | None = None,
385
+ ) -> str:
386
+ """Synthesize final response from tool results with conversation history."""
387
+ # Build context from tool results
388
+ context_parts = []
389
+ for tool_call in tool_results:
390
+ if tool_call.result:
391
+ context_parts.append(
392
+ f"Kết quả từ {tool_call.tool_name}:\n{json.dumps(tool_call.result, ensure_ascii=False, indent=2)}"
393
+ )
394
+
395
+ context = "\n\n".join(context_parts) if context_parts else "Không tìm thấy kết quả phù hợp."
396
+
397
+ # Build history section if available
398
+ history_section = ""
399
+ if history:
400
+ history_section = f"""Lịch sử hội thoại trước đó:
401
+ {history}
402
+
403
+ ---
404
+ """
405
+
406
+ # Generate response using LLM
407
+ prompt = f"""{history_section}Dựa trên kết quả tìm kiếm sau, hãy trả lời câu hỏi của người dùng một cách tự nhiên và hữu ích.
408
+
409
+ Câu hỏi hiện tại: {message}
410
+
411
+ {context}
412
+
413
+ Hãy trả lời bằng tiếng Việt, thân thiện. Nếu có nhiều kết quả, hãy giới thiệu top 2-3 địa điểm phù hợp nhất.
414
+ Nếu có lịch sử hội thoại, hãy cân nhắc ngữ cảnh trước đó khi trả lời."""
415
+
416
+ agent_logger.llm_call(self.provider, self.model or "default", prompt[:100])
417
+
418
+ response = await self.llm_client.generate(
419
+ prompt=prompt,
420
+ temperature=0.7,
421
+ system_instruction=SYSTEM_PROMPT,
422
+ )
423
+
424
+ return response
425
+
426
+ def _extract_location(self, message: str) -> str | None:
427
+ """Extract location name from message using pattern matching."""
428
+ known_locations = {
429
+ "mỹ khê": "My Khe Beach",
430
+ "my khe": "My Khe Beach",
431
+ "bãi biển mỹ khê": "My Khe Beach",
432
+ "cầu rồng": "Dragon Bridge",
433
+ "cau rong": "Dragon Bridge",
434
+ "dragon bridge": "Dragon Bridge",
435
+ "bà nà": "Ba Na Hills",
436
+ "ba na": "Ba Na Hills",
437
+ "bà nà hills": "Ba Na Hills",
438
+ "sơn trà": "Son Tra Peninsula",
439
+ "son tra": "Son Tra Peninsula",
440
+ "hội an": "Hoi An",
441
+ "hoi an": "Hoi An",
442
+ "ngũ hành sơn": "Marble Mountains",
443
+ "ngu hanh son": "Marble Mountains",
444
+ "marble mountains": "Marble Mountains",
445
+ }
446
+
447
+ message_lower = message.lower()
448
+ for pattern, location in known_locations.items():
449
+ if pattern in message_lower:
450
+ return location
451
+
452
+ return None
453
+
454
+ def _extract_category(self, message: str) -> str | None:
455
+ """Extract place category from message."""
456
+ categories = {
457
+ "cafe": ["cafe", "cà phê", "coffee"],
458
+ "restaurant": ["nhà hàng", "quán ăn", "restaurant", "ăn"],
459
+ "beach": ["bãi biển", "beach", "biển"],
460
+ "attraction": ["điểm tham quan", "du lịch", "attraction"],
461
+ "hotel": ["khách sạn", "hotel", "lưu trú"],
462
+ "bar": ["bar", "pub", "quán bar"],
463
+ }
464
+
465
+ message_lower = message.lower()
466
+ for category, keywords in categories.items():
467
+ if any(kw in message_lower for kw in keywords):
468
+ return category
469
+
470
+ return None
471
+
472
+ def clear_history(self) -> None:
473
+ """Clear conversation history."""
474
+ self.conversation_history = []
475
+
476
+
477
+ # Default agent instance (using MegaLLM)
478
+ mmca_agent = MMCAAgent()
app/agent/react_agent.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReAct Agent - Multi-step reasoning and tool execution.
2
+
3
+ Implements the ReAct (Reasoning + Acting) pattern:
4
+ 1. Reason about what to do next
5
+ 2. Execute a tool
6
+ 3. Observe the result
7
+ 4. Repeat until done or max steps reached
8
+ """
9
+
10
+ import time
11
+ import json
12
+ from typing import Any
13
+
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from app.agent.state import AgentState, ReActStep
17
+ from app.agent.reasoning import (
18
+ REACT_SYSTEM_PROMPT,
19
+ parse_reasoning_response,
20
+ build_reasoning_prompt,
21
+ get_tool_purpose,
22
+ )
23
+ from app.mcp.tools import mcp_tools
24
+ from app.shared.integrations.gemini_client import GeminiClient
25
+ from app.shared.integrations.megallm_client import MegaLLMClient
26
+ from app.shared.logger import agent_logger, AgentWorkflow, WorkflowStep
27
+
28
+
29
+ # Default coordinates for Da Nang
30
+ DANANG_CENTER = (16.0544, 108.2022)
31
+
32
+
33
+ class ReActAgent:
34
+ """
35
+ ReAct Agent with multi-step tool chaining.
36
+
37
+ Allows LLM to reason about each step and decide which tool to call next,
38
+ using previous results to inform subsequent actions.
39
+ """
40
+
41
+ def __init__(self, provider: str = "MegaLLM", model: str | None = None, max_steps: int = 5):
42
+ """
43
+ Initialize ReAct agent.
44
+
45
+ Args:
46
+ provider: "Google" or "MegaLLM"
47
+ model: Model name
48
+ max_steps: Maximum reasoning steps (default 5)
49
+ """
50
+ self.provider = provider
51
+ self.model = model
52
+ self.max_steps = max_steps
53
+ self.tools = mcp_tools
54
+
55
+ # Initialize LLM client
56
+ if provider == "Google":
57
+ self.llm_client = GeminiClient(model=model)
58
+ else:
59
+ self.llm_client = MegaLLMClient(model=model)
60
+
61
+ agent_logger.workflow_step(
62
+ "ReAct Agent initialized",
63
+ f"Provider: {provider}, Model: {model}, MaxSteps: {max_steps}"
64
+ )
65
+
66
+ async def run(
67
+ self,
68
+ query: str,
69
+ db: AsyncSession,
70
+ image_url: str | None = None,
71
+ history: str | None = None,
72
+ ) -> tuple[str, AgentState]:
73
+ """
74
+ Run the ReAct loop.
75
+
76
+ Args:
77
+ query: User's query
78
+ db: Database session
79
+ image_url: Optional image for visual search
80
+ history: Conversation history
81
+
82
+ Returns:
83
+ Tuple of (final_response, agent_state)
84
+ """
85
+ start_time = time.time()
86
+
87
+ # Initialize state
88
+ state = AgentState(query=query, max_steps=self.max_steps)
89
+
90
+ agent_logger.api_request(
91
+ endpoint="/chat (ReAct)",
92
+ method="POST",
93
+ body={"query": query[:100], "max_steps": self.max_steps}
94
+ )
95
+
96
+ # ReAct loop
97
+ while state.can_continue():
98
+ step_start = time.time()
99
+ step_number = state.current_step + 1
100
+
101
+ agent_logger.workflow_step(f"ReAct Step {step_number}", "Reasoning...")
102
+
103
+ try:
104
+ # Step 1: Reason about what to do next
105
+ reasoning = await self._reason(state, image_url)
106
+
107
+ agent_logger.workflow_step(
108
+ f"Step {step_number} Thought",
109
+ reasoning.thought[:100]
110
+ )
111
+ agent_logger.workflow_step(
112
+ f"Step {step_number} Action",
113
+ f"{reasoning.action} → {json.dumps(reasoning.action_input, ensure_ascii=False)[:80]}"
114
+ )
115
+
116
+ # Step 2: Check if done
117
+ if reasoning.action == "finish":
118
+ state.is_complete = True
119
+ state.steps.append(ReActStep(
120
+ step_number=step_number,
121
+ thought=reasoning.thought,
122
+ action="finish",
123
+ action_input={},
124
+ duration_ms=(time.time() - step_start) * 1000,
125
+ ))
126
+ break
127
+
128
+ # Step 3: Execute tool
129
+ observation = await self._execute_tool(
130
+ reasoning.action,
131
+ reasoning.action_input,
132
+ db,
133
+ image_url,
134
+ )
135
+
136
+ result_count = len(observation) if isinstance(observation, list) else 1
137
+ agent_logger.tool_result(reasoning.action, result_count)
138
+
139
+ # Step 4: Add step to state
140
+ step = ReActStep(
141
+ step_number=step_number,
142
+ thought=reasoning.thought,
143
+ action=reasoning.action,
144
+ action_input=reasoning.action_input,
145
+ observation=observation,
146
+ duration_ms=(time.time() - step_start) * 1000,
147
+ )
148
+ state.add_step(step)
149
+
150
+ except Exception as e:
151
+ agent_logger.error(f"ReAct step {step_number} failed", e)
152
+ state.error = str(e)
153
+ break
154
+
155
+ # Final synthesis
156
+ state.total_duration_ms = (time.time() - start_time) * 1000
157
+
158
+ if state.error:
159
+ final_response = f"Xin lỗi, đã xảy ra lỗi: {state.error}"
160
+ else:
161
+ final_response = await self._synthesize(state, history)
162
+
163
+ state.final_answer = final_response
164
+
165
+ agent_logger.api_response(
166
+ "/chat (ReAct)",
167
+ 200,
168
+ {"steps": len(state.steps), "tools": list(state.context.keys())},
169
+ state.total_duration_ms,
170
+ )
171
+
172
+ return final_response, state
173
+
174
+ async def _reason(self, state: AgentState, image_url: str | None = None) -> Any:
175
+ """Get LLM reasoning for next step."""
176
+ prompt = build_reasoning_prompt(
177
+ query=state.query,
178
+ context_summary=state.get_context_summary(),
179
+ previous_steps=[s.to_dict() for s in state.steps],
180
+ image_url=image_url,
181
+ )
182
+
183
+ agent_logger.llm_call(self.provider, self.model or "default", prompt[:100])
184
+
185
+ response = await self.llm_client.generate(
186
+ prompt=prompt,
187
+ temperature=0.3, # Lower temp for more deterministic reasoning
188
+ system_instruction=REACT_SYSTEM_PROMPT,
189
+ )
190
+
191
+ return parse_reasoning_response(response)
192
+
193
+ async def _execute_tool(
194
+ self,
195
+ action: str,
196
+ action_input: dict,
197
+ db: AsyncSession,
198
+ image_url: str | None = None,
199
+ ) -> Any:
200
+ """Execute a tool and return observation."""
201
+ agent_logger.tool_call(action, action_input)
202
+
203
+ if action == "get_location_coordinates":
204
+ location_name = action_input.get("location_name", "")
205
+ coords = await self.tools.get_location_coordinates(location_name)
206
+ if coords:
207
+ return {"lat": coords[0], "lng": coords[1], "location": location_name}
208
+ return {"error": f"Location not found: {location_name}"}
209
+
210
+ elif action == "find_nearby_places":
211
+ lat = action_input.get("lat", DANANG_CENTER[0])
212
+ lng = action_input.get("lng", DANANG_CENTER[1])
213
+
214
+ # If lat/lng are from previous step context
215
+ if isinstance(lat, str) or isinstance(lng, str):
216
+ lat, lng = DANANG_CENTER
217
+
218
+ results = await self.tools.find_nearby_places(
219
+ lat=lat,
220
+ lng=lng,
221
+ max_distance_km=action_input.get("max_distance_km", 3.0),
222
+ category=action_input.get("category"),
223
+ limit=action_input.get("limit", 5),
224
+ )
225
+ return [
226
+ {
227
+ "place_id": r.place_id,
228
+ "name": r.name,
229
+ "category": r.category,
230
+ "distance_km": r.distance_km,
231
+ "rating": r.rating,
232
+ }
233
+ for r in results
234
+ ]
235
+
236
+ elif action == "retrieve_context_text":
237
+ results = await self.tools.retrieve_context_text(
238
+ db=db,
239
+ query=action_input.get("query", ""),
240
+ limit=action_input.get("limit", 5),
241
+ )
242
+ return [
243
+ {
244
+ "place_id": r.place_id,
245
+ "name": r.name,
246
+ "category": r.category,
247
+ "rating": r.rating,
248
+ "source_text": r.source_text[:100] if r.source_text else "",
249
+ }
250
+ for r in results
251
+ ]
252
+
253
+ elif action == "retrieve_similar_visuals":
254
+ url = action_input.get("image_url") or image_url
255
+ if not url:
256
+ return {"error": "No image URL provided"}
257
+
258
+ results = await self.tools.retrieve_similar_visuals(
259
+ db=db,
260
+ image_url=url,
261
+ limit=action_input.get("limit", 5),
262
+ )
263
+ return [
264
+ {
265
+ "place_id": r.place_id,
266
+ "name": r.name,
267
+ "category": r.category,
268
+ "similarity": r.similarity,
269
+ }
270
+ for r in results
271
+ ]
272
+
273
+ else:
274
+ return {"error": f"Unknown tool: {action}"}
275
+
276
+ async def _synthesize(self, state: AgentState, history: str | None = None) -> str:
277
+ """Synthesize final response from all collected information."""
278
+ # Build context from all steps
279
+ context_parts = []
280
+ for step in state.steps:
281
+ if step.observation and step.action != "finish":
282
+ context_parts.append(
283
+ f"Kết quả từ {step.action}:\n{json.dumps(step.observation, ensure_ascii=False, indent=2)}"
284
+ )
285
+
286
+ context = "\n\n".join(context_parts) if context_parts else "Không có kết quả."
287
+
288
+ # Build history section
289
+ history_section = ""
290
+ if history:
291
+ history_section = f"Lịch sử hội thoại:\n{history}\n\n---\n"
292
+
293
+ # Build steps summary
294
+ steps_summary = "\n".join([
295
+ f"- Bước {s.step_number}: {s.thought[:60]}... → {get_tool_purpose(s.action)}"
296
+ for s in state.steps
297
+ ])
298
+
299
+ prompt = f"""{history_section}Dựa trên các bước suy luận và tìm kiếm sau:
300
+
301
+ {steps_summary}
302
+
303
+ Và kết quả thu thập được:
304
+ {context}
305
+
306
+ Hãy trả lời câu hỏi của user một cách tự nhiên và hữu ích:
307
+ "{state.query}"
308
+
309
+ 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 với thông tin cụ thể."""
310
+
311
+ response = await self.llm_client.generate(
312
+ prompt=prompt,
313
+ temperature=0.7,
314
+ system_instruction="Bạn là trợ lý du lịch thông minh cho Đà Nẵng. Trả lời ngắn gọn, hữu ích.",
315
+ )
316
+
317
+ return response
318
+
319
+ def to_workflow(self, state: AgentState) -> AgentWorkflow:
320
+ """Convert AgentState to AgentWorkflow for response."""
321
+ workflow = AgentWorkflow(query=state.query)
322
+ workflow.intent_detected = "react_multi_step"
323
+ workflow.total_duration_ms = state.total_duration_ms
324
+ workflow.tools_used = list(state.context.keys())
325
+
326
+ for step in state.steps:
327
+ workflow.add_step(WorkflowStep(
328
+ step_name=f"Step {step.step_number}: {step.thought[:50]}...",
329
+ tool_name=step.action if step.action != "finish" else None,
330
+ purpose=get_tool_purpose(step.action),
331
+ result_count=len(step.observation) if isinstance(step.observation, list) else 0,
332
+ duration_ms=step.duration_ms,
333
+ ))
334
+
335
+ return workflow
app/agent/reasoning.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReAct Reasoning Module - Prompts and parsing for multi-step reasoning."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from app.shared.logger import agent_logger
9
+
10
+
11
+ # System prompt for ReAct mode
12
+ REACT_SYSTEM_PROMPT = """Bạn là agent du lịch thông minh cho Đà Nẵng với khả năng suy luận multi-step.
13
+
14
+ **Tools có sẵn:**
15
+ 1. `get_location_coordinates` - Lấy tọa độ từ tên địa điểm
16
+ - Input: {"location_name": "Dragon Bridge"}
17
+ - Output: {"lat": 16.06, "lng": 108.22}
18
+
19
+ 2. `find_nearby_places` - Tìm địa điểm gần vị trí
20
+ - Input: {"lat": 16.06, "lng": 108.22, "category": "cafe", "max_distance_km": 3}
21
+ - Output: [{name, category, distance_km, rating}]
22
+
23
+ 3. `retrieve_context_text` - Tìm semantic trong reviews/descriptions
24
+ - Input: {"query": "cafe view đẹp", "limit": 5}
25
+ - Output: [{name, category, rating, source_text}]
26
+
27
+ 4. `retrieve_similar_visuals` - Tìm địa điểm có hình ảnh tương tự
28
+ - Input: {"image_url": "..."}
29
+ - Output: [{name, similarity, image_url}]
30
+
31
+ **Quy trình:**
32
+ Với mỗi bước, bạn phải:
33
+ 1. **Thought**: Suy nghĩ về bước tiếp theo cần làm
34
+ 2. **Action**: Chọn tool hoặc "finish" nếu đủ thông tin
35
+ 3. **Action Input**: JSON parameters cho tool
36
+
37
+ **Trả lời CHÍNH XÁC theo format JSON:**
38
+ ```json
39
+ {
40
+ "thought": "Suy nghĩ của bạn...",
41
+ "action": "tool_name hoặc finish",
42
+ "action_input": {"param1": "value1"}
43
+ }
44
+ ```
45
+
46
+ **Quan trọng:**
47
+ - Nếu cần biết vị trí → dùng get_location_coordinates trước
48
+ - Nếu tìm theo khoảng cách → dùng find_nearby_places
49
+ - Nếu cần lọc theo đặc điểm (view, không gian, giá) → dùng retrieve_context_text
50
+ - Khi đủ thông tin → action = "finish"
51
+ """
52
+
53
+
54
+ @dataclass
55
+ class ReasoningResult:
56
+ """Result from LLM reasoning step."""
57
+
58
+ thought: str
59
+ action: str
60
+ action_input: dict
61
+ raw_response: str
62
+ parse_error: str | None = None
63
+
64
+
65
+ def parse_reasoning_response(response: str) -> ReasoningResult:
66
+ """
67
+ Parse LLM response into thought/action/action_input.
68
+
69
+ Handles various formats:
70
+ - Clean JSON
71
+ - JSON in markdown code blocks
72
+ - Partial/malformed JSON
73
+ """
74
+ raw = response.strip()
75
+
76
+ # Try to extract JSON from code blocks
77
+ json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL)
78
+ if json_match:
79
+ raw = json_match.group(1)
80
+
81
+ # Try to find JSON object
82
+ json_start = raw.find('{')
83
+ json_end = raw.rfind('}')
84
+ if json_start != -1 and json_end != -1:
85
+ raw = raw[json_start:json_end + 1]
86
+
87
+ try:
88
+ data = json.loads(raw)
89
+ return ReasoningResult(
90
+ thought=data.get("thought", ""),
91
+ action=data.get("action", "finish"),
92
+ action_input=data.get("action_input", {}),
93
+ raw_response=response,
94
+ )
95
+ except json.JSONDecodeError as e:
96
+ agent_logger.error(f"Failed to parse reasoning response", e)
97
+
98
+ # Fallback: try to extract key fields with regex
99
+ thought_match = re.search(r'"thought"\s*:\s*"([^"]*)"', raw)
100
+ action_match = re.search(r'"action"\s*:\s*"([^"]*)"', raw)
101
+
102
+ thought = thought_match.group(1) if thought_match else "Parse error"
103
+ action = action_match.group(1) if action_match else "finish"
104
+
105
+ return ReasoningResult(
106
+ thought=thought,
107
+ action=action,
108
+ action_input={},
109
+ raw_response=response,
110
+ parse_error=str(e),
111
+ )
112
+
113
+
114
+ def build_reasoning_prompt(
115
+ query: str,
116
+ context_summary: str,
117
+ previous_steps: list[dict],
118
+ image_url: str | None = None,
119
+ ) -> str:
120
+ """Build the prompt for the next reasoning step."""
121
+
122
+ # Previous steps summary
123
+ steps_text = ""
124
+ if previous_steps:
125
+ steps_text = "\n**Các bước đã thực hiện:**\n"
126
+ for step in previous_steps:
127
+ steps_text += f"- Step {step['step']}: {step['thought'][:80]}...\n"
128
+ steps_text += f" Action: {step['action']} → {len(step.get('observation', [])) if isinstance(step.get('observation'), list) else 'done'} kết quả\n"
129
+
130
+ # Image context
131
+ image_text = ""
132
+ if image_url:
133
+ image_text = "\n**Lưu ý:** User đã gửi kèm ảnh. Có thể dùng retrieve_similar_visuals nếu cần.\n"
134
+
135
+ prompt = f"""**Câu hỏi của user:** {query}
136
+ {image_text}
137
+ {context_summary}
138
+ {steps_text}
139
+ **Bước tiếp theo là gì?**
140
+
141
+ Trả lời theo format JSON:
142
+ ```json
143
+ {{
144
+ "thought": "...",
145
+ "action": "tool_name hoặc finish",
146
+ "action_input": {{...}}
147
+ }}
148
+ ```"""
149
+
150
+ return prompt
151
+
152
+
153
+ def get_tool_purpose(action: str) -> str:
154
+ """Get human-readable purpose for a tool."""
155
+ purposes = {
156
+ "get_location_coordinates": "Lấy tọa độ địa điểm",
157
+ "find_nearby_places": "Tìm địa điểm gần vị trí",
158
+ "retrieve_context_text": "Tìm theo văn bản (reviews, mô tả)",
159
+ "retrieve_similar_visuals": "Tìm theo hình ảnh tương tự",
160
+ "finish": "Hoàn thành và tổng hợp kết quả",
161
+ }
162
+ return purposes.get(action, action)
app/agent/state.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent State Models - State management for ReAct multi-step reasoning."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class ReActStep:
10
+ """A single step in the ReAct reasoning loop."""
11
+
12
+ step_number: int
13
+ thought: str # LLM's reasoning about what to do
14
+ action: str # Tool name or "finish"
15
+ action_input: dict # Tool arguments
16
+ observation: Any = None # Tool result
17
+ duration_ms: float = 0
18
+ timestamp: datetime = field(default_factory=datetime.now)
19
+
20
+ def to_dict(self) -> dict:
21
+ """Convert to dictionary for serialization."""
22
+ return {
23
+ "step": self.step_number,
24
+ "thought": self.thought,
25
+ "action": self.action,
26
+ "action_input": self.action_input,
27
+ "observation": self._truncate_observation(),
28
+ "duration_ms": round(self.duration_ms, 1),
29
+ }
30
+
31
+ def _truncate_observation(self, max_items: int = 3) -> Any:
32
+ """Truncate observation for display."""
33
+ if isinstance(self.observation, list) and len(self.observation) > max_items:
34
+ return self.observation[:max_items] + [f"... and {len(self.observation) - max_items} more"]
35
+ return self.observation
36
+
37
+
38
+ @dataclass
39
+ class AgentState:
40
+ """Complete state for a ReAct agent session."""
41
+
42
+ query: str
43
+ steps: list[ReActStep] = field(default_factory=list)
44
+ context: dict = field(default_factory=dict) # Accumulated context from tools
45
+ current_step: int = 0
46
+ max_steps: int = 5
47
+ is_complete: bool = False
48
+ final_answer: str = ""
49
+ total_duration_ms: float = 0
50
+ error: str | None = None
51
+
52
+ def add_step(self, step: ReActStep) -> None:
53
+ """Add a completed step to the state."""
54
+ self.steps.append(step)
55
+ self.current_step += 1
56
+
57
+ # Add tool result to context
58
+ if step.action != "finish" and step.observation:
59
+ self.context[step.action] = step.observation
60
+
61
+ def can_continue(self) -> bool:
62
+ """Check if agent can continue reasoning."""
63
+ return (
64
+ not self.is_complete
65
+ and self.current_step < self.max_steps
66
+ and self.error is None
67
+ )
68
+
69
+ def get_context_summary(self) -> str:
70
+ """Get a summary of accumulated context for LLM."""
71
+ if not self.context:
72
+ return "Chưa có kết quả từ các tools trước đó."
73
+
74
+ summary_parts = []
75
+ for tool_name, result in self.context.items():
76
+ if isinstance(result, list):
77
+ summary_parts.append(f"- {tool_name}: {len(result)} kết quả")
78
+ elif isinstance(result, dict):
79
+ summary_parts.append(f"- {tool_name}: {result}")
80
+ else:
81
+ summary_parts.append(f"- {tool_name}: {str(result)[:100]}")
82
+
83
+ return "Kết quả từ các bước trước:\n" + "\n".join(summary_parts)
84
+
85
+ def to_dict(self) -> dict:
86
+ """Convert to dictionary for API response."""
87
+ return {
88
+ "query": self.query,
89
+ "total_steps": len(self.steps),
90
+ "max_steps": self.max_steps,
91
+ "is_complete": self.is_complete,
92
+ "steps": [s.to_dict() for s in self.steps],
93
+ "tools_used": list(self.context.keys()),
94
+ "total_duration_ms": round(self.total_duration_ms, 1),
95
+ "error": self.error,
96
+ }
app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API module."""
app/api/router.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API Router with /chat endpoint for Swagger testing."""
2
+
3
+ from enum import Enum
4
+ from pydantic import BaseModel, Field
5
+ from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.agent.mmca_agent import MMCAAgent
9
+ from app.agent.react_agent import ReActAgent
10
+ from app.shared.db.session import get_db
11
+ from app.core.config import settings
12
+ from app.mcp.tools import mcp_tools
13
+ from app.shared.chat_history import chat_history
14
+
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class LLMProvider(str, Enum):
20
+ """Available LLM providers."""
21
+
22
+ GOOGLE = "Google"
23
+ MEGALLM = "MegaLLM"
24
+
25
+
26
+ class ChatRequest(BaseModel):
27
+ """Chat request model."""
28
+
29
+ message: str = Field(
30
+ ...,
31
+ description="User message in natural language",
32
+ examples=["Tìm quán cafe gần bãi biển Mỹ Khê"],
33
+ )
34
+ user_id: str = Field(
35
+ default="anonymous",
36
+ description="User ID for session management",
37
+ examples=["user_123", "anonymous"],
38
+ )
39
+ session_id: str | None = Field(
40
+ None,
41
+ description="Session ID (optional, uses 'default' if not provided)",
42
+ examples=["session_abc", "default"],
43
+ )
44
+ image_url: str | None = Field(
45
+ None,
46
+ description="Optional image URL for visual similarity search",
47
+ examples=["https://example.com/cafe.jpg"],
48
+ )
49
+ provider: LLMProvider = Field(
50
+ default=LLMProvider.MEGALLM,
51
+ description="LLM provider to use: Google or MegaLLM",
52
+ )
53
+ model: str | None = Field(
54
+ None,
55
+ description=f"Model name. Defaults: Google={settings.default_gemini_model}, MegaLLM={settings.default_megallm_model}",
56
+ examples=["gemini-2.0-flash", "deepseek-r1-distill-llama-70b"],
57
+ )
58
+ react_mode: bool = Field(
59
+ default=False,
60
+ description="Enable ReAct multi-step reasoning mode",
61
+ )
62
+ max_steps: int = Field(
63
+ default=5,
64
+ description="Maximum reasoning steps for ReAct mode",
65
+ ge=1,
66
+ le=10,
67
+ )
68
+
69
+
70
+ class WorkflowStepResponse(BaseModel):
71
+ """Workflow step info."""
72
+
73
+ step: str = Field(..., description="Step name")
74
+ tool: str | None = Field(None, description="Tool used")
75
+ purpose: str = Field(default="", description="Purpose of this step")
76
+ results: int = Field(default=0, description="Number of results")
77
+
78
+
79
+ class WorkflowResponse(BaseModel):
80
+ """Workflow trace for debugging."""
81
+
82
+ query: str = Field(..., description="Original query")
83
+ intent_detected: str = Field(..., description="Detected intent")
84
+ tools_used: list[str] = Field(default_factory=list, description="Tools used")
85
+ steps: list[WorkflowStepResponse] = Field(default_factory=list, description="Workflow steps")
86
+ total_duration_ms: float = Field(..., description="Total processing time")
87
+
88
+
89
+ class ChatResponse(BaseModel):
90
+ """Chat response model with workflow trace."""
91
+
92
+ response: str = Field(..., description="Agent's response")
93
+ status: str = Field(default="success", description="Response status")
94
+ provider: str = Field(..., description="LLM provider used")
95
+ model: str = Field(..., description="Model used")
96
+ user_id: str = Field(..., description="User ID")
97
+ session_id: str = Field(..., description="Session ID used")
98
+ workflow: WorkflowResponse | None = Field(None, description="Workflow trace for debugging")
99
+ tools_used: list[str] = Field(default_factory=list, description="MCP tools used")
100
+ duration_ms: float = Field(default=0, description="Total processing time in ms")
101
+
102
+
103
+ class NearbyRequest(BaseModel):
104
+ """Nearby places request model."""
105
+
106
+ lat: float = Field(..., description="Latitude", examples=[16.0626442])
107
+ lng: float = Field(..., description="Longitude", examples=[108.2462143])
108
+ max_distance_km: float = Field(
109
+ default=5.0,
110
+ description="Maximum distance in kilometers",
111
+ examples=[5.0, 18.72],
112
+ )
113
+ category: str | None = Field(
114
+ None,
115
+ description="Category filter (cafe, restaurant, attraction, etc.)",
116
+ examples=["cafe", "restaurant"],
117
+ )
118
+ limit: int = Field(default=10, description="Maximum results", examples=[10, 20])
119
+
120
+
121
+ class PlaceResponse(BaseModel):
122
+ """Place response model."""
123
+
124
+ place_id: str
125
+ name: str
126
+ category: str | None = None
127
+ lat: float | None = None
128
+ lng: float | None = None
129
+ distance_km: float | None = None
130
+ rating: float | None = None
131
+ description: str | None = None
132
+
133
+
134
+ class NearbyResponse(BaseModel):
135
+ """Nearby places response model."""
136
+
137
+ places: list[PlaceResponse]
138
+ count: int
139
+ query: dict
140
+
141
+
142
+ class ClearHistoryRequest(BaseModel):
143
+ """Clear history request model."""
144
+
145
+ user_id: str = Field(..., description="User ID to clear history for")
146
+ session_id: str | None = Field(
147
+ None,
148
+ description="Session ID to clear (clears all if not provided)",
149
+ )
150
+
151
+
152
+ class HistoryResponse(BaseModel):
153
+ """Chat history response model."""
154
+
155
+ user_id: str
156
+ sessions: list[str]
157
+ current_session: str | None
158
+ message_count: int
159
+
160
+
161
+ @router.post(
162
+ "/nearby",
163
+ response_model=NearbyResponse,
164
+ summary="Find nearby places (Neo4j)",
165
+ description="""
166
+ Find places near a given location using Neo4j spatial query.
167
+
168
+ This endpoint directly tests the `find_nearby_places` MCP tool.
169
+
170
+ ## Test Cases
171
+ - Case 1: lat=16.0626442, lng=108.2462143, max_distance_km=18.72
172
+ - Case 2: lat=16.0623184, lng=108.2306049, max_distance_km=17.94
173
+ """,
174
+ )
175
+ async def find_nearby(request: NearbyRequest) -> NearbyResponse:
176
+ """
177
+ Find nearby places using Neo4j graph database.
178
+
179
+ Directly calls the find_nearby_places MCP tool.
180
+ """
181
+ places = await mcp_tools.find_nearby_places(
182
+ lat=request.lat,
183
+ lng=request.lng,
184
+ max_distance_km=request.max_distance_km,
185
+ category=request.category,
186
+ limit=request.limit,
187
+ )
188
+
189
+ return NearbyResponse(
190
+ places=[
191
+ PlaceResponse(
192
+ place_id=p.place_id,
193
+ name=p.name,
194
+ category=p.category,
195
+ lat=p.lat,
196
+ lng=p.lng,
197
+ distance_km=p.distance_km,
198
+ rating=p.rating,
199
+ description=p.description,
200
+ )
201
+ for p in places
202
+ ],
203
+ count=len(places),
204
+ query={
205
+ "lat": request.lat,
206
+ "lng": request.lng,
207
+ "max_distance_km": request.max_distance_km,
208
+ "category": request.category,
209
+ },
210
+ )
211
+
212
+
213
+ @router.post(
214
+ "/chat",
215
+ response_model=ChatResponse,
216
+ summary="Chat with LocalMate Agent",
217
+ description="""
218
+ Chat with the Multi-Modal Contextual Agent (MMCA).
219
+
220
+ ## Session Management
221
+ - Each user can have up to **3 sessions** stored
222
+ - Provide `user_id` and optional `session_id` to maintain conversation history
223
+ - History is automatically injected into the agent prompt
224
+
225
+ ## LLM Providers
226
+ - **Google**: Gemini models (gemini-2.0-flash, etc.)
227
+ - **MegaLLM**: DeepSeek models (deepseek-r1-distill-llama-70b, etc.)
228
+
229
+ ## Examples
230
+ - "Tìm quán cafe gần bãi biển Mỹ Khê"
231
+ - "Nhà hàng hải sản nào gần Cầu Rồng?"
232
+ """,
233
+ )
234
+ async def chat(
235
+ request: ChatRequest,
236
+ db: AsyncSession = Depends(get_db),
237
+ ) -> ChatResponse:
238
+ """
239
+ Chat endpoint with per-user history support.
240
+
241
+ Send a natural language message, select provider and model.
242
+ The agent will analyze your intent, query relevant data sources,
243
+ and return a synthesized response with conversation context.
244
+ """
245
+ # Determine model to use
246
+ if request.model:
247
+ model = request.model
248
+ elif request.provider == LLMProvider.GOOGLE:
249
+ model = settings.default_gemini_model
250
+ else:
251
+ model = settings.default_megallm_model
252
+
253
+ # Get session ID
254
+ session_id = request.session_id or "default"
255
+
256
+ # Get conversation history for context
257
+ history = chat_history.get_history(
258
+ user_id=request.user_id,
259
+ session_id=session_id,
260
+ max_messages=6, # Last 3 exchanges (6 messages)
261
+ )
262
+
263
+ # Add user message to history
264
+ chat_history.add_message(
265
+ user_id=request.user_id,
266
+ role="user",
267
+ content=request.message,
268
+ session_id=session_id,
269
+ )
270
+
271
+ # Choose agent based on react_mode
272
+ if request.react_mode:
273
+ # ReAct multi-step agent
274
+ agent = ReActAgent(
275
+ provider=request.provider.value,
276
+ model=model,
277
+ max_steps=request.max_steps,
278
+ )
279
+ response_text, agent_state = await agent.run(
280
+ query=request.message,
281
+ db=db,
282
+ image_url=request.image_url,
283
+ history=history,
284
+ )
285
+
286
+ # Convert state to workflow
287
+ workflow = agent.to_workflow(agent_state)
288
+ workflow_data = workflow.to_dict()
289
+
290
+ # Add assistant response to history
291
+ chat_history.add_message(
292
+ user_id=request.user_id,
293
+ role="assistant",
294
+ content=response_text,
295
+ session_id=session_id,
296
+ )
297
+
298
+ workflow_response = WorkflowResponse(
299
+ query=workflow_data["query"],
300
+ intent_detected=workflow_data["intent_detected"],
301
+ tools_used=workflow_data["tools_used"],
302
+ steps=[WorkflowStepResponse(**s) for s in workflow_data["steps"]],
303
+ total_duration_ms=workflow_data["total_duration_ms"],
304
+ )
305
+
306
+ return ChatResponse(
307
+ response=response_text,
308
+ status="success",
309
+ provider=request.provider.value,
310
+ model=model,
311
+ user_id=request.user_id,
312
+ session_id=session_id,
313
+ workflow=workflow_response,
314
+ tools_used=workflow.tools_used,
315
+ duration_ms=agent_state.total_duration_ms,
316
+ )
317
+
318
+ else:
319
+ # Single-step agent (original behavior)
320
+ agent = MMCAAgent(provider=request.provider.value, model=model)
321
+
322
+ # Pass history to agent
323
+ result = await agent.chat(
324
+ message=request.message,
325
+ db=db,
326
+ image_url=request.image_url,
327
+ history=history,
328
+ )
329
+
330
+ # Add assistant response to history
331
+ chat_history.add_message(
332
+ user_id=request.user_id,
333
+ role="assistant",
334
+ content=result.response,
335
+ session_id=session_id,
336
+ )
337
+
338
+ # Build workflow response
339
+ workflow_data = result.workflow.to_dict()
340
+ workflow_response = WorkflowResponse(
341
+ query=workflow_data["query"],
342
+ intent_detected=workflow_data["intent_detected"],
343
+ tools_used=workflow_data["tools_used"],
344
+ steps=[WorkflowStepResponse(**s) for s in workflow_data["steps"]],
345
+ total_duration_ms=workflow_data["total_duration_ms"],
346
+ )
347
+
348
+ return ChatResponse(
349
+ response=result.response,
350
+ status="success",
351
+ provider=request.provider.value,
352
+ model=model,
353
+ user_id=request.user_id,
354
+ session_id=session_id,
355
+ workflow=workflow_response,
356
+ tools_used=result.tools_used,
357
+ duration_ms=result.total_duration_ms,
358
+ )
359
+
360
+
361
+ @router.post(
362
+ "/chat/clear",
363
+ summary="Clear chat history",
364
+ description="Clears the conversation history for a specific user/session.",
365
+ )
366
+ async def clear_chat(request: ClearHistoryRequest):
367
+ """Clear conversation history for a user."""
368
+ if request.session_id:
369
+ chat_history.clear_session(request.user_id, request.session_id)
370
+ message = f"Session '{request.session_id}' cleared for user '{request.user_id}'"
371
+ else:
372
+ chat_history.clear_all_sessions(request.user_id)
373
+ message = f"All sessions cleared for user '{request.user_id}'"
374
+
375
+ return {"status": "success", "message": message}
376
+
377
+
378
+ @router.get(
379
+ "/chat/history/{user_id}",
380
+ response_model=HistoryResponse,
381
+ summary="Get chat history info",
382
+ description="Get information about user's chat sessions.",
383
+ )
384
+ async def get_history_info(user_id: str) -> HistoryResponse:
385
+ """Get chat history information for a user."""
386
+ sessions = chat_history.get_session_ids(user_id)
387
+ messages = chat_history.get_messages(user_id)
388
+
389
+ return HistoryResponse(
390
+ user_id=user_id,
391
+ sessions=sessions,
392
+ current_session="default" if "default" in sessions else (sessions[0] if sessions else None),
393
+ message_count=len(messages),
394
+ )
395
+
396
+
397
+ class ImageSearchResult(BaseModel):
398
+ """Image search result model."""
399
+
400
+ place_id: str
401
+ name: str
402
+ category: str | None = None
403
+ rating: float | None = None
404
+ similarity: float
405
+ matched_images: int = 1
406
+ image_url: str | None = None
407
+
408
+
409
+ class ImageSearchResponse(BaseModel):
410
+ """Image search response model."""
411
+
412
+ results: list[ImageSearchResult]
413
+ total: int
414
+
415
+
416
+ @router.post(
417
+ "/search/image",
418
+ response_model=ImageSearchResponse,
419
+ summary="Search places by image",
420
+ description="""
421
+ Upload an image to find visually similar places.
422
+
423
+ Uses image embeddings stored in Supabase pgvector.
424
+ """,
425
+ )
426
+ async def search_by_image(
427
+ image: UploadFile = File(..., description="Image file to search"),
428
+ limit: int = Query(10, ge=1, le=50, description="Maximum results"),
429
+ db: AsyncSession = Depends(get_db),
430
+ ) -> ImageSearchResponse:
431
+ """
432
+ Search places by uploading an image.
433
+
434
+ Uses visual embeddings to find similar places.
435
+ """
436
+ try:
437
+ # Read image bytes
438
+ image_bytes = await image.read()
439
+
440
+ if len(image_bytes) > 10 * 1024 * 1024: # 10MB limit
441
+ raise HTTPException(status_code=400, detail="Image too large (max 10MB)")
442
+
443
+ # Search using visual tool
444
+ results = await mcp_tools.search_by_image_bytes(
445
+ db=db,
446
+ image_bytes=image_bytes,
447
+ limit=limit,
448
+ )
449
+
450
+ return ImageSearchResponse(
451
+ results=[
452
+ ImageSearchResult(
453
+ place_id=r.place_id,
454
+ name=r.name,
455
+ category=r.category,
456
+ rating=r.rating,
457
+ similarity=r.similarity,
458
+ matched_images=r.matched_images,
459
+ image_url=r.image_url,
460
+ )
461
+ for r in results
462
+ ],
463
+ total=len(results),
464
+ )
465
+ except HTTPException:
466
+ raise
467
+ except Exception as e:
468
+ raise HTTPException(status_code=500, detail=f"Image search error: {str(e)}")
469
+
app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core module."""
app/core/config.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core configuration for LocalMate v2."""
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ """Application settings loaded from environment."""
8
+
9
+ # App
10
+ app_env: str = "local"
11
+ app_debug: bool = True
12
+
13
+ # Supabase
14
+ supabase_url: str
15
+ supabase_anon_key: str
16
+ supabase_service_role_key: str
17
+ database_url: str
18
+
19
+ # Neo4j
20
+ neo4j_uri: str
21
+ neo4j_username: str
22
+ neo4j_password: str
23
+
24
+ # Google AI
25
+ google_api_key: str
26
+
27
+ # MegaLLM (OpenAI-compatible)
28
+ megallm_api_key: str | None = None
29
+ megallm_base_url: str = "https://ai.megallm.io/v1"
30
+
31
+ # Optional: CLIP for image embeddings
32
+ huggingface_api_key: str | None = None
33
+
34
+ # Default model configs (can be overridden per request)
35
+ default_gemini_model: str = "gemini-2.0-flash"
36
+ default_megallm_model: str = "deepseek-ai/deepseek-v3.1-terminus"
37
+ embedding_model: str = "text-embedding-004"
38
+
39
+ class Config:
40
+ env_file = ".env"
41
+ extra = "ignore"
42
+
43
+
44
+ settings = Settings()
45
+
app/main.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LocalMate Da Nang V2 - Multi-Modal Contextual Agent API.
3
+
4
+ FastAPI application entry point with /chat endpoint for testing.
5
+ """
6
+
7
+ from contextlib import asynccontextmanager
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ from app.api.router import router as api_router
13
+ from app.planner.router import router as planner_router
14
+ from app.shared.db.session import engine
15
+ from app.shared.integrations.neo4j_client import neo4j_client
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ """Application lifespan handler for startup/shutdown."""
21
+ # Startup - preload SigLIP model
22
+ try:
23
+ from app.shared.integrations.siglip_client import get_siglip_client
24
+ siglip = get_siglip_client()
25
+ print(f"✅ SigLIP ready: {siglip.is_loaded}")
26
+ except Exception as e:
27
+ print(f"⚠️ SigLIP not loaded (image search disabled): {e}")
28
+
29
+ yield
30
+
31
+ # Shutdown
32
+ await neo4j_client.close()
33
+ await engine.dispose()
34
+
35
+
36
+ app = FastAPI(
37
+ title="LocalMate Da Nang V2",
38
+ description="""
39
+ ## Multi-Modal Contextual Agent (MMCA) API
40
+
41
+ Intelligent travel assistant for Da Nang with 3 MCP tools + Trip Planner:
42
+
43
+ ### Tools Available:
44
+ 1. **retrieve_context_text** - Semantic search in text descriptions (reviews, menus)
45
+ 2. **retrieve_similar_visuals** - Image similarity search (find similar vibes)
46
+ 3. **find_nearby_places** - Spatial search (find places near a location)
47
+
48
+ ### Trip Planner:
49
+ - Create plans and add places
50
+ - Optimize route with TSP algorithm
51
+ - Reorder, replace, and manage places
52
+
53
+ ### Examples:
54
+ - "Tìm quán cafe gần bãi biển Mỹ Khê"
55
+ - "Nhà hàng hải sản nào được review tốt?"
56
+ - "Quán nào có không gian xanh mát?" (with image_url)
57
+ """,
58
+ version="0.2.1",
59
+ lifespan=lifespan,
60
+ )
61
+
62
+ # CORS middleware - allow all for demo
63
+ app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=["*"],
66
+ allow_credentials=True,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+ # Include API routers
72
+ app.include_router(api_router, prefix="/api/v1", tags=["Chat"])
73
+ app.include_router(planner_router, prefix="/api/v1", tags=["Trip Planner"])
74
+
75
+
76
+ @app.get("/health", tags=["System"])
77
+ async def health_check():
78
+ """
79
+ Health check endpoint.
80
+
81
+ Returns status of the application and connected services.
82
+ """
83
+ neo4j_ok = await neo4j_client.verify_connectivity()
84
+ return {
85
+ "status": "healthy",
86
+ "version": "0.2.0",
87
+ "services": {
88
+ "neo4j": "connected" if neo4j_ok else "disconnected",
89
+ },
90
+ }
91
+
92
+
93
+ @app.get("/", tags=["System"])
94
+ async def root():
95
+ """Root endpoint with API info."""
96
+ return {
97
+ "name": "LocalMate Da Nang V2 - MMCA API",
98
+ "version": "0.2.0",
99
+ "docs": "/docs",
100
+ "description": "Multi-Modal Contextual Agent for Da Nang Tourism",
101
+ }
app/mcp/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """MCP module - Model Context Protocol tools."""
app/mcp/tools/__init__.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP Tools Package - Model Context Protocol tools for the agent.
2
+
3
+ Tools:
4
+ 1. retrieve_context_text - Text search via pgvector + places_metadata
5
+ 2. retrieve_similar_visuals - Image search via pgvector + places_metadata
6
+ 3. find_nearby_places - Neo4j spatial search + place details
7
+ """
8
+
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,
19
+ retrieve_similar_visuals,
20
+ search_by_image_url,
21
+ search_by_image_bytes,
22
+ TOOL_DEFINITION as VISUAL_TOOL_DEFINITION,
23
+ )
24
+ from app.mcp.tools.graph_tool import (
25
+ PlaceResult,
26
+ PlaceDetails,
27
+ NearbyPlace,
28
+ Review,
29
+ AVAILABLE_CATEGORIES,
30
+ find_nearby_places,
31
+ get_place_details,
32
+ get_nearby_by_relationship,
33
+ get_same_category_places,
34
+ geocode_location,
35
+ get_location_coordinates,
36
+ TOOL_DEFINITION as GRAPH_TOOL_DEFINITION,
37
+ )
38
+
39
+
40
+ # Combined tool definitions for agent
41
+ TOOL_DEFINITIONS = [
42
+ TEXT_TOOL_DEFINITION,
43
+ VISUAL_TOOL_DEFINITION,
44
+ GRAPH_TOOL_DEFINITION,
45
+ ]
46
+
47
+
48
+ class MCPTools:
49
+ """
50
+ MCP Tools container implementing the 3 core tools for MMCA Agent.
51
+ """
52
+
53
+ TOOL_DEFINITIONS = TOOL_DEFINITIONS
54
+ AVAILABLE_CATEGORIES = AVAILABLE_CATEGORIES
55
+
56
+ # Text Tool
57
+ async def retrieve_context_text(self, db, query, limit=10, threshold=0.3):
58
+ """Semantic search in text descriptions."""
59
+ return await retrieve_context_text(db, query, limit, threshold)
60
+
61
+ # Visual Tool
62
+ async def retrieve_similar_visuals(self, db, image_url=None, image_bytes=None, limit=10, threshold=0.2):
63
+ """Visual similarity search using image embeddings."""
64
+ return await retrieve_similar_visuals(db, image_url, image_bytes, limit, threshold)
65
+
66
+ async def search_by_image_url(self, db, image_url, limit=10):
67
+ """Search places by image URL."""
68
+ return await search_by_image_url(db, image_url, limit)
69
+
70
+ async def search_by_image_bytes(self, db, image_bytes, limit=10):
71
+ """Search places by uploading image bytes."""
72
+ return await search_by_image_bytes(db, image_bytes, limit)
73
+
74
+ # Graph Tool
75
+ async def find_nearby_places(self, lat, lng, max_distance_km=5.0, category=None, limit=10):
76
+ """Find nearby places using Neo4j spatial query."""
77
+ return await find_nearby_places(lat, lng, max_distance_km, category, limit)
78
+
79
+ async def get_place_details(self, place_id, include_nearby=True, include_same_category=True, nearby_limit=5):
80
+ """Get complete place details with photos, reviews, and relationships."""
81
+ return await get_place_details(place_id, include_nearby, include_same_category, nearby_limit)
82
+
83
+ async def get_same_category_places(self, place_id, limit=5):
84
+ """Get other places in the same category."""
85
+ return await get_same_category_places(place_id, limit)
86
+
87
+ async def geocode_location(self, location_name, country="Vietnam"):
88
+ """Geocode a location using OpenStreetMap Nominatim."""
89
+ return await geocode_location(location_name, country)
90
+
91
+ async def get_location_coordinates(self, location_name):
92
+ """Get coordinates for a location (Neo4j + OSM fallback)."""
93
+ return await get_location_coordinates(location_name)
94
+
95
+
96
+ # Global MCP tools instance
97
+ mcp_tools = MCPTools()
98
+
99
+
100
+ # Re-export for convenience
101
+ __all__ = [
102
+ "MCPTools",
103
+ "mcp_tools",
104
+ "TextSearchResult",
105
+ "ImageSearchResult",
106
+ "PlaceResult",
107
+ "PlaceDetails",
108
+ "NearbyPlace",
109
+ "Review",
110
+ "retrieve_context_text",
111
+ "retrieve_similar_visuals",
112
+ "find_nearby_places",
113
+ "get_place_details",
114
+ "geocode_location",
115
+ "get_location_coordinates",
116
+ "TOOL_DEFINITIONS",
117
+ "AVAILABLE_CATEGORIES",
118
+ ]
app/mcp/tools/graph_tool.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph Tool - Neo4j spatial search with place details, relationships, and OSM geocoding.
2
+
3
+ Features:
4
+ - Spatial search (find nearby places by coordinates)
5
+ - Place details with photos and reviews
6
+ - Same category relationships
7
+ - OpenStreetMap geocoding fallback
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional, Any
12
+
13
+ import httpx
14
+
15
+ from app.shared.integrations.neo4j_client import neo4j_client
16
+
17
+
18
+ @dataclass
19
+ class PlaceResult:
20
+ """Result from nearby places search."""
21
+
22
+ place_id: str
23
+ name: str
24
+ category: str
25
+ lat: float
26
+ lng: float
27
+ distance_km: float | None = None
28
+ rating: float | None = None
29
+ description: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class NearbyPlace:
34
+ """Nearby place with distance."""
35
+
36
+ place_id: str
37
+ name: str
38
+ category: str
39
+ rating: float
40
+ distance_km: float
41
+
42
+
43
+ @dataclass
44
+ class Review:
45
+ """Place review."""
46
+
47
+ text: str
48
+ rating: int
49
+ reviewer: str
50
+
51
+
52
+ @dataclass
53
+ class PlaceDetails:
54
+ """Complete place details from Neo4j."""
55
+
56
+ place_id: str
57
+ name: str
58
+ category: str
59
+ rating: float
60
+ address: str
61
+ phone: str | None = None
62
+ website: str | None = None
63
+ google_maps_url: str | None = None
64
+ description: str | None = None
65
+ specialty: str | None = None
66
+ price_range: str | None = None
67
+ coordinates: dict[str, float] = field(default_factory=dict)
68
+ photos_count: int = 0
69
+ reviews_count: int = 0
70
+ photos: list[str] = field(default_factory=list)
71
+ reviews: list[Review] = field(default_factory=list)
72
+ nearby_places: list[NearbyPlace] = field(default_factory=list)
73
+ same_category: list[dict[str, Any]] = field(default_factory=list)
74
+
75
+
76
+ # Available categories in Neo4j
77
+ AVAILABLE_CATEGORIES = [
78
+ "Asian restaurant", "Athletic club", "Badminton court", "Bakery", "Bar",
79
+ "Bistro", "Board game club", "Breakfast restaurant", "Cafe",
80
+ "Cantonese restaurant", "Chicken restaurant", "Chinese restaurant",
81
+ "Cocktail bar", "Coffee shop", "Country food restaurant", "Deli",
82
+ "Dessert shop", "Disco club", "Dumpling restaurant", "Espresso bar",
83
+ "Family restaurant", "Fine dining restaurant", "Fitness center",
84
+ "Food court", "French restaurant", "Game store", "Gym",
85
+ "Hamburger restaurant", "Holiday apartment rental", "Hot pot restaurant",
86
+ "Hotel", "Ice cream shop", "Indian restaurant", "Irish pub",
87
+ "Italian restaurant", "Izakaya restaurant", "Japanese restaurant",
88
+ "Korean barbecue restaurant", "Korean restaurant", "Live music bar",
89
+ "Malaysian restaurant", "Mexican restaurant", "Movie theater",
90
+ "Musical club", "Noodle shop", "Pho restaurant", "Pickleball court",
91
+ "Pizza restaurant", "Ramen restaurant", "Restaurant", "Restaurant or cafe",
92
+ "Rice cake shop", "Sandwich shop", "Seafood restaurant", "Soccer field",
93
+ "Soup shop", "Sports bar", "Sports club", "Sports complex", "Steak house",
94
+ "Sushi restaurant", "Takeout Restaurant", "Tennis court", "Tiffin center",
95
+ "Udon noodle restaurant", "Vegan restaurant", "Vegetarian restaurant",
96
+ "Vietnamese restaurant",
97
+ ]
98
+
99
+
100
+ # Tool definition for agent
101
+ TOOL_DEFINITION = {
102
+ "name": "find_nearby_places",
103
+ "description": """Tìm địa điểm gần một vị trí hoặc lấy chi tiết địa điểm.
104
+
105
+ Dùng khi:
106
+ - Người dùng hỏi về vị trí, khoảng cách, "gần đây", "gần X"
107
+ - Cần tìm quán xung quanh một landmark (Cầu Rồng, Mỹ Khê, Bà Nà)
108
+ - Lấy chi tiết đầy đủ về một địa điểm cụ thể
109
+
110
+ Categories: Restaurant, Coffee shop, Cafe, Bar, Hotel, Seafood restaurant,
111
+ Japanese restaurant, Korean restaurant, Gym, Fitness center, v.v.""",
112
+ "parameters": {
113
+ "location": "Tên địa điểm trung tâm (VD: 'Bãi biển Mỹ Khê', 'Cầu Rồng')",
114
+ "category": "Loại địa điểm: restaurant, coffee, hotel, bar, seafood, gym, etc.",
115
+ "max_distance_km": "Khoảng cách tối đa tính theo km (mặc định 5)",
116
+ "limit": "Số kết quả tối đa (mặc định 10)",
117
+ },
118
+ }
119
+
120
+
121
+ async def find_nearby_places(
122
+ lat: float,
123
+ lng: float,
124
+ max_distance_km: float = 5.0,
125
+ category: str | None = None,
126
+ limit: int = 10,
127
+ ) -> list[PlaceResult]:
128
+ """
129
+ Find nearby places using Neo4j spatial query.
130
+
131
+ Args:
132
+ lat: Center latitude
133
+ lng: Center longitude
134
+ max_distance_km: Maximum distance in kilometers
135
+ category: Optional category filter
136
+ limit: Maximum results
137
+
138
+ Returns:
139
+ List of nearby places ordered by distance
140
+ """
141
+ category_filter = ""
142
+ if category:
143
+ category_filter = "AND toLower(p.category) CONTAINS toLower($category)"
144
+
145
+ query = f"""
146
+ MATCH (p:Place)
147
+ WITH p, point.distance(
148
+ point({{latitude: p.latitude, longitude: p.longitude}}),
149
+ point({{latitude: $lat, longitude: $lng}})
150
+ ) / 1000 as distance_km
151
+ WHERE distance_km <= $max_distance {category_filter}
152
+ RETURN
153
+ p.id as place_id,
154
+ p.name as name,
155
+ p.category as category,
156
+ p.latitude as lat,
157
+ p.longitude as lng,
158
+ distance_km,
159
+ p.rating as rating,
160
+ p.description as description
161
+ ORDER BY distance_km
162
+ LIMIT $limit
163
+ """
164
+
165
+ params = {
166
+ "lat": lat,
167
+ "lng": lng,
168
+ "max_distance": max_distance_km,
169
+ "limit": limit,
170
+ }
171
+ if category:
172
+ params["category"] = category
173
+
174
+ results = await neo4j_client.run_cypher(query, params)
175
+
176
+ return [
177
+ PlaceResult(
178
+ place_id=r["place_id"],
179
+ name=r["name"],
180
+ category=r["category"] or '',
181
+ lat=r["lat"] or 0.0,
182
+ lng=r["lng"] or 0.0,
183
+ distance_km=r.get("distance_km"),
184
+ rating=r.get("rating"),
185
+ description=r.get("description"),
186
+ )
187
+ for r in results
188
+ ]
189
+
190
+
191
+ async def get_place_details(
192
+ place_id: str,
193
+ include_nearby: bool = True,
194
+ include_same_category: bool = True,
195
+ nearby_limit: int = 5,
196
+ ) -> PlaceDetails | None:
197
+ """
198
+ Get complete place details including photos, reviews, and relationships.
199
+
200
+ Args:
201
+ place_id: The place identifier
202
+ include_nearby: Whether to include nearby places
203
+ include_same_category: Whether to include same category places
204
+ nearby_limit: Limit for nearby/related results
205
+
206
+ Returns:
207
+ PlaceDetails or None if not found
208
+ """
209
+ # Main place query with photos and reviews
210
+ query = """
211
+ MATCH (p:Place {id: $place_id})
212
+ OPTIONAL MATCH (p)-[:HAS_PHOTO]->(photo:Photo)
213
+ OPTIONAL MATCH (p)-[:HAS_REVIEW]->(review:Review)
214
+ RETURN p,
215
+ collect(DISTINCT photo.path) as photos,
216
+ collect(DISTINCT {
217
+ text: review.text,
218
+ rating: review.rating,
219
+ reviewer: review.reviewer
220
+ }) as reviews
221
+ """
222
+
223
+ results = await neo4j_client.run_cypher(query, {"place_id": place_id})
224
+
225
+ if not results or not results[0].get('p'):
226
+ return None
227
+
228
+ record = results[0]
229
+ place = record['p']
230
+
231
+ details = PlaceDetails(
232
+ place_id=place.get('id', place_id),
233
+ name=place.get('name', 'Unknown'),
234
+ category=place.get('category', ''),
235
+ rating=float(place.get('rating', 0) or 0),
236
+ address=place.get('address', ''),
237
+ phone=place.get('phone'),
238
+ website=place.get('website'),
239
+ google_maps_url=place.get('google_maps_url'),
240
+ description=place.get('description'),
241
+ specialty=place.get('specialty'),
242
+ price_range=place.get('price_range'),
243
+ coordinates={
244
+ 'lat': float(place.get('latitude', 0) or 0),
245
+ 'lng': float(place.get('longitude', 0) or 0)
246
+ },
247
+ photos_count=int(place.get('photos_count', 0) or 0),
248
+ reviews_count=int(place.get('reviews_count', 0) or 0),
249
+ photos=record.get('photos', [])[:10],
250
+ reviews=[
251
+ Review(
252
+ text=r['text'] or '',
253
+ rating=int(r['rating'] or 0),
254
+ reviewer=r['reviewer'] or ''
255
+ )
256
+ for r in record.get('reviews', [])[:5]
257
+ if r.get('text')
258
+ ]
259
+ )
260
+
261
+ # Get nearby places
262
+ if include_nearby:
263
+ details.nearby_places = await get_nearby_by_relationship(place_id, nearby_limit)
264
+
265
+ # Get same category places
266
+ if include_same_category:
267
+ details.same_category = await get_same_category_places(place_id, nearby_limit)
268
+
269
+ return details
270
+
271
+
272
+ async def get_nearby_by_relationship(
273
+ place_id: str,
274
+ limit: int = 5,
275
+ max_distance_km: float = 2.0
276
+ ) -> list[NearbyPlace]:
277
+ """
278
+ Get places near a given place using NEAR relationship.
279
+
280
+ Args:
281
+ place_id: The source place identifier
282
+ limit: Maximum number of results
283
+ max_distance_km: Maximum distance in km
284
+
285
+ Returns:
286
+ List of NearbyPlace objects
287
+ """
288
+ query = """
289
+ MATCH (p:Place {id: $place_id})-[n:NEAR]-(other:Place)
290
+ WHERE n.distance_km <= $max_distance
291
+ RETURN other.id as place_id,
292
+ other.name as name,
293
+ other.category as category,
294
+ other.rating as rating,
295
+ n.distance_km as distance_km
296
+ ORDER BY n.distance_km
297
+ LIMIT $limit
298
+ """
299
+
300
+ results = await neo4j_client.run_cypher(query, {
301
+ "place_id": place_id,
302
+ "max_distance": max_distance_km,
303
+ "limit": limit
304
+ })
305
+
306
+ return [
307
+ NearbyPlace(
308
+ place_id=r['place_id'],
309
+ name=r['name'],
310
+ category=r['category'] or '',
311
+ rating=float(r['rating'] or 0),
312
+ distance_km=round(float(r['distance_km'] or 0), 2)
313
+ )
314
+ for r in results
315
+ ]
316
+
317
+
318
+ async def get_same_category_places(
319
+ place_id: str,
320
+ limit: int = 5
321
+ ) -> list[dict[str, Any]]:
322
+ """
323
+ Get other places in the same category.
324
+
325
+ Args:
326
+ place_id: The source place identifier
327
+ limit: Maximum number of results
328
+
329
+ Returns:
330
+ List of places in same category, ordered by rating
331
+ """
332
+ query = """
333
+ MATCH (p:Place {id: $place_id})-[:IN_CATEGORY]->(c:Category)<-[:IN_CATEGORY]-(other:Place)
334
+ WHERE other.id <> $place_id
335
+ RETURN other.id as place_id,
336
+ other.name as name,
337
+ other.category as category,
338
+ other.rating as rating,
339
+ other.address as address
340
+ ORDER BY other.rating DESC
341
+ LIMIT $limit
342
+ """
343
+
344
+ results = await neo4j_client.run_cypher(query, {
345
+ "place_id": place_id,
346
+ "limit": limit
347
+ })
348
+
349
+ return [
350
+ {
351
+ 'place_id': r['place_id'],
352
+ 'name': r['name'],
353
+ 'category': r['category'] or '',
354
+ 'rating': float(r['rating'] or 0),
355
+ 'address': r['address'] or ''
356
+ }
357
+ for r in results
358
+ ]
359
+
360
+
361
+ async def geocode_location(location_name: str, country: str = "Vietnam") -> tuple[float, float] | None:
362
+ """
363
+ Geocode a location name using OpenStreetMap Nominatim API.
364
+
365
+ Args:
366
+ location_name: Name of the place (e.g., "Cầu Rồng", "Bãi biển Mỹ Khê")
367
+ country: Country to bias search (default: Vietnam)
368
+
369
+ Returns:
370
+ (lat, lng) tuple or None if not found
371
+ """
372
+ search_query = f"{location_name}, Da Nang, {country}"
373
+
374
+ try:
375
+ async with httpx.AsyncClient(timeout=10.0) as client:
376
+ response = await client.get(
377
+ "https://nominatim.openstreetmap.org/search",
378
+ params={
379
+ "q": search_query,
380
+ "format": "json",
381
+ "limit": 1,
382
+ "addressdetails": 0,
383
+ },
384
+ headers={
385
+ "User-Agent": "LocalMate-DaNang/1.0 (travel assistant app)",
386
+ },
387
+ )
388
+ response.raise_for_status()
389
+ data = response.json()
390
+
391
+ if data and len(data) > 0:
392
+ lat = float(data[0]["lat"])
393
+ lng = float(data[0]["lon"])
394
+ return (lat, lng)
395
+
396
+ except Exception:
397
+ pass
398
+
399
+ return None
400
+
401
+
402
+ async def get_location_coordinates(location_name: str) -> tuple[float, float] | None:
403
+ """
404
+ Get coordinates for a location name.
405
+
406
+ First tries Neo4j, then falls back to OpenStreetMap Nominatim.
407
+
408
+ Args:
409
+ location_name: Name of the place (e.g., "Khách sạn Rex", "Cầu Rồng")
410
+
411
+ Returns:
412
+ (lat, lng) tuple or None if not found
413
+ """
414
+ # Try Neo4j first
415
+ try:
416
+ query = """
417
+ MATCH (p:Place)
418
+ WHERE toLower(p.name) CONTAINS toLower($name)
419
+ RETURN p.latitude as lat, p.longitude as lng
420
+ LIMIT 1
421
+ """
422
+ results = await neo4j_client.run_cypher(query, {"name": location_name})
423
+ if results and results[0].get("lat") and results[0].get("lng"):
424
+ return (results[0]["lat"], results[0]["lng"])
425
+ except Exception:
426
+ pass
427
+
428
+ # Fallback to OpenStreetMap Nominatim
429
+ osm_result = await geocode_location(location_name)
430
+ if osm_result:
431
+ return osm_result
432
+
433
+ return None
app/mcp/tools/text_tool.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text RAG Tool - Semantic search in text descriptions using pgvector.
2
+
3
+ Schema: place_text_embeddings (place_id, embedding, content_type, source_text, metadata)
4
+ places_metadata (place_id, name, category, rating, raw_data)
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from collections import defaultdict
9
+ from typing import Optional
10
+
11
+ from sqlalchemy import text
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from app.shared.integrations.embedding_client import embedding_client
15
+
16
+
17
+ @dataclass
18
+ class TextSearchResult:
19
+ """Result from text context search."""
20
+
21
+ place_id: str
22
+ name: str
23
+ category: str
24
+ rating: float
25
+ similarity: float
26
+ description: str = ""
27
+ source_text: str = ""
28
+ content_type: str = ""
29
+
30
+
31
+ # Category keywords for intent detection
32
+ CATEGORY_KEYWORDS = {
33
+ 'cafe': ['cafe', 'cà phê', 'coffee', 'caphe', 'caphê'],
34
+ 'pho': ['phở', 'pho'],
35
+ 'banh_mi': ['bánh mì', 'banh mi', 'bread'],
36
+ 'seafood': ['hải sản', 'hai san', 'seafood', 'cá', 'tôm', 'cua'],
37
+ 'restaurant': ['nhà hàng', 'restaurant', 'quán ăn', 'ăn'],
38
+ 'bar': ['bar', 'pub', 'cocktail', 'beer', 'bia'],
39
+ 'hotel': ['hotel', 'khách sạn', 'resort', 'villa'],
40
+ 'japanese': ['nhật', 'japan', 'sushi', 'ramen'],
41
+ 'korean': ['hàn', 'korea', 'bbq'],
42
+ }
43
+
44
+ CATEGORY_TO_DB = {
45
+ 'cafe': ['Coffee shop', 'Cafe', 'Coffee house', 'Espresso bar'],
46
+ 'pho': ['Pho restaurant', 'Bistro', 'Restaurant', 'Vietnamese restaurant'],
47
+ 'banh_mi': ['Bakery', 'Tiffin center', 'Restaurant'],
48
+ 'seafood': ['Seafood restaurant', 'Restaurant', 'Asian restaurant'],
49
+ 'restaurant': ['Restaurant', 'Vietnamese restaurant', 'Asian restaurant'],
50
+ 'bar': ['Bar', 'Cocktail bar', 'Pub', 'Night club', 'Live music bar'],
51
+ 'hotel': ['Hotel', 'Resort', 'Apartment', 'Villa', 'Holiday apartment rental'],
52
+ 'japanese': ['Japanese restaurant', 'Sushi restaurant', 'Ramen restaurant'],
53
+ 'korean': ['Korean restaurant', 'Korean barbecue restaurant'],
54
+ }
55
+
56
+
57
+ # Tool definition for agent
58
+ TOOL_DEFINITION = {
59
+ "name": "retrieve_context_text",
60
+ "description": """Tìm kiếm thông tin địa điểm dựa trên văn bản, mô tả, đánh giá.
61
+
62
+ Dùng khi:
63
+ - Người dùng hỏi về menu, review, mô tả địa điểm
64
+ - Tìm kiếm theo đặc điểm: "quán cafe view đẹp", "phở ngon giá rẻ"
65
+ - Tìm theo không khí: "nơi lãng mạn", "chỗ yên tĩnh làm việc"
66
+
67
+ Hỗ trợ: Vietnamese + English""",
68
+ "parameters": {
69
+ "query": "Câu query tìm kiếm tự nhiên (VD: 'quán phở nước dùng đậm đà')",
70
+ "limit": "Số kết quả tối đa (mặc định 10)",
71
+ },
72
+ }
73
+
74
+
75
+ def detect_category_intent(query: str) -> Optional[str]:
76
+ """Detect if query is asking for specific category."""
77
+ query_lower = query.lower()
78
+
79
+ for category, keywords in CATEGORY_KEYWORDS.items():
80
+ if any(kw in query_lower for kw in keywords):
81
+ return category
82
+ return None
83
+
84
+
85
+ async def retrieve_context_text(
86
+ db: AsyncSession,
87
+ query: str,
88
+ limit: int = 10,
89
+ threshold: float = 0.3,
90
+ ) -> list[TextSearchResult]:
91
+ """
92
+ Semantic search in text descriptions using pgvector.
93
+
94
+ Uses place_text_embeddings table with JOIN to places_metadata.
95
+
96
+ Args:
97
+ db: Database session
98
+ query: Natural language query
99
+ limit: Maximum results
100
+ threshold: Minimum similarity threshold
101
+
102
+ Returns:
103
+ List of places with similarity scores
104
+ """
105
+ # Generate embedding for query
106
+ query_embedding = await embedding_client.embed_text(query)
107
+
108
+ # Convert to PostgreSQL vector format
109
+ embedding_str = "[" + ",".join(str(x) for x in query_embedding) + "]"
110
+
111
+ # Detect category intent for boosting
112
+ category_intent = detect_category_intent(query)
113
+ category_filter = CATEGORY_TO_DB.get(category_intent, []) if category_intent else []
114
+
115
+ # Search with JOIN to places_metadata
116
+ # Note: Use format string for embedding since SQLAlchemy param binding
117
+ # doesn't work correctly with ::vector type casting
118
+ sql = text(f"""
119
+ SELECT DISTINCT ON (e.place_id)
120
+ e.place_id,
121
+ e.content_type,
122
+ e.source_text,
123
+ 1 - (e.embedding <=> '{embedding_str}'::vector) as similarity,
124
+ m.name,
125
+ m.category,
126
+ m.rating,
127
+ m.raw_data
128
+ FROM place_text_embeddings e
129
+ JOIN places_metadata m ON e.place_id = m.place_id
130
+ WHERE 1 - (e.embedding <=> '{embedding_str}'::vector) > :threshold
131
+ AND m.name IS NOT NULL
132
+ AND m.name != ''
133
+ ORDER BY e.place_id, e.embedding <=> '{embedding_str}'::vector
134
+ """)
135
+
136
+ results = await db.execute(sql, {
137
+ "threshold": threshold,
138
+ })
139
+
140
+ rows = results.fetchall()
141
+
142
+ # Process and score results with category boosting
143
+ scored_results = []
144
+ for r in rows:
145
+ score = float(r.similarity)
146
+
147
+ # Category boost (15%)
148
+ if category_filter and r.category in category_filter:
149
+ score += 0.15
150
+
151
+ # Rating boost (5% for >= 4.5)
152
+ if r.rating and r.rating >= 4.5:
153
+ score += 0.05
154
+ elif r.rating and r.rating >= 4.0:
155
+ score += 0.02
156
+
157
+ raw_data = r.raw_data or {}
158
+
159
+ scored_results.append((score, TextSearchResult(
160
+ place_id=r.place_id,
161
+ name=r.name or '',
162
+ category=r.category or '',
163
+ rating=float(r.rating) if r.rating else 0.0,
164
+ similarity=round(score, 4),
165
+ description=raw_data.get('description', '')[:300] if isinstance(raw_data, dict) else '',
166
+ source_text=r.source_text[:300] if r.source_text else '',
167
+ content_type=r.content_type or '',
168
+ )))
169
+
170
+ # Sort by score and limit
171
+ scored_results.sort(key=lambda x: x[0], reverse=True)
172
+
173
+ return [r for _, r in scored_results[:limit]]
app/mcp/tools/visual_tool.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visual RAG Tool - Image similarity search using local SigLIP embeddings.
2
+
3
+ Schema: place_image_embeddings (place_id, embedding, image_url, metadata)
4
+ places_metadata (place_id, name, category, rating, raw_data)
5
+
6
+ Uses local SigLIP model (ViT-B-16-SigLIP) for generating 768-dim image embeddings.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from sqlalchemy import text
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from app.shared.integrations.siglip_client import get_siglip_client
16
+
17
+
18
+ @dataclass
19
+ class ImageSearchResult:
20
+ """Result from visual similarity search."""
21
+
22
+ place_id: str
23
+ name: str
24
+ category: str
25
+ rating: float
26
+ similarity: float
27
+ matched_images: int = 1
28
+ image_url: str = ""
29
+
30
+
31
+ # Tool definition for agent
32
+ TOOL_DEFINITION = {
33
+ "name": "retrieve_similar_visuals",
34
+ "description": """Tìm địa điểm có hình ảnh tương tự.
35
+
36
+ Dùng khi:
37
+ - Người dùng gửi ảnh và muốn tìm nơi tương tự
38
+ - Mô tả về không gian, decor, view
39
+ - "Tìm quán có view như này", "Nơi nào có không gian giống ảnh này"
40
+ """,
41
+ "parameters": {
42
+ "image_url": "URL của ảnh cần tìm kiếm tương tự",
43
+ "limit": "Số kết quả tối đa (mặc định 10)",
44
+ },
45
+ }
46
+
47
+
48
+ async def retrieve_similar_visuals(
49
+ db: AsyncSession,
50
+ image_url: str | None = None,
51
+ image_bytes: bytes | None = None,
52
+ limit: int = 10,
53
+ threshold: float = 0.2,
54
+ ) -> list[ImageSearchResult]:
55
+ """
56
+ Visual similarity search using local SigLIP embeddings.
57
+
58
+ Uses place_image_embeddings table with JOIN to places_metadata.
59
+
60
+ Args:
61
+ db: Database session
62
+ image_url: URL of the query image
63
+ image_bytes: Raw image bytes (alternative to URL)
64
+ limit: Maximum results
65
+ threshold: Minimum similarity threshold
66
+
67
+ Returns:
68
+ List of places with visual similarity scores
69
+ """
70
+ # Get SigLIP client (singleton)
71
+ siglip = get_siglip_client()
72
+
73
+ # Generate image embedding using local model
74
+ if image_bytes:
75
+ image_embedding = siglip.embed_image_bytes(image_bytes)
76
+ elif image_url:
77
+ image_embedding = siglip.embed_image_url(image_url)
78
+ else:
79
+ return []
80
+
81
+ if image_embedding is None:
82
+ return []
83
+
84
+ # Convert numpy array to PostgreSQL vector format
85
+ embedding_str = "[" + ",".join(str(x) for x in image_embedding.tolist()) + "]"
86
+
87
+ # Search with JOIN to places_metadata
88
+ sql = text(f"""
89
+ SELECT
90
+ e.place_id,
91
+ e.image_url,
92
+ 1 - (e.embedding <=> '{embedding_str}'::vector) as similarity,
93
+ m.name,
94
+ m.category,
95
+ m.rating
96
+ FROM place_image_embeddings e
97
+ JOIN places_metadata m ON e.place_id = m.place_id
98
+ WHERE 1 - (e.embedding <=> '{embedding_str}'::vector) > :threshold
99
+ AND m.name IS NOT NULL
100
+ AND m.name != ''
101
+ ORDER BY e.embedding <=> '{embedding_str}'::vector
102
+ LIMIT 100
103
+ """)
104
+
105
+ results = await db.execute(sql, {
106
+ "threshold": threshold,
107
+ })
108
+
109
+ rows = results.fetchall()
110
+
111
+ # Aggregate by place (multiple images per place)
112
+ place_scores: dict = {}
113
+
114
+ for r in rows:
115
+ pid = r.place_id
116
+
117
+ if pid not in place_scores:
118
+ place_scores[pid] = {
119
+ 'total_score': 0.0,
120
+ 'count': 0,
121
+ 'data': r,
122
+ 'best_image': r.image_url,
123
+ }
124
+
125
+ place_scores[pid]['total_score'] += float(r.similarity)
126
+ place_scores[pid]['count'] += 1
127
+
128
+ # Keep best matching image
129
+ if float(r.similarity) > place_scores[pid]['total_score'] / place_scores[pid]['count']:
130
+ place_scores[pid]['best_image'] = r.image_url
131
+
132
+ # Sort by average similarity
133
+ sorted_places = sorted(
134
+ place_scores.items(),
135
+ key=lambda x: x[1]['total_score'] / x[1]['count'],
136
+ reverse=True
137
+ )[:limit]
138
+
139
+ # Build results
140
+ return [
141
+ ImageSearchResult(
142
+ place_id=pid,
143
+ name=data['data'].name or '',
144
+ category=data['data'].category or '',
145
+ rating=float(data['data'].rating or 0),
146
+ similarity=round(data['total_score'] / data['count'], 4),
147
+ matched_images=data['count'],
148
+ image_url=data['best_image'] or '',
149
+ )
150
+ for pid, data in sorted_places
151
+ ]
152
+
153
+
154
+ async def search_by_image_url(
155
+ db: AsyncSession,
156
+ image_url: str,
157
+ limit: int = 10,
158
+ ) -> list[ImageSearchResult]:
159
+ """Search places by image URL."""
160
+ return await retrieve_similar_visuals(db=db, image_url=image_url, limit=limit)
161
+
162
+
163
+ async def search_by_image_bytes(
164
+ db: AsyncSession,
165
+ image_bytes: bytes,
166
+ limit: int = 10,
167
+ ) -> list[ImageSearchResult]:
168
+ """Search places by uploading image bytes."""
169
+ return await retrieve_similar_visuals(db=db, image_bytes=image_bytes, limit=limit)
app/planner/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Trip Planner Package - Plan your Da Nang trip with TSP optimization."""
app/planner/models.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trip Planner Models - Pydantic schemas for Plan and PlanItem."""
2
+
3
+ from datetime import datetime
4
+ from pydantic import BaseModel, Field
5
+ from typing import Optional
6
+
7
+
8
+ class PlaceInput(BaseModel):
9
+ """Place data for adding to plan."""
10
+
11
+ place_id: str = Field(..., description="Unique place identifier")
12
+ name: str = Field(..., description="Place name")
13
+ category: str = Field(default="", description="Place category")
14
+ lat: float = Field(..., description="Latitude")
15
+ lng: float = Field(..., description="Longitude")
16
+ rating: Optional[float] = Field(None, description="Rating 0-5")
17
+ description: Optional[str] = Field(None, description="Place description")
18
+
19
+
20
+ class PlanItem(BaseModel):
21
+ """A place in the plan with order and metadata."""
22
+
23
+ item_id: str = Field(..., description="Unique item identifier")
24
+ place_id: str = Field(..., description="Reference to place")
25
+ name: str = Field(..., description="Place name")
26
+ category: str = Field(default="", description="Category")
27
+ lat: float = Field(..., description="Latitude")
28
+ lng: float = Field(..., description="Longitude")
29
+ order: int = Field(..., description="Order in plan (1-indexed)")
30
+ added_at: datetime = Field(default_factory=datetime.now)
31
+ notes: Optional[str] = Field(None, description="User notes")
32
+ rating: Optional[float] = None
33
+ distance_from_prev_km: Optional[float] = Field(None, description="Distance from previous item")
34
+
35
+
36
+ class Plan(BaseModel):
37
+ """A trip plan containing multiple places."""
38
+
39
+ plan_id: str = Field(..., description="Unique plan identifier")
40
+ user_id: str = Field(..., description="Owner user ID")
41
+ name: str = Field(default="My Trip", description="Plan name")
42
+ items: list[PlanItem] = Field(default_factory=list)
43
+ created_at: datetime = Field(default_factory=datetime.now)
44
+ updated_at: datetime = Field(default_factory=datetime.now)
45
+ total_distance_km: Optional[float] = Field(None, description="Total route distance")
46
+ estimated_duration_min: Optional[int] = Field(None, description="Estimated duration")
47
+ is_optimized: bool = Field(default=False, description="Route has been optimized")
48
+
49
+
50
+ # Request/Response Models
51
+
52
+ class CreatePlanRequest(BaseModel):
53
+ """Request to create a new plan."""
54
+
55
+ name: str = Field(default="My Trip", description="Plan name")
56
+
57
+
58
+ class CreatePlanResponse(BaseModel):
59
+ """Response after creating a plan."""
60
+
61
+ plan_id: str
62
+ name: str
63
+ message: str
64
+
65
+
66
+ class AddPlaceRequest(BaseModel):
67
+ """Request to add a place to plan."""
68
+
69
+ place: PlaceInput
70
+ notes: Optional[str] = None
71
+
72
+
73
+ class ReorderRequest(BaseModel):
74
+ """Request to reorder places in plan."""
75
+
76
+ new_order: list[str] = Field(..., description="List of item_ids in new order")
77
+
78
+
79
+ class ReplaceRequest(BaseModel):
80
+ """Request to replace a place."""
81
+
82
+ new_place: PlaceInput
83
+
84
+
85
+ class OptimizeResponse(BaseModel):
86
+ """Response from route optimization."""
87
+
88
+ plan_id: str
89
+ items: list[PlanItem]
90
+ total_distance_km: float
91
+ estimated_duration_min: int
92
+ distance_saved_km: Optional[float] = None
93
+ message: str
94
+
95
+
96
+ class PlanResponse(BaseModel):
97
+ """Full plan response."""
98
+
99
+ plan: Plan
100
+ message: str
app/planner/router.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trip Planner Router - API endpoints for plan management."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+
5
+ from app.planner.models import (
6
+ Plan,
7
+ PlanItem,
8
+ CreatePlanRequest,
9
+ CreatePlanResponse,
10
+ AddPlaceRequest,
11
+ ReorderRequest,
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"])
20
+
21
+
22
+ @router.post(
23
+ "/create",
24
+ response_model=CreatePlanResponse,
25
+ summary="Create a new trip plan",
26
+ description="Creates an empty trip plan for the user.",
27
+ )
28
+ async def create_plan(
29
+ request: CreatePlanRequest,
30
+ user_id: str = Query(default="anonymous", description="User ID"),
31
+ ) -> CreatePlanResponse:
32
+ """Create a new empty plan."""
33
+ plan = planner_service.create_plan(user_id=user_id, name=request.name)
34
+
35
+ return CreatePlanResponse(
36
+ plan_id=plan.plan_id,
37
+ name=plan.name,
38
+ message=f"Created plan '{plan.name}'",
39
+ )
40
+
41
+
42
+ @router.get(
43
+ "/{plan_id}",
44
+ response_model=PlanResponse,
45
+ summary="Get a trip plan",
46
+ description="Retrieves a plan by ID.",
47
+ )
48
+ async def get_plan(
49
+ plan_id: str,
50
+ user_id: str = Query(default="anonymous", description="User ID"),
51
+ ) -> PlanResponse:
52
+ """Get a plan by ID."""
53
+ plan = planner_service.get_plan(user_id=user_id, plan_id=plan_id)
54
+
55
+ if not plan:
56
+ raise HTTPException(status_code=404, detail="Plan not found")
57
+
58
+ return PlanResponse(plan=plan, message="Plan retrieved")
59
+
60
+
61
+ @router.get(
62
+ "/user/plans",
63
+ response_model=list[Plan],
64
+ summary="Get all user plans",
65
+ description="Retrieves all plans for a user.",
66
+ )
67
+ async def get_user_plans(
68
+ user_id: str = Query(default="anonymous", description="User ID"),
69
+ ) -> list[Plan]:
70
+ """Get all plans for a user."""
71
+ return planner_service.get_user_plans(user_id)
72
+
73
+
74
+ @router.post(
75
+ "/{plan_id}/add",
76
+ response_model=PlanItem,
77
+ summary="Add a place to plan",
78
+ description="Adds a new place to the end of the plan.",
79
+ )
80
+ async def add_place(
81
+ plan_id: str,
82
+ request: AddPlaceRequest,
83
+ user_id: str = Query(default="anonymous", description="User ID"),
84
+ ) -> PlanItem:
85
+ """Add a place to the plan."""
86
+ # Try to find existing plan or create default
87
+ plan = planner_service.get_plan(user_id, plan_id)
88
+ if not plan:
89
+ # Auto-create plan if it doesn't exist
90
+ plan = planner_service.create_plan(user_id=user_id)
91
+
92
+ item = planner_service.add_place(
93
+ user_id=user_id,
94
+ plan_id=plan.plan_id,
95
+ place=request.place,
96
+ notes=request.notes,
97
+ )
98
+
99
+ if not item:
100
+ raise HTTPException(status_code=404, detail="Plan not found")
101
+
102
+ return item
103
+
104
+
105
+ @router.delete(
106
+ "/{plan_id}/remove/{item_id}",
107
+ summary="Remove a place from plan",
108
+ description="Removes a place from the plan by item ID.",
109
+ )
110
+ async def remove_place(
111
+ plan_id: str,
112
+ item_id: str,
113
+ user_id: str = Query(default="anonymous", description="User ID"),
114
+ ) -> dict:
115
+ """Remove a place from the plan."""
116
+ success = planner_service.remove_place(
117
+ user_id=user_id,
118
+ plan_id=plan_id,
119
+ item_id=item_id,
120
+ )
121
+
122
+ if not success:
123
+ raise HTTPException(status_code=404, detail="Item not found")
124
+
125
+ return {"status": "success", "message": f"Removed item {item_id}"}
126
+
127
+
128
+ @router.put(
129
+ "/{plan_id}/reorder",
130
+ response_model=PlanResponse,
131
+ summary="Reorder places in plan",
132
+ description="Manually reorder places by providing new order of item IDs.",
133
+ )
134
+ async def reorder_places(
135
+ plan_id: str,
136
+ request: ReorderRequest,
137
+ user_id: str = Query(default="anonymous", description="User ID"),
138
+ ) -> PlanResponse:
139
+ """Reorder places in the plan."""
140
+ success = planner_service.reorder_places(
141
+ user_id=user_id,
142
+ plan_id=plan_id,
143
+ new_order=request.new_order,
144
+ )
145
+
146
+ if not success:
147
+ raise HTTPException(
148
+ status_code=400,
149
+ detail="Invalid order. Ensure all item IDs are included."
150
+ )
151
+
152
+ plan = planner_service.get_plan(user_id, plan_id)
153
+ return PlanResponse(plan=plan, message="Plan reordered")
154
+
155
+
156
+ @router.post(
157
+ "/{plan_id}/optimize",
158
+ response_model=OptimizeResponse,
159
+ summary="Optimize route (TSP)",
160
+ description="""
161
+ Optimizes the route using TSP (Traveling Salesman Problem) algorithm.
162
+
163
+ Uses Nearest Neighbor heuristic with 2-opt improvement.
164
+ Minimizes total travel distance.
165
+ """,
166
+ )
167
+ async def optimize_route(
168
+ plan_id: str,
169
+ user_id: str = Query(default="anonymous", description="User ID"),
170
+ start_index: int = Query(default=0, description="Index of starting place"),
171
+ ) -> OptimizeResponse:
172
+ """Optimize the route using TSP."""
173
+ # Get original distance for comparison
174
+ plan = planner_service.get_plan(user_id, plan_id)
175
+ if not plan:
176
+ raise HTTPException(status_code=404, detail="Plan not found")
177
+
178
+ if len(plan.items) < 2:
179
+ return OptimizeResponse(
180
+ plan_id=plan_id,
181
+ items=plan.items,
182
+ total_distance_km=0,
183
+ estimated_duration_min=0,
184
+ message="Need at least 2 places to optimize",
185
+ )
186
+
187
+ original_distance = plan.total_distance_km or 0
188
+
189
+ # Optimize
190
+ optimized_plan = planner_service.optimize_plan(
191
+ user_id=user_id,
192
+ plan_id=plan_id,
193
+ start_index=start_index,
194
+ )
195
+
196
+ if not optimized_plan:
197
+ raise HTTPException(status_code=404, detail="Plan not found")
198
+
199
+ # Calculate savings
200
+ distance_saved = original_distance - optimized_plan.total_distance_km if original_distance > 0 else None
201
+
202
+ return OptimizeResponse(
203
+ plan_id=plan_id,
204
+ items=optimized_plan.items,
205
+ total_distance_km=optimized_plan.total_distance_km,
206
+ estimated_duration_min=optimized_plan.estimated_duration_min,
207
+ distance_saved_km=round(distance_saved, 2) if distance_saved else None,
208
+ message=f"Route optimized! Total: {optimized_plan.total_distance_km}km, ~{optimized_plan.estimated_duration_min}min",
209
+ )
210
+
211
+
212
+ @router.put(
213
+ "/{plan_id}/replace/{item_id}",
214
+ response_model=PlanItem,
215
+ summary="Replace a place in plan",
216
+ description="Replaces an existing place with a new one.",
217
+ )
218
+ async def replace_place(
219
+ plan_id: str,
220
+ item_id: str,
221
+ request: ReplaceRequest,
222
+ user_id: str = Query(default="anonymous", description="User ID"),
223
+ ) -> PlanItem:
224
+ """Replace a place in the plan."""
225
+ item = planner_service.replace_place(
226
+ user_id=user_id,
227
+ plan_id=plan_id,
228
+ item_id=item_id,
229
+ new_place=request.new_place,
230
+ )
231
+
232
+ if not item:
233
+ raise HTTPException(status_code=404, detail="Item not found")
234
+
235
+ return item
236
+
237
+
238
+ @router.delete(
239
+ "/{plan_id}",
240
+ summary="Delete a plan",
241
+ description="Deletes an entire plan.",
242
+ )
243
+ async def delete_plan(
244
+ plan_id: str,
245
+ user_id: str = Query(default="anonymous", description="User ID"),
246
+ ) -> dict:
247
+ """Delete a plan."""
248
+ success = planner_service.delete_plan(user_id=user_id, plan_id=plan_id)
249
+
250
+ if not success:
251
+ raise HTTPException(status_code=404, detail="Plan not found")
252
+
253
+ return {"status": "success", "message": f"Deleted plan {plan_id}"}
app/planner/service.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trip Planner Service - Business logic and in-memory storage."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import Optional
6
+ from collections import defaultdict
7
+
8
+ from app.planner.models import Plan, PlanItem, PlaceInput
9
+ from app.planner.tsp import optimize_route, estimate_duration, haversine
10
+
11
+
12
+ class PlannerService:
13
+ """
14
+ Service for managing trip plans.
15
+
16
+ Uses in-memory storage per user_id (session-based).
17
+ Plans persist during server lifetime.
18
+ """
19
+
20
+ def __init__(self):
21
+ """Initialize with in-memory storage."""
22
+ # {user_id: {plan_id: Plan}}
23
+ self._plans: dict[str, dict[str, Plan]] = defaultdict(dict)
24
+
25
+ def create_plan(self, user_id: str, name: str = "My Trip") -> Plan:
26
+ """
27
+ Create a new empty plan.
28
+
29
+ Args:
30
+ user_id: Owner's user ID
31
+ name: Plan name
32
+
33
+ Returns:
34
+ Created Plan object
35
+ """
36
+ plan_id = f"plan_{uuid.uuid4().hex[:12]}"
37
+
38
+ plan = Plan(
39
+ plan_id=plan_id,
40
+ user_id=user_id,
41
+ name=name,
42
+ items=[],
43
+ created_at=datetime.now(),
44
+ updated_at=datetime.now(),
45
+ )
46
+
47
+ self._plans[user_id][plan_id] = plan
48
+ return plan
49
+
50
+ def get_plan(self, user_id: str, plan_id: str) -> Optional[Plan]:
51
+ """Get a plan by ID."""
52
+ return self._plans.get(user_id, {}).get(plan_id)
53
+
54
+ def get_user_plans(self, user_id: str) -> list[Plan]:
55
+ """Get all plans for a user."""
56
+ return list(self._plans.get(user_id, {}).values())
57
+
58
+ def get_or_create_default_plan(self, user_id: str) -> Plan:
59
+ """Get user's first plan or create one."""
60
+ plans = self.get_user_plans(user_id)
61
+ if plans:
62
+ return plans[0]
63
+ return self.create_plan(user_id)
64
+
65
+ def add_place(
66
+ self,
67
+ user_id: str,
68
+ plan_id: str,
69
+ place: PlaceInput,
70
+ notes: Optional[str] = None
71
+ ) -> Optional[PlanItem]:
72
+ """
73
+ Add a place to a plan.
74
+
75
+ Args:
76
+ user_id: Owner's user ID
77
+ plan_id: Target plan ID
78
+ place: Place data
79
+ notes: Optional user notes
80
+
81
+ Returns:
82
+ Created PlanItem or None if plan not found
83
+ """
84
+ plan = self.get_plan(user_id, plan_id)
85
+ if not plan:
86
+ return None
87
+
88
+ # Create new item
89
+ item_id = f"item_{uuid.uuid4().hex[:8]}"
90
+ order = len(plan.items) + 1
91
+
92
+ item = PlanItem(
93
+ item_id=item_id,
94
+ place_id=place.place_id,
95
+ name=place.name,
96
+ category=place.category,
97
+ lat=place.lat,
98
+ lng=place.lng,
99
+ order=order,
100
+ added_at=datetime.now(),
101
+ notes=notes,
102
+ rating=place.rating,
103
+ )
104
+
105
+ plan.items.append(item)
106
+ plan.updated_at = datetime.now()
107
+ plan.is_optimized = False
108
+
109
+ # Update distance if there are multiple items
110
+ self._update_distances(plan)
111
+
112
+ return item
113
+
114
+ def remove_place(self, user_id: str, plan_id: str, item_id: str) -> bool:
115
+ """
116
+ Remove a place from plan.
117
+
118
+ Returns:
119
+ True if removed, False if not found
120
+ """
121
+ plan = self.get_plan(user_id, plan_id)
122
+ if not plan:
123
+ return False
124
+
125
+ # Find and remove item
126
+ original_len = len(plan.items)
127
+ plan.items = [item for item in plan.items if item.item_id != item_id]
128
+
129
+ if len(plan.items) < original_len:
130
+ # Reorder remaining items
131
+ for i, item in enumerate(plan.items):
132
+ item.order = i + 1
133
+
134
+ plan.updated_at = datetime.now()
135
+ plan.is_optimized = False
136
+ self._update_distances(plan)
137
+ return True
138
+
139
+ return False
140
+
141
+ def reorder_places(self, user_id: str, plan_id: str, new_order: list[str]) -> bool:
142
+ """
143
+ Reorder places in plan.
144
+
145
+ Args:
146
+ user_id: Owner's user ID
147
+ plan_id: Plan ID
148
+ new_order: List of item_ids in new order
149
+
150
+ Returns:
151
+ True if reordered, False if invalid
152
+ """
153
+ plan = self.get_plan(user_id, plan_id)
154
+ if not plan:
155
+ return False
156
+
157
+ # Validate all item_ids exist
158
+ existing_ids = {item.item_id for item in plan.items}
159
+ if set(new_order) != existing_ids:
160
+ return False
161
+
162
+ # Create id -> item mapping
163
+ item_map = {item.item_id: item for item in plan.items}
164
+
165
+ # Reorder
166
+ plan.items = [item_map[item_id] for item_id in new_order]
167
+ for i, item in enumerate(plan.items):
168
+ item.order = i + 1
169
+
170
+ plan.updated_at = datetime.now()
171
+ plan.is_optimized = False
172
+ self._update_distances(plan)
173
+
174
+ return True
175
+
176
+ def replace_place(
177
+ self,
178
+ user_id: str,
179
+ plan_id: str,
180
+ item_id: str,
181
+ new_place: PlaceInput
182
+ ) -> Optional[PlanItem]:
183
+ """
184
+ Replace a place in plan with a new one.
185
+
186
+ Args:
187
+ user_id: Owner's user ID
188
+ plan_id: Plan ID
189
+ item_id: Item to replace
190
+ new_place: New place data
191
+
192
+ Returns:
193
+ Updated PlanItem or None if not found
194
+ """
195
+ plan = self.get_plan(user_id, plan_id)
196
+ if not plan:
197
+ return None
198
+
199
+ # Find item to replace
200
+ for i, item in enumerate(plan.items):
201
+ if item.item_id == item_id:
202
+ # Update with new place data
203
+ item.place_id = new_place.place_id
204
+ item.name = new_place.name
205
+ item.category = new_place.category
206
+ item.lat = new_place.lat
207
+ item.lng = new_place.lng
208
+ item.rating = new_place.rating
209
+
210
+ plan.updated_at = datetime.now()
211
+ plan.is_optimized = False
212
+ self._update_distances(plan)
213
+
214
+ return item
215
+
216
+ return None
217
+
218
+ def optimize_plan(self, user_id: str, plan_id: str, start_index: int = 0) -> Optional[Plan]:
219
+ """
220
+ Optimize the route for a plan using TSP.
221
+
222
+ Args:
223
+ user_id: Owner's user ID
224
+ plan_id: Plan ID
225
+ start_index: Index of starting place
226
+
227
+ Returns:
228
+ Optimized Plan or None if not found
229
+ """
230
+ plan = self.get_plan(user_id, plan_id)
231
+ if not plan or len(plan.items) < 2:
232
+ return plan
233
+
234
+ # Calculate original distance for comparison
235
+ original_distance = plan.total_distance_km or 0
236
+
237
+ # Convert items to places format for TSP
238
+ places = [
239
+ {'lat': item.lat, 'lng': item.lng}
240
+ for item in plan.items
241
+ ]
242
+
243
+ # Run TSP optimization
244
+ optimized_order, total_distance = optimize_route(places, start_index)
245
+
246
+ # Reorder items according to optimized order
247
+ original_items = plan.items.copy()
248
+ plan.items = [original_items[i] for i in optimized_order]
249
+
250
+ # Update orders
251
+ for i, item in enumerate(plan.items):
252
+ item.order = i + 1
253
+
254
+ # Update plan metadata
255
+ plan.total_distance_km = total_distance
256
+ plan.estimated_duration_min = estimate_duration(total_distance)
257
+ plan.is_optimized = True
258
+ plan.updated_at = datetime.now()
259
+
260
+ # Calculate distances between consecutive items
261
+ self._update_distances(plan)
262
+
263
+ return plan
264
+
265
+ def _update_distances(self, plan: Plan) -> None:
266
+ """Update total distance and per-item distances."""
267
+ if len(plan.items) < 2:
268
+ plan.total_distance_km = 0
269
+ plan.estimated_duration_min = 0
270
+ if plan.items:
271
+ plan.items[0].distance_from_prev_km = None
272
+ return
273
+
274
+ total = 0.0
275
+ plan.items[0].distance_from_prev_km = None
276
+
277
+ for i in range(1, len(plan.items)):
278
+ prev = plan.items[i - 1]
279
+ curr = plan.items[i]
280
+
281
+ dist = haversine(prev.lat, prev.lng, curr.lat, curr.lng)
282
+ curr.distance_from_prev_km = round(dist, 2)
283
+ total += dist
284
+
285
+ plan.total_distance_km = round(total, 2)
286
+ plan.estimated_duration_min = estimate_duration(total)
287
+
288
+ def delete_plan(self, user_id: str, plan_id: str) -> bool:
289
+ """Delete a plan."""
290
+ if plan_id in self._plans.get(user_id, {}):
291
+ del self._plans[user_id][plan_id]
292
+ return True
293
+ return False
294
+
295
+
296
+ # Global singleton instance
297
+ planner_service = PlannerService()
app/planner/tsp.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TSP Algorithm - Nearest Neighbor + 2-opt optimization.
2
+
3
+ Optimizes route for a list of places to minimize total travel distance.
4
+ Uses Haversine formula for distance calculation.
5
+ """
6
+
7
+ from math import radians, sin, cos, sqrt, atan2
8
+
9
+
10
+ def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
11
+ """
12
+ Calculate distance between 2 points on Earth in kilometers.
13
+
14
+ Uses the Haversine formula for great-circle distance.
15
+
16
+ Args:
17
+ lat1, lng1: First point coordinates
18
+ lat2, lng2: Second point coordinates
19
+
20
+ Returns:
21
+ Distance in kilometers
22
+ """
23
+ R = 6371 # Earth's radius in km
24
+
25
+ dlat = radians(lat2 - lat1)
26
+ dlng = radians(lng2 - lng1)
27
+
28
+ a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlng/2)**2
29
+ c = 2 * atan2(sqrt(a), sqrt(1-a))
30
+
31
+ return R * c
32
+
33
+
34
+ def calculate_distance_matrix(places: list[dict]) -> list[list[float]]:
35
+ """
36
+ Build NxN distance matrix for all place pairs.
37
+
38
+ Args:
39
+ places: List of places with 'lat' and 'lng' keys
40
+
41
+ Returns:
42
+ NxN matrix where matrix[i][j] is distance from place i to j
43
+ """
44
+ n = len(places)
45
+ matrix = [[0.0] * n for _ in range(n)]
46
+
47
+ for i in range(n):
48
+ for j in range(n):
49
+ if i != j:
50
+ matrix[i][j] = haversine(
51
+ places[i]['lat'], places[i]['lng'],
52
+ places[j]['lat'], places[j]['lng']
53
+ )
54
+
55
+ return matrix
56
+
57
+
58
+ def nearest_neighbor(matrix: list[list[float]], start: int = 0) -> list[int]:
59
+ """
60
+ Greedy nearest neighbor heuristic for TSP.
61
+
62
+ Builds tour by always visiting the closest unvisited city.
63
+ O(n²) complexity.
64
+
65
+ Args:
66
+ matrix: Distance matrix
67
+ start: Starting city index
68
+
69
+ Returns:
70
+ List of city indices in visit order
71
+ """
72
+ n = len(matrix)
73
+ visited = [False] * n
74
+ tour = [start]
75
+ visited[start] = True
76
+
77
+ for _ in range(n - 1):
78
+ current = tour[-1]
79
+ nearest = -1
80
+ min_dist = float('inf')
81
+
82
+ for j in range(n):
83
+ if not visited[j] and matrix[current][j] < min_dist:
84
+ min_dist = matrix[current][j]
85
+ nearest = j
86
+
87
+ if nearest != -1:
88
+ tour.append(nearest)
89
+ visited[nearest] = True
90
+
91
+ return tour
92
+
93
+
94
+ def two_opt(tour: list[int], matrix: list[list[float]]) -> list[int]:
95
+ """
96
+ 2-opt local search improvement for TSP.
97
+
98
+ Iteratively reverses segments to reduce total distance.
99
+ Continues until no improvement is found.
100
+
101
+ Args:
102
+ tour: Initial tour (list of indices)
103
+ matrix: Distance matrix
104
+
105
+ Returns:
106
+ Improved tour
107
+ """
108
+ n = len(tour)
109
+ if n < 4:
110
+ return tour
111
+
112
+ improved = True
113
+ tour = tour.copy()
114
+
115
+ while improved:
116
+ improved = False
117
+ for i in range(1, n - 1):
118
+ for j in range(i + 1, n):
119
+ if j == n - 1:
120
+ # Handle edge case for last element
121
+ d1 = matrix[tour[i-1]][tour[i]] + matrix[tour[j]][tour[0]]
122
+ d2 = matrix[tour[i-1]][tour[j]] + matrix[tour[i]][tour[0]]
123
+ else:
124
+ d1 = matrix[tour[i-1]][tour[i]] + matrix[tour[j]][tour[j+1]]
125
+ d2 = matrix[tour[i-1]][tour[j]] + matrix[tour[i]][tour[j+1]]
126
+
127
+ if d2 < d1 - 0.0001: # Small epsilon to avoid floating point issues
128
+ # Reverse segment [i, j]
129
+ tour[i:j+1] = tour[i:j+1][::-1]
130
+ improved = True
131
+
132
+ return tour
133
+
134
+
135
+ def calculate_total_distance(tour: list[int], matrix: list[list[float]]) -> float:
136
+ """Calculate total distance of a tour."""
137
+ total = 0.0
138
+ for i in range(len(tour) - 1):
139
+ total += matrix[tour[i]][tour[i+1]]
140
+ return total
141
+
142
+
143
+ def optimize_route(places: list[dict], start_index: int = 0) -> tuple[list[int], float]:
144
+ """
145
+ Main TSP optimization function.
146
+
147
+ Uses Nearest Neighbor heuristic followed by 2-opt improvement.
148
+ Suitable for up to ~50 places.
149
+
150
+ Args:
151
+ places: List of places with 'lat' and 'lng' keys
152
+ start_index: Index of starting place (default: first place)
153
+
154
+ Returns:
155
+ Tuple of (optimized_order, total_distance_km)
156
+ - optimized_order: List of indices in visit order
157
+ - total_distance_km: Total route distance
158
+ """
159
+ n = len(places)
160
+
161
+ # Handle edge cases
162
+ if n == 0:
163
+ return [], 0.0
164
+ if n == 1:
165
+ return [0], 0.0
166
+ if n == 2:
167
+ dist = haversine(
168
+ places[0]['lat'], places[0]['lng'],
169
+ places[1]['lat'], places[1]['lng']
170
+ )
171
+ return [0, 1], dist
172
+
173
+ # Build distance matrix
174
+ matrix = calculate_distance_matrix(places)
175
+
176
+ # Get initial tour using nearest neighbor
177
+ tour = nearest_neighbor(matrix, start_index)
178
+
179
+ # Improve with 2-opt
180
+ tour = two_opt(tour, matrix)
181
+
182
+ # Calculate total distance
183
+ total = calculate_total_distance(tour, matrix)
184
+
185
+ return tour, round(total, 2)
186
+
187
+
188
+ def estimate_duration(distance_km: float, avg_speed_kmh: float = 30) -> int:
189
+ """
190
+ Estimate travel duration in minutes.
191
+
192
+ Args:
193
+ distance_km: Total distance in km
194
+ avg_speed_kmh: Average speed (default: 30 km/h for city driving)
195
+
196
+ Returns:
197
+ Estimated duration in minutes
198
+ """
199
+ hours = distance_km / avg_speed_kmh
200
+ return int(hours * 60)
app/shared/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Shared module - integrations, DB, models."""
app/shared/chat_history.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat history manager for per-user conversation storage."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from collections import defaultdict
6
+
7
+
8
+ @dataclass
9
+ class ChatMessage:
10
+ """Single chat message."""
11
+
12
+ role: str # "user" or "assistant"
13
+ content: str
14
+ timestamp: datetime = field(default_factory=datetime.now)
15
+
16
+
17
+ @dataclass
18
+ class ChatSession:
19
+ """Chat session with history."""
20
+
21
+ session_id: str
22
+ messages: list[ChatMessage] = field(default_factory=list)
23
+ created_at: datetime = field(default_factory=datetime.now)
24
+
25
+ def add_message(self, role: str, content: str) -> None:
26
+ """Add a message to this session."""
27
+ self.messages.append(ChatMessage(role=role, content=content))
28
+
29
+ def get_history_text(self, max_messages: int = 10) -> str:
30
+ """Get formatted history for prompt injection."""
31
+ recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
32
+ if not recent:
33
+ return ""
34
+
35
+ lines = []
36
+ for msg in recent:
37
+ prefix = "User" if msg.role == "user" else "Assistant"
38
+ lines.append(f"{prefix}: {msg.content}")
39
+
40
+ return "\n".join(lines)
41
+
42
+
43
+ class ChatHistoryManager:
44
+ """
45
+ Manages chat history per user with multiple sessions.
46
+
47
+ Each user can have up to max_sessions active sessions.
48
+ Oldest sessions are removed when limit is exceeded.
49
+ """
50
+
51
+ def __init__(self, max_sessions_per_user: int = 3, max_messages_per_session: int = 20):
52
+ """
53
+ Initialize the chat history manager.
54
+
55
+ Args:
56
+ max_sessions_per_user: Maximum sessions to keep per user (default 3)
57
+ max_messages_per_session: Maximum messages per session (default 20)
58
+ """
59
+ self.max_sessions = max_sessions_per_user
60
+ self.max_messages = max_messages_per_session
61
+ self._sessions: dict[str, dict[str, ChatSession]] = defaultdict(dict)
62
+
63
+ def get_or_create_session(self, user_id: str, session_id: str | None = None) -> ChatSession:
64
+ """
65
+ Get existing session or create a new one.
66
+
67
+ Args:
68
+ user_id: User identifier
69
+ session_id: Optional session ID (uses "default" if not provided)
70
+
71
+ Returns:
72
+ ChatSession instance
73
+ """
74
+ session_id = session_id or "default"
75
+ user_sessions = self._sessions[user_id]
76
+
77
+ if session_id not in user_sessions:
78
+ # Create new session
79
+ user_sessions[session_id] = ChatSession(session_id=session_id)
80
+
81
+ # Enforce max sessions limit
82
+ if len(user_sessions) > self.max_sessions:
83
+ # Remove oldest session
84
+ oldest_id = min(
85
+ user_sessions.keys(),
86
+ key=lambda k: user_sessions[k].created_at
87
+ )
88
+ del user_sessions[oldest_id]
89
+
90
+ return user_sessions[session_id]
91
+
92
+ def add_message(
93
+ self,
94
+ user_id: str,
95
+ role: str,
96
+ content: str,
97
+ session_id: str | None = None,
98
+ ) -> None:
99
+ """
100
+ Add a message to user's session.
101
+
102
+ Args:
103
+ user_id: User identifier
104
+ role: "user" or "assistant"
105
+ content: Message content
106
+ session_id: Optional session ID
107
+ """
108
+ session = self.get_or_create_session(user_id, session_id)
109
+ session.add_message(role, content)
110
+
111
+ # Enforce max messages per session
112
+ if len(session.messages) > self.max_messages:
113
+ session.messages = session.messages[-self.max_messages:]
114
+
115
+ def get_history(
116
+ self,
117
+ user_id: str,
118
+ session_id: str | None = None,
119
+ max_messages: int = 10,
120
+ ) -> str:
121
+ """
122
+ Get formatted chat history for prompt.
123
+
124
+ Args:
125
+ user_id: User identifier
126
+ session_id: Optional session ID
127
+ max_messages: Maximum messages to include
128
+
129
+ Returns:
130
+ Formatted history string
131
+ """
132
+ session = self.get_or_create_session(user_id, session_id)
133
+ return session.get_history_text(max_messages)
134
+
135
+ def get_messages(
136
+ self,
137
+ user_id: str,
138
+ session_id: str | None = None,
139
+ ) -> list[ChatMessage]:
140
+ """Get all messages for a session."""
141
+ session = self.get_or_create_session(user_id, session_id)
142
+ return session.messages
143
+
144
+ def clear_session(self, user_id: str, session_id: str | None = None) -> None:
145
+ """Clear a specific session."""
146
+ session_id = session_id or "default"
147
+ if user_id in self._sessions and session_id in self._sessions[user_id]:
148
+ del self._sessions[user_id][session_id]
149
+
150
+ def clear_all_sessions(self, user_id: str) -> None:
151
+ """Clear all sessions for a user."""
152
+ if user_id in self._sessions:
153
+ self._sessions[user_id].clear()
154
+
155
+ def get_session_ids(self, user_id: str) -> list[str]:
156
+ """Get all session IDs for a user."""
157
+ return list(self._sessions.get(user_id, {}).keys())
158
+
159
+
160
+ # Global chat history manager instance
161
+ chat_history = ChatHistoryManager(max_sessions_per_user=3, max_messages_per_session=20)
app/shared/db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Database module."""
app/shared/db/session.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database session and engine configuration."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
6
+
7
+ from app.core.config import settings
8
+
9
+ # Create async engine
10
+ engine = create_async_engine(
11
+ settings.database_url,
12
+ echo=False, # Disable SQL logging (embedding vectors are too verbose)
13
+ pool_pre_ping=True,
14
+ )
15
+
16
+ # Session factory
17
+ async_session_maker = async_sessionmaker(
18
+ engine,
19
+ class_=AsyncSession,
20
+ expire_on_commit=False,
21
+ )
22
+
23
+
24
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
25
+ """Dependency for getting database session."""
26
+ async with async_session_maker() as session:
27
+ try:
28
+ yield session
29
+ finally:
30
+ await session.close()
app/shared/integrations/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Integrations module."""
app/shared/integrations/embedding_client.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embedding client for text and image embeddings.
2
+
3
+ Supports:
4
+ - Text: Google text-embedding-004 (768-dim)
5
+ - Image: HuggingFace CLIP/SigLIP (512/768-dim)
6
+ """
7
+
8
+ import httpx
9
+ from io import BytesIO
10
+ from google import genai
11
+
12
+ from app.core.config import settings
13
+
14
+ # Initialize Google GenAI client
15
+ client = genai.Client(api_key=settings.google_api_key)
16
+
17
+
18
+ class EmbeddingClient:
19
+ """Client for generating text and image embeddings."""
20
+
21
+ def __init__(self):
22
+ """Initialize embedding client."""
23
+ self.text_model = settings.embedding_model
24
+ self.hf_api_key = settings.huggingface_api_key
25
+
26
+ async def embed_text(self, text: str) -> list[float]:
27
+ """
28
+ Generate text embedding using text-embedding-004.
29
+
30
+ Args:
31
+ text: Text to embed
32
+
33
+ Returns:
34
+ 768-dimensional embedding vector
35
+ """
36
+ response = client.models.embed_content(
37
+ model=self.text_model,
38
+ contents=text,
39
+ )
40
+ return response.embeddings[0].values
41
+
42
+ async def embed_texts(self, texts: list[str]) -> list[list[float]]:
43
+ """
44
+ Generate embeddings for multiple texts.
45
+
46
+ Args:
47
+ texts: List of texts to embed
48
+
49
+ Returns:
50
+ List of embedding vectors
51
+ """
52
+ response = client.models.embed_content(
53
+ model=self.text_model,
54
+ contents=texts,
55
+ )
56
+ return [emb.values for emb in response.embeddings]
57
+
58
+ async def embed_image(self, image_url: str) -> list[float] | None:
59
+ """
60
+ Generate image embedding using CLIP via HuggingFace.
61
+
62
+ Args:
63
+ image_url: URL of the image
64
+
65
+ Returns:
66
+ 512-dimensional embedding vector, or None if failed
67
+ """
68
+ if not self.hf_api_key:
69
+ return None
70
+
71
+ try:
72
+ async with httpx.AsyncClient() as http_client:
73
+ # Use CLIP model via HuggingFace Inference API
74
+ response = await http_client.post(
75
+ "https://api-inference.huggingface.co/models/openai/clip-vit-base-patch32",
76
+ headers={"Authorization": f"Bearer {self.hf_api_key}"},
77
+ json={"inputs": {"image": image_url}},
78
+ timeout=30.0,
79
+ )
80
+ if response.status_code == 200:
81
+ return response.json()
82
+ return None
83
+ except Exception:
84
+ return None
85
+
86
+ async def embed_image_bytes(self, image_bytes: bytes) -> list[float] | None:
87
+ """
88
+ Generate image embedding from raw image bytes.
89
+
90
+ Args:
91
+ image_bytes: Raw image bytes (JPEG, PNG, etc.)
92
+
93
+ Returns:
94
+ 512-dimensional embedding vector, or None if failed
95
+ """
96
+ if not self.hf_api_key:
97
+ return None
98
+
99
+ try:
100
+ import base64
101
+ # Convert bytes to base64 data URL
102
+ b64_image = base64.b64encode(image_bytes).decode('utf-8')
103
+ data_url = f"data:image/jpeg;base64,{b64_image}"
104
+
105
+ async with httpx.AsyncClient() as http_client:
106
+ response = await http_client.post(
107
+ "https://api-inference.huggingface.co/models/openai/clip-vit-base-patch32",
108
+ headers={"Authorization": f"Bearer {self.hf_api_key}"},
109
+ json={"inputs": {"image": data_url}},
110
+ timeout=30.0,
111
+ )
112
+ if response.status_code == 200:
113
+ return response.json()
114
+ return None
115
+ except Exception:
116
+ return None
117
+
118
+
119
+ # Global embedding client instance
120
+ embedding_client = EmbeddingClient()
app/shared/integrations/gemini_client.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini client for LLM operations using Google GenAI."""
2
+
3
+ import json
4
+ import re
5
+
6
+ from google import genai
7
+
8
+ from app.core.config import settings
9
+
10
+ # Initialize Google GenAI client
11
+ client = genai.Client(api_key=settings.google_api_key)
12
+
13
+
14
+ class GeminiClient:
15
+ """Client for Gemini LLM operations."""
16
+
17
+ def __init__(self, model: str | None = None):
18
+ """Initialize with optional model override."""
19
+ self.model = model or settings.default_gemini_model
20
+
21
+ async def chat(
22
+ self,
23
+ messages: list[dict],
24
+ temperature: float = 0.7,
25
+ system_instruction: str | None = None,
26
+ ) -> str:
27
+ """
28
+ Generate chat completion using Gemini.
29
+
30
+ Args:
31
+ messages: List of message dicts with 'role' and 'parts'
32
+ temperature: Sampling temperature (0.0 - 1.0)
33
+ system_instruction: Optional system prompt
34
+
35
+ Returns:
36
+ Generated text response
37
+ """
38
+ config = {"temperature": temperature}
39
+ if system_instruction:
40
+ config["system_instruction"] = system_instruction
41
+
42
+ response = client.models.generate_content(
43
+ model=self.model,
44
+ contents=messages,
45
+ config=config,
46
+ )
47
+ return response.text
48
+
49
+ async def generate(
50
+ self,
51
+ prompt: str,
52
+ temperature: float = 0.7,
53
+ system_instruction: str | None = None,
54
+ ) -> str:
55
+ """
56
+ Simple text generation.
57
+
58
+ Args:
59
+ prompt: Text prompt
60
+ temperature: Sampling temperature
61
+ system_instruction: Optional system prompt
62
+
63
+ Returns:
64
+ Generated text
65
+ """
66
+ config = {"temperature": temperature}
67
+ if system_instruction:
68
+ config["system_instruction"] = system_instruction
69
+
70
+ response = client.models.generate_content(
71
+ model=self.model,
72
+ contents=prompt,
73
+ config=config,
74
+ )
75
+ return response.text
76
+
77
+ async def parse_tool_calls(self, user_message: str, available_tools: list[dict]) -> dict:
78
+ """
79
+ Parse user message to determine which MCP tools to call.
80
+
81
+ Args:
82
+ user_message: User's natural language query
83
+ available_tools: List of available tool definitions
84
+
85
+ Returns:
86
+ Dict with tool_name and arguments
87
+ """
88
+ tools_desc = "\n".join([
89
+ f"- {t['name']}: {t['description']}" for t in available_tools
90
+ ])
91
+
92
+ prompt = f'''Bạn là AI assistant phân tích intent. Xác định công cụ cần gọi.
93
+
94
+ Các công cụ có sẵn:
95
+ {tools_desc}
96
+
97
+ Query của người dùng: "{user_message}"
98
+
99
+ Trả về JSON (chỉ JSON, không giải thích):
100
+ {{
101
+ "tool_name": "tên_tool_cần_gọi",
102
+ "arguments": {{...}},
103
+ "reasoning": "lý do chọn tool này"
104
+ }}
105
+
106
+ Nếu không cần gọi tool, trả về:
107
+ {{"tool_name": null, "arguments": {{}}, "reasoning": "..."}}'''
108
+
109
+ response = await self.generate(prompt, temperature=0.3)
110
+
111
+ try:
112
+ json_match = re.search(r'\{[^{}]*\}', response, re.DOTALL)
113
+ if json_match:
114
+ return json.loads(json_match.group())
115
+ return json.loads(response)
116
+ except json.JSONDecodeError:
117
+ return {"tool_name": None, "arguments": {}, "reasoning": "Failed to parse"}
118
+
119
+
120
+ def get_gemini_client(model: str | None = None) -> GeminiClient:
121
+ """Factory function to create Gemini client with specified model."""
122
+ return GeminiClient(model=model)
123
+
124
+
125
+ # Global Gemini client instance (with default model)
126
+ gemini_client = GeminiClient()
127
+
app/shared/integrations/megallm_client.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MegaLLM client using OpenAI-compatible API with retry logic."""
2
+
3
+ import httpx
4
+
5
+ from app.core.config import settings
6
+
7
+ # Timeout configuration for DeepSeek reasoning models (can take longer)
8
+ REQUEST_TIMEOUT = httpx.Timeout(
9
+ connect=30.0, # Connection timeout
10
+ read=300.0, # Read timeout (5 minutes for reasoning models)
11
+ write=30.0, # Write timeout
12
+ pool=30.0, # Pool timeout
13
+ )
14
+
15
+
16
+ class MegaLLMClient:
17
+ """Client for MegaLLM (OpenAI-compatible API) operations."""
18
+
19
+ def __init__(self, model: str | None = None):
20
+ """Initialize with optional model override."""
21
+ self.model = model or settings.default_megallm_model
22
+ self.api_key = settings.megallm_api_key
23
+ self.base_url = settings.megallm_base_url
24
+
25
+ async def generate(
26
+ self,
27
+ prompt: str,
28
+ temperature: float = 0.7,
29
+ system_instruction: str | None = None,
30
+ max_retries: int = 2,
31
+ ) -> str:
32
+ """
33
+ Generate text using MegaLLM (OpenAI-compatible API).
34
+
35
+ Args:
36
+ prompt: Text prompt
37
+ temperature: Sampling temperature
38
+ system_instruction: Optional system prompt
39
+ max_retries: Number of retries on timeout
40
+
41
+ Returns:
42
+ Generated text
43
+ """
44
+ if not self.api_key:
45
+ raise ValueError("MEGALLM_API_KEY is not configured")
46
+
47
+ messages = []
48
+ if system_instruction:
49
+ messages.append({"role": "system", "content": system_instruction})
50
+ messages.append({"role": "user", "content": prompt})
51
+
52
+ last_error = None
53
+ for attempt in range(max_retries + 1):
54
+ try:
55
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
56
+ response = await client.post(
57
+ f"{self.base_url}/chat/completions",
58
+ headers={
59
+ "Authorization": f"Bearer {self.api_key}",
60
+ "Content-Type": "application/json",
61
+ },
62
+ json={
63
+ "model": self.model,
64
+ "messages": messages,
65
+ "temperature": temperature,
66
+ },
67
+ )
68
+ response.raise_for_status()
69
+ data = response.json()
70
+ return data["choices"][0]["message"]["content"]
71
+ except httpx.ReadTimeout as e:
72
+ last_error = e
73
+ if attempt < max_retries:
74
+ continue # Retry
75
+ raise
76
+ except Exception as e:
77
+ last_error = e
78
+ raise
79
+
80
+ # This shouldn't be reached, but just in case
81
+ raise last_error if last_error else RuntimeError("Unknown error")
82
+
83
+ async def chat(
84
+ self,
85
+ messages: list[dict],
86
+ temperature: float = 0.7,
87
+ system_instruction: str | None = None,
88
+ ) -> str:
89
+ """
90
+ Generate chat completion using MegaLLM.
91
+
92
+ Args:
93
+ messages: List of message dicts with 'role' and 'content'
94
+ temperature: Sampling temperature
95
+ system_instruction: Optional system prompt
96
+
97
+ Returns:
98
+ Generated text response
99
+ """
100
+ if not self.api_key:
101
+ raise ValueError("MEGALLM_API_KEY is not configured")
102
+
103
+ chat_messages = []
104
+ if system_instruction:
105
+ chat_messages.append({"role": "system", "content": system_instruction})
106
+
107
+ # Convert messages to OpenAI format
108
+ for msg in messages:
109
+ role = msg.get("role", "user")
110
+ content = msg.get("content") or msg.get("parts", [""])[0]
111
+ chat_messages.append({"role": role, "content": content})
112
+
113
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
114
+ response = await client.post(
115
+ f"{self.base_url}/chat/completions",
116
+ headers={
117
+ "Authorization": f"Bearer {self.api_key}",
118
+ "Content-Type": "application/json",
119
+ },
120
+ json={
121
+ "model": self.model,
122
+ "messages": chat_messages,
123
+ "temperature": temperature,
124
+ },
125
+ )
126
+ response.raise_for_status()
127
+ data = response.json()
128
+ return data["choices"][0]["message"]["content"]
129
+
130
+
131
+ def get_megallm_client(model: str | None = None) -> MegaLLMClient:
132
+ """Factory function to create MegaLLM client with specified model."""
133
+ return MegaLLMClient(model=model)
app/shared/integrations/neo4j_client.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Neo4j client for graph database operations."""
2
+
3
+ from neo4j import AsyncGraphDatabase
4
+
5
+ from app.core.config import settings
6
+
7
+
8
+ class Neo4jClient:
9
+ """Async Neo4j client for spatial and graph queries."""
10
+
11
+ def __init__(self, uri: str, user: str, password: str):
12
+ """Initialize Neo4j driver."""
13
+ self._driver = AsyncGraphDatabase.driver(uri, auth=(user, password))
14
+
15
+ async def close(self) -> None:
16
+ """Close the driver connection."""
17
+ await self._driver.close()
18
+
19
+ async def run_cypher(
20
+ self,
21
+ query: str,
22
+ params: dict | None = None,
23
+ ) -> list[dict]:
24
+ """
25
+ Execute a Cypher query and return results.
26
+
27
+ Args:
28
+ query: Cypher query string
29
+ params: Optional query parameters
30
+
31
+ Returns:
32
+ List of result records as dictionaries
33
+ """
34
+ async with self._driver.session() as session:
35
+ result = await session.run(query, params or {})
36
+ return await result.data()
37
+
38
+ async def verify_connectivity(self) -> bool:
39
+ """Verify connection to Neo4j."""
40
+ try:
41
+ await self._driver.verify_connectivity()
42
+ return True
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ # Global Neo4j client instance
48
+ neo4j_client = Neo4jClient(
49
+ settings.neo4j_uri,
50
+ settings.neo4j_username,
51
+ settings.neo4j_password,
52
+ )
app/shared/integrations/siglip_client.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SigLIP Image Embedding Client - Local model for image embeddings.
2
+
3
+ Uses open_clip with ViT-B-16-SigLIP model for generating 768-dim image embeddings.
4
+ Model is loaded once at startup and reused for all requests.
5
+ """
6
+
7
+ import io
8
+ from typing import Optional
9
+ import numpy as np
10
+ from PIL import Image
11
+
12
+
13
+ class SigLIPClient:
14
+ """
15
+ Local SigLIP model client for image embeddings.
16
+
17
+ Model: ViT-B-16-SigLIP (pretrained on WebLI)
18
+ Output: 768-dimensional normalized embedding vector
19
+ """
20
+
21
+ _instance: Optional["SigLIPClient"] = None
22
+ _initialized: bool = False
23
+
24
+ def __new__(cls):
25
+ """Singleton pattern - only one model instance."""
26
+ if cls._instance is None:
27
+ cls._instance = super().__new__(cls)
28
+ return cls._instance
29
+
30
+ def __init__(self):
31
+ """Initialize SigLIP model (only once)."""
32
+ if SigLIPClient._initialized:
33
+ return
34
+
35
+ self.model = None
36
+ self.preprocess = None
37
+ self.device = None
38
+ self._load_model()
39
+ SigLIPClient._initialized = True
40
+
41
+ def _load_model(self):
42
+ """Load SigLIP model."""
43
+ try:
44
+ import torch
45
+ import open_clip
46
+
47
+ print("🔄 Loading SigLIP model (ViT-B-16-SigLIP)...")
48
+
49
+ self.model, _, self.preprocess = open_clip.create_model_and_transforms(
50
+ "ViT-B-16-SigLIP", pretrained="webli"
51
+ )
52
+ self.model.eval()
53
+
54
+ # Use CUDA if available, else CPU
55
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
56
+ self.model.to(self.device)
57
+
58
+ print(f"✅ SigLIP model loaded on {self.device}")
59
+
60
+ except ImportError as e:
61
+ print(f"⚠️ SigLIP dependencies not installed: {e}")
62
+ print(" Install with: pip install torch open_clip_torch pillow")
63
+ raise
64
+ except Exception as e:
65
+ print(f"❌ Failed to load SigLIP model: {e}")
66
+ raise
67
+
68
+ def embed_image(self, image: Image.Image) -> np.ndarray:
69
+ """
70
+ Generate embedding for a PIL Image.
71
+
72
+ Args:
73
+ image: PIL Image object
74
+
75
+ Returns:
76
+ Normalized 768-dim embedding vector
77
+ """
78
+ import torch
79
+
80
+ # Ensure RGB
81
+ if image.mode != 'RGB':
82
+ image = image.convert('RGB')
83
+
84
+ # Preprocess and embed
85
+ image_tensor = self.preprocess(image).unsqueeze(0).to(self.device)
86
+
87
+ with torch.no_grad():
88
+ image_features = self.model.encode_image(image_tensor)
89
+ image_features = image_features / image_features.norm(dim=-1, keepdim=True)
90
+
91
+ return image_features.cpu().numpy()[0]
92
+
93
+ def embed_image_bytes(self, image_bytes: bytes) -> np.ndarray:
94
+ """
95
+ Generate embedding from raw image bytes.
96
+
97
+ Args:
98
+ image_bytes: Raw image bytes (JPEG, PNG, etc.)
99
+
100
+ Returns:
101
+ Normalized 768-dim embedding vector
102
+ """
103
+ image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
104
+ return self.embed_image(image)
105
+
106
+ def embed_image_url(self, image_url: str) -> Optional[np.ndarray]:
107
+ """
108
+ Download and embed image from URL.
109
+
110
+ Args:
111
+ image_url: URL to image
112
+
113
+ Returns:
114
+ Embedding vector or None if failed
115
+ """
116
+ import httpx
117
+
118
+ try:
119
+ response = httpx.get(image_url, timeout=30.0)
120
+ response.raise_for_status()
121
+ return self.embed_image_bytes(response.content)
122
+ except Exception as e:
123
+ print(f"⚠️ Failed to embed image from URL: {e}")
124
+ return None
125
+
126
+ @property
127
+ def is_loaded(self) -> bool:
128
+ """Check if model is loaded."""
129
+ return self.model is not None
130
+
131
+
132
+ # Lazy initialization - model loads on first use
133
+ _siglip_client: Optional[SigLIPClient] = None
134
+
135
+
136
+ def get_siglip_client() -> SigLIPClient:
137
+ """Get or create SigLIP client singleton."""
138
+ global _siglip_client
139
+ if _siglip_client is None:
140
+ _siglip_client = SigLIPClient()
141
+ return _siglip_client
142
+
143
+
144
+ # For convenience: pre-initialized client (loads model on import)
145
+ # Uncomment below to load model on app startup:
146
+ # siglip_client = get_siglip_client()
app/shared/integrations/supabase_client.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase client for database and auth operations."""
2
+
3
+ from supabase import create_client
4
+
5
+ from app.core.config import settings
6
+
7
+ # Initialize Supabase client
8
+ supabase = create_client(
9
+ settings.supabase_url,
10
+ settings.supabase_service_role_key,
11
+ )
app/shared/logger.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Logger utility for LocalMate - Structured logging for debugging.
2
+
3
+ Provides colored console logging with structured output for:
4
+ - API request/response
5
+ - Tool execution
6
+ - LLM calls
7
+ - Workflow tracing
8
+ """
9
+
10
+ import logging
11
+ import json
12
+ import sys
13
+ from datetime import datetime
14
+ from typing import Any
15
+ from dataclasses import dataclass, field, asdict
16
+
17
+
18
+ # Configure root logger
19
+ logging.basicConfig(
20
+ level=logging.INFO,
21
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
22
+ datefmt="%H:%M:%S",
23
+ stream=sys.stdout,
24
+ )
25
+
26
+ # Color codes for terminal
27
+ COLORS = {
28
+ "RESET": "\033[0m",
29
+ "BOLD": "\033[1m",
30
+ "CYAN": "\033[36m",
31
+ "GREEN": "\033[32m",
32
+ "YELLOW": "\033[33m",
33
+ "MAGENTA": "\033[35m",
34
+ "BLUE": "\033[34m",
35
+ "RED": "\033[31m",
36
+ }
37
+
38
+
39
+ def colorize(text: str, color: str) -> str:
40
+ """Add color to text for terminal output."""
41
+ return f"{COLORS.get(color, '')}{text}{COLORS['RESET']}"
42
+
43
+
44
+ class LocalMateLogger:
45
+ """Structured logger for LocalMate with colored output."""
46
+
47
+ def __init__(self, name: str):
48
+ self.logger = logging.getLogger(name)
49
+ self.name = name
50
+
51
+ def _format_data(self, data: Any, max_len: int = 500) -> str:
52
+ """Format data for logging, truncating if needed."""
53
+ if data is None:
54
+ return "None"
55
+
56
+ if isinstance(data, (dict, list)):
57
+ try:
58
+ formatted = json.dumps(data, ensure_ascii=False, default=str)
59
+ if len(formatted) > max_len:
60
+ return formatted[:max_len] + "..."
61
+ return formatted
62
+ except:
63
+ return str(data)[:max_len]
64
+
65
+ text = str(data)
66
+ return text[:max_len] + "..." if len(text) > max_len else text
67
+
68
+ def api_request(self, endpoint: str, method: str, params: dict = None, body: Any = None):
69
+ """Log API request."""
70
+ msg = f"{colorize('→ REQUEST', 'CYAN')} {colorize(method, 'BOLD')} {endpoint}"
71
+ if params:
72
+ msg += f"\n Params: {self._format_data(params)}"
73
+ if body:
74
+ msg += f"\n Body: {self._format_data(body)}"
75
+ self.logger.info(msg)
76
+
77
+ def api_response(self, endpoint: str, status: int, data: Any = None, duration_ms: float = None):
78
+ """Log API response."""
79
+ status_color = "GREEN" if status < 400 else "RED"
80
+ msg = f"{colorize('← RESPONSE', status_color)} {endpoint} [{status}]"
81
+ if duration_ms:
82
+ msg += f" ({duration_ms:.0f}ms)"
83
+ if data:
84
+ msg += f"\n Data: {self._format_data(data)}"
85
+ self.logger.info(msg)
86
+
87
+ def tool_call(self, tool_name: str, arguments: dict):
88
+ """Log tool call start."""
89
+ msg = f"{colorize('🔧 TOOL', 'MAGENTA')} {colorize(tool_name, 'BOLD')}"
90
+ msg += f"\n Args: {self._format_data(arguments)}"
91
+ self.logger.info(msg)
92
+
93
+ def tool_result(self, tool_name: str, result_count: int, sample: Any = None):
94
+ """Log tool result."""
95
+ msg = f"{colorize('✓ RESULT', 'GREEN')} {tool_name} → {result_count} results"
96
+ if sample:
97
+ msg += f"\n Sample: {self._format_data(sample, max_len=200)}"
98
+ self.logger.info(msg)
99
+
100
+ def llm_call(self, provider: str, model: str, prompt_preview: str = None):
101
+ """Log LLM call."""
102
+ msg = f"{colorize('🤖 LLM', 'BLUE')} {provider}/{model}"
103
+ if prompt_preview:
104
+ preview = prompt_preview[:100] + "..." if len(prompt_preview) > 100 else prompt_preview
105
+ msg += f"\n Prompt: {preview}"
106
+ self.logger.info(msg)
107
+
108
+ def llm_response(self, provider: str, response_preview: str = None, tokens: int = None):
109
+ """Log LLM response."""
110
+ msg = f"{colorize('💬 LLM RESPONSE', 'BLUE')} {provider}"
111
+ if tokens:
112
+ msg += f" ({tokens} tokens)"
113
+ if response_preview:
114
+ preview = response_preview[:150] + "..." if len(response_preview) > 150 else response_preview
115
+ msg += f"\n Response: {preview}"
116
+ self.logger.info(msg)
117
+
118
+ def workflow_step(self, step: str, details: str = None):
119
+ """Log workflow step."""
120
+ msg = f"{colorize('▶', 'YELLOW')} {step}"
121
+ if details:
122
+ msg += f": {details}"
123
+ self.logger.info(msg)
124
+
125
+ def error(self, message: str, error: Exception = None):
126
+ """Log error."""
127
+ msg = f"{colorize('❌ ERROR', 'RED')} {message}"
128
+ if error:
129
+ msg += f"\n {type(error).__name__}: {str(error)}"
130
+ self.logger.error(msg)
131
+
132
+ def debug(self, message: str, data: Any = None):
133
+ """Log debug info."""
134
+ msg = f"{colorize('DEBUG', 'CYAN')} {message}"
135
+ if data:
136
+ msg += f": {self._format_data(data)}"
137
+ self.logger.debug(msg)
138
+
139
+
140
+ @dataclass
141
+ class WorkflowStep:
142
+ """A step in the agent workflow."""
143
+
144
+ step_name: str
145
+ tool_name: str | None = None
146
+ purpose: str = ""
147
+ input_summary: str = ""
148
+ output_summary: str = ""
149
+ result_count: int = 0
150
+ duration_ms: float = 0
151
+
152
+
153
+ @dataclass
154
+ class AgentWorkflow:
155
+ """Complete workflow trace for a chat request."""
156
+
157
+ query: str
158
+ intent_detected: str = ""
159
+ steps: list[WorkflowStep] = field(default_factory=list)
160
+ total_duration_ms: float = 0
161
+ tools_used: list[str] = field(default_factory=list)
162
+
163
+ def add_step(self, step: WorkflowStep):
164
+ """Add a step to the workflow."""
165
+ self.steps.append(step)
166
+ if step.tool_name and step.tool_name not in self.tools_used:
167
+ self.tools_used.append(step.tool_name)
168
+
169
+ def to_dict(self) -> dict:
170
+ """Convert to dictionary for JSON serialization."""
171
+ return {
172
+ "query": self.query,
173
+ "intent_detected": self.intent_detected,
174
+ "tools_used": self.tools_used,
175
+ "steps": [
176
+ {
177
+ "step": s.step_name,
178
+ "tool": s.tool_name,
179
+ "purpose": s.purpose,
180
+ "results": s.result_count,
181
+ }
182
+ for s in self.steps
183
+ ],
184
+ "total_duration_ms": round(self.total_duration_ms, 1),
185
+ }
186
+
187
+ def to_summary(self) -> str:
188
+ """Generate human-readable workflow summary."""
189
+ lines = [f"📊 **Workflow Summary**"]
190
+ lines.append(f"- Query: \"{self.query[:50]}{'...' if len(self.query) > 50 else ''}\"")
191
+ lines.append(f"- Intent: {self.intent_detected}")
192
+ lines.append(f"- Tools: {', '.join(self.tools_used) or 'None'}")
193
+
194
+ if self.steps:
195
+ lines.append("\n**Steps:**")
196
+ for i, step in enumerate(self.steps, 1):
197
+ tool_info = f" ({step.tool_name})" if step.tool_name else ""
198
+ results_info = f" → {step.result_count} results" if step.result_count else ""
199
+ lines.append(f"{i}. {step.step_name}{tool_info}{results_info}")
200
+
201
+ lines.append(f"\n⏱️ Total: {self.total_duration_ms:.0f}ms")
202
+ return "\n".join(lines)
203
+
204
+
205
+ # Global logger instances
206
+ agent_logger = LocalMateLogger("agent")
207
+ api_logger = LocalMateLogger("api")
208
+ tool_logger = LocalMateLogger("tools")
app/shared/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Models module."""
app/shared/models/base.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base model classes."""
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import func
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ """Base class for all models."""
11
+
12
+ pass
13
+
14
+
15
+ class TimestampMixin:
16
+ """Mixin for created_at and updated_at timestamps."""
17
+
18
+ created_at: Mapped[datetime] = mapped_column(
19
+ default=func.now(),
20
+ nullable=False,
21
+ )
22
+ updated_at: Mapped[datetime] = mapped_column(
23
+ default=func.now(),
24
+ onupdate=func.now(),
25
+ nullable=False,
26
+ )
docs/TRIP_PLANNER_PLAN.md ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Trip Planner Feature - Technical Design Document
2
+
3
+ **Version**: 1.0
4
+ **Date**: 2025-12-15
5
+ **Status**: Draft
6
+
7
+ ---
8
+
9
+ ## 📋 Overview
10
+
11
+ Tính năng Trip Planner cho phép user lên kế hoạch chuyến đi bằng cách:
12
+ 1. Chat với AI để tìm địa điểm
13
+ 2. Thêm địa điểm vào Plan Box
14
+ 3. Tối ưu lộ trình bằng thuật toán TSP
15
+ 4. Chỉnh sửa/thay thế địa điểm
16
+
17
+ ---
18
+
19
+ ## 🎯 User Flow
20
+
21
+ ```mermaid
22
+ flowchart TD
23
+ A[User Chat] --> B{AI Response}
24
+ B --> C[Place Cards với 'Add to Plan']
25
+ C --> |Click Add| D[Plan Box]
26
+ D --> E{User Actions}
27
+ E --> |Optimize| F[TSP Algorithm]
28
+ E --> |Drag & Drop| G[Reorder Places]
29
+ E --> |Replace| H[AI hỏi criteria mới]
30
+ H --> I[Suggest Alternatives]
31
+ I --> D
32
+ F --> D
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 🏗️ Architecture
38
+
39
+ ### Backend Components
40
+
41
+ ```
42
+ app/
43
+ ├── planner/
44
+ │ ├── __init__.py
45
+ │ ├── models.py # Plan, PlanItem schemas
46
+ │ ├── router.py # API endpoints
47
+ │ ├── service.py # Business logic
48
+ │ └── tsp.py # TSP optimization algorithm
49
+ └── mcp/tools/
50
+ └── graph_tool.py # Neo4j + OSM (có sẵn)
51
+ ```
52
+
53
+ ### API Endpoints
54
+
55
+ | Method | Endpoint | Description |
56
+ |--------|----------|-------------|
57
+ | POST | `/planner/create` | Tạo plan mới |
58
+ | GET | `/planner/{plan_id}` | Lấy plan |
59
+ | POST | `/planner/{plan_id}/add` | Thêm place vào plan |
60
+ | DELETE | `/planner/{plan_id}/remove/{item_id}` | Xóa place |
61
+ | PUT | `/planner/{plan_id}/reorder` | Sắp xếp lại thứ tự |
62
+ | POST | `/planner/{plan_id}/optimize` | Chạy TSP |
63
+ | POST | `/planner/{plan_id}/replace/{item_id}` | Thay thế place |
64
+
65
+ ---
66
+
67
+ ## 📦 Data Models
68
+
69
+ ### Plan
70
+
71
+ ```python
72
+ @dataclass
73
+ class Plan:
74
+ plan_id: str
75
+ user_id: str
76
+ name: str
77
+ items: list[PlanItem]
78
+ created_at: datetime
79
+ updated_at: datetime
80
+ total_distance_km: float | None
81
+ estimated_duration_min: int | None
82
+ ```
83
+
84
+ ### PlanItem
85
+
86
+ ```python
87
+ @dataclass
88
+ class PlanItem:
89
+ item_id: str
90
+ place_id: str
91
+ name: str
92
+ category: str
93
+ lat: float
94
+ lng: float
95
+ order: int # Thứ tự trong plan
96
+ added_at: datetime
97
+ notes: str | None
98
+ ```
99
+
100
+ ---
101
+
102
+ ## 🧮 TSP Algorithm
103
+
104
+ ### Approach: Nearest Neighbor + 2-opt Optimization
105
+
106
+ ```python
107
+ # app/planner/tsp.py
108
+
109
+ from math import radians, sin, cos, sqrt, atan2
110
+
111
+ def haversine(lat1, lng1, lat2, lng2) -> float:
112
+ """Calculate distance between 2 points in km."""
113
+ R = 6371 # Earth's radius in km
114
+ dlat = radians(lat2 - lat1)
115
+ dlng = radians(lng2 - lng1)
116
+ a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlng/2)**2
117
+ return 2 * R * atan2(sqrt(a), sqrt(1-a))
118
+
119
+
120
+ def calculate_distance_matrix(places: list[dict]) -> list[list[float]]:
121
+ """Build NxN distance matrix."""
122
+ n = len(places)
123
+ matrix = [[0.0] * n for _ in range(n)]
124
+ for i in range(n):
125
+ for j in range(n):
126
+ if i != j:
127
+ matrix[i][j] = haversine(
128
+ places[i]['lat'], places[i]['lng'],
129
+ places[j]['lat'], places[j]['lng']
130
+ )
131
+ return matrix
132
+
133
+
134
+ def nearest_neighbor(matrix: list[list[float]], start: int = 0) -> list[int]:
135
+ """Greedy nearest neighbor heuristic."""
136
+ n = len(matrix)
137
+ visited = [False] * n
138
+ tour = [start]
139
+ visited[start] = True
140
+
141
+ for _ in range(n - 1):
142
+ current = tour[-1]
143
+ nearest = -1
144
+ min_dist = float('inf')
145
+ for j in range(n):
146
+ if not visited[j] and matrix[current][j] < min_dist:
147
+ min_dist = matrix[current][j]
148
+ nearest = j
149
+ tour.append(nearest)
150
+ visited[nearest] = True
151
+
152
+ return tour
153
+
154
+
155
+ def two_opt(tour: list[int], matrix: list[list[float]]) -> list[int]:
156
+ """2-opt local search improvement."""
157
+ improved = True
158
+ while improved:
159
+ improved = False
160
+ for i in range(1, len(tour) - 1):
161
+ for j in range(i + 1, len(tour)):
162
+ # Calculate improvement
163
+ d1 = matrix[tour[i-1]][tour[i]] + matrix[tour[j-1]][tour[j]]
164
+ d2 = matrix[tour[i-1]][tour[j-1]] + matrix[tour[i]][tour[j]]
165
+ if d2 < d1:
166
+ # Reverse segment
167
+ tour[i:j] = tour[i:j][::-1]
168
+ improved = True
169
+ return tour
170
+
171
+
172
+ def optimize_route(places: list[dict], start_index: int = 0) -> tuple[list[int], float]:
173
+ """
174
+ Main TSP optimization function.
175
+
176
+ Args:
177
+ places: List of places with 'lat', 'lng' keys
178
+ start_index: Index of starting place
179
+
180
+ Returns:
181
+ (optimized_order, total_distance_km)
182
+ """
183
+ if len(places) <= 2:
184
+ return list(range(len(places))), 0.0
185
+
186
+ matrix = calculate_distance_matrix(places)
187
+ tour = nearest_neighbor(matrix, start_index)
188
+ tour = two_opt(tour, matrix)
189
+
190
+ # Calculate total distance
191
+ total = sum(matrix[tour[i]][tour[i+1]] for i in range(len(tour)-1))
192
+
193
+ return tour, total
194
+ ```
195
+
196
+ ### Complexity
197
+
198
+ - **Nearest Neighbor**: O(n²)
199
+ - **2-opt**: O(n²) per iteration, ~O(n³) worst case
200
+ - **Suitable for**: Up to ~50 places (typical trip size)
201
+
202
+ ---
203
+
204
+ ## 🔄 Replace Flow
205
+
206
+ ### Workflow
207
+
208
+ ```mermaid
209
+ sequenceDiagram
210
+ participant U as User
211
+ participant F as Frontend
212
+ participant B as Backend
213
+ participant AI as LLM Agent
214
+
215
+ U->>F: Click Replace on Place X
216
+ F->>B: POST /chat {"message": "replace_context", "place_id": X}
217
+ B->>AI: "User muốn thay thế [Place X]. Hỏi họ muốn tìm địa điểm như nào?"
218
+ AI->>B: "Bạn muốn tìm địa điểm thay thế như thế nào? (VD: gần hơn, rẻ hơn, khác loại...)"
219
+ B->>F: Response
220
+ F->>U: Display AI question
221
+ U->>F: "Tìm quán cafe yên tĩnh hơn"
222
+ F->>B: POST /chat with context
223
+ B->>AI: Search for alternatives
224
+ AI->>B: Return alternatives as Place Cards
225
+ B->>F: Place Cards
226
+ U->>F: Select replacement
227
+ F->>B: PUT /planner/{plan_id}/replace/{item_id}
228
+ B->>F: Updated Plan
229
+ ```
230
+
231
+ ### API Request
232
+
233
+ ```json
234
+ // POST /planner/{plan_id}/replace/{item_id}
235
+ {
236
+ "new_place_id": "cafe_xyz_123",
237
+ "new_place": {
238
+ "name": "Cafe XYZ",
239
+ "lat": 16.0544,
240
+ "lng": 108.2480,
241
+ "category": "Coffee shop"
242
+ }
243
+ }
244
+ ```
245
+
246
+ ---
247
+
248
+ ## 🎨 Frontend Integration
249
+
250
+ ### Chat Response Format
251
+
252
+ ```json
253
+ {
254
+ "response": "Đây là một số quán cafe gần Cầu Rồng:",
255
+ "places": [
256
+ {
257
+ "place_id": "sound_cafe",
258
+ "name": "Sound Cafe",
259
+ "category": "Coffee shop",
260
+ "lat": 16.0611,
261
+ "lng": 108.2272,
262
+ "rating": 4.7,
263
+ "description": "Quán cafe âm nhạc acoustic...",
264
+ "distance_km": 1.75,
265
+ "actions": ["add_to_plan", "view_details"]
266
+ }
267
+ ],
268
+ "plan_context": {
269
+ "plan_id": "plan_abc123",
270
+ "item_count": 3
271
+ }
272
+ }
273
+ ```
274
+
275
+ ### Plan Box State
276
+
277
+ ```typescript
278
+ interface PlanState {
279
+ planId: string;
280
+ items: PlanItem[];
281
+ isOptimized: boolean;
282
+ totalDistanceKm: number;
283
+ estimatedDurationMin: number;
284
+ }
285
+
286
+ interface PlanItem {
287
+ itemId: string;
288
+ placeId: string;
289
+ name: string;
290
+ category: string;
291
+ lat: number;
292
+ lng: number;
293
+ order: number;
294
+ }
295
+ ```
296
+
297
+ ---
298
+
299
+ ## 📐 Implementation Plan
300
+
301
+ ### Phase 1: Core API (Week 1)
302
+
303
+ - [ ] Create `app/planner/` module
304
+ - [ ] Implement `models.py` with Pydantic schemas
305
+ - [ ] Implement `tsp.py` with optimization algorithm
306
+ - [ ] Create `router.py` with basic CRUD endpoints
307
+ - [ ] Add session-based plan storage
308
+
309
+ ### Phase 2: Chat Integration (Week 2)
310
+
311
+ - [ ] Modify chat response format to include `places` array
312
+ - [ ] Add `add_to_plan` action handling in agent
313
+ - [ ] Implement replace flow with context tracking
314
+ - [ ] Store plan context per user session
315
+
316
+ ### Phase 3: TSP & Optimization (Week 3)
317
+
318
+ - [ ] Implement `/optimize` endpoint
319
+ - [ ] Add distance matrix calculation using graph_tool
320
+ - [ ] Integrate with Neo4j for real distances (optional: OSRM for road distances)
321
+ - [ ] Return optimized order with total distance
322
+
323
+ ### Phase 4: Frontend (Week 4)
324
+
325
+ - [ ] Create Place Card component with actions
326
+ - [ ] Implement Plan Box with drag-drop (react-beautiful-dnd)
327
+ - [ ] Add Optimize button with loading state
328
+ - [ ] Implement Replace flow UI
329
+
330
+ ---
331
+
332
+ ## 🔧 Technical Considerations
333
+
334
+ ### Storage Options
335
+
336
+ | Option | Pros | Cons |
337
+ |--------|------|------|
338
+ | In-memory (Redis) | Fast, simple | Lost on restart |
339
+ | Supabase | Persistent, user-linked | Requires auth |
340
+ | Session-based | No auth needed | Client-side storage |
341
+
342
+ **Recommendation**: Start with session-based (in-memory per user_id), migrate to Supabase later.
343
+
344
+ ### Distance Calculation
345
+
346
+ | Method | Accuracy | Speed |
347
+ |--------|----------|-------|
348
+ | Haversine | ~95% | Very fast |
349
+ | OSRM API | ~99% (road) | Slower |
350
+ | Graph (Neo4j) | ~95% | Fast |
351
+
352
+ **Recommendation**: Use Haversine for MVP, add OSRM for production.
353
+
354
+ ### Rate Limits
355
+
356
+ - OpenStreetMap Nominatim: 1 req/sec
357
+ - OSRM: Self-hosted or 10 req/min (demo server)
358
+
359
+ ---
360
+
361
+ ## 📝 Example Usage
362
+
363
+ ### 1. User Chat
364
+
365
+ ```
366
+ User: "Tìm quán cafe và nhà hàng hải sản gần Mỹ Khê"
367
+ ```
368
+
369
+ ### 2. AI Response with Place Cards
370
+
371
+ ```
372
+ AI: "Đây là một số gợi ý cho bạn:
373
+
374
+ ☕ **Cafe**
375
+ - [Nia Coffee] - 4.3★ - 1.2km [Add to Plan]
376
+ - [Sound Cafe] - 4.7★ - 1.8km [Add to Plan]
377
+
378
+ 🦐 **Hải sản**
379
+ - [My Hanh Seafood] - 4.8★ - 0.5km [Add to Plan]
380
+ - [Bé Ni 2] - 4.8★ - 0.6km [Add to Plan]
381
+ "
382
+ ```
383
+
384
+ ### 3. Plan Box
385
+
386
+ ```
387
+ 📍 Your Plan (4 places)
388
+ ┌──────────────────────────────┐
389
+ │ 1. Nia Coffee [✏️] [🔄] │
390
+ │ 2. Sound Cafe [✏️] [🔄] │
391
+ │ 3. My Hanh Seafood [✏️] [🔄] │
392
+ │ 4. Bé Ni 2 [✏️] [🔄] │
393
+ └───────────────────���──────────┘
394
+ Total: 8.2km | ~45min
395
+
396
+ [🔀 Optimize Route] [📤 Export]
397
+ ```
398
+
399
+ ### 4. After Optimization
400
+
401
+ ```
402
+ 📍 Your Plan (Optimized ✓)
403
+ ┌──────────────────────────────┐
404
+ │ 1. My Hanh Seafood (start) │
405
+ │ 2. Bé Ni 2 (+0.3km) │
406
+ │ 3. Sound Cafe (+1.2km) │
407
+ │ 4. Nia Coffee (+0.8km) │
408
+ └──────────────────────────────┘
409
+ Total: 2.3km | ~15min (Saved 5.9km!)
410
+ ```
411
+
412
+ ---
413
+
414
+ ## 🔗 Related Files
415
+
416
+ - [`app/mcp/tools/graph_tool.py`](file:///Volumes/WorkSpace/Project/LocalMate/localmate-danang-backend-v2/app/mcp/tools/graph_tool.py) - Existing geocoding/spatial search
417
+ - [`app/shared/chat_history.py`](file:///Volumes/WorkSpace/Project/LocalMate/localmate-danang-backend-v2/app/shared/chat_history.py) - Session management
418
+ - [`app/agent/mmca_agent.py`](file:///Volumes/WorkSpace/Project/LocalMate/localmate-danang-backend-v2/app/agent/mmca_agent.py) - Chat agent
419
+
420
+ ---
421
+
422
+ ## ✅ Success Metrics
423
+
424
+ - User can add 5+ places to plan in < 2 minutes
425
+ - TSP optimization runs in < 500ms for 20 places
426
+ - Replace flow completes in < 3 exchanges with AI
docs/neo4j_test_report.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Neo4j Category Filter Test Report
2
+
3
+ **Date**: 2025-12-15
4
+
5
+ ## Test Coordinates
6
+
7
+ | Test Case | Latitude | Longitude | Max Distance (km) |
8
+ |-----------|----------|-----------|-------------------|
9
+ | Case 1 | 16.0626442 | 108.2462143 | 18.72 |
10
+ | Case 2 | 16.0623184 | 108.2306049 | 17.94 |
11
+
12
+ ## Results by Category
13
+
14
+ | Category | Case 1 | Case 2 |
15
+ |----------|:------:|:------:|
16
+ | restaurant | 10 | 10 |
17
+ | coffee | 10 | 10 |
18
+ | bar | 10 | 10 |
19
+ | seafood | 4 | 4 |
20
+ | steak | 3 | 3 |
21
+ | gym | 3 | 3 |
22
+ | hotel | 1 | 1 |
23
+ | attraction | 0 | 0 |
24
+ | spa | 0 | 0 |
25
+
26
+ ---
27
+
28
+ ## All Available Categories (69 total)
29
+
30
+ ### 🍜 Ăn uống (Food & Drink)
31
+ | Category | Category | Category |
32
+ |----------|----------|----------|
33
+ | Asian restaurant | Bakery | Bar |
34
+ | Bistro | Breakfast restaurant | Cafe |
35
+ | Cantonese restaurant | Chicken restaurant | Chinese restaurant |
36
+ | Cocktail bar | Coffee shop | Country food restaurant |
37
+ | Deli | Dessert shop | Dumpling restaurant |
38
+ | Espresso bar | Family restaurant | Fine dining restaurant |
39
+ | Food court | French restaurant | Hamburger restaurant |
40
+ | Hot pot restaurant | Ice cream shop | Indian restaurant |
41
+ | Irish pub | Italian restaurant | Izakaya restaurant |
42
+ | Japanese restaurant | Korean BBQ restaurant | Korean restaurant |
43
+ | Live music bar | Malaysian restaurant | Mexican restaurant |
44
+ | Noodle shop | Pho restaurant | Pizza restaurant |
45
+ | Ramen restaurant | Restaurant | Restaurant or cafe |
46
+ | Rice cake shop | Sandwich shop | Seafood restaurant |
47
+ | Soup shop | Sports bar | Steak house |
48
+ | Sushi restaurant | Takeout Restaurant | Tiffin center |
49
+ | Udon noodle restaurant | Vegan restaurant | Vegetarian restaurant |
50
+ | Vietnamese restaurant | | |
51
+
52
+ ### 🏨 Lưu trú (Accommodation)
53
+ - Hotel
54
+ - Holiday apartment rental
55
+
56
+ ### 🏃 Thể thao (Sports)
57
+ - Athletic club
58
+ - Badminton court
59
+ - Fitness center
60
+ - Gym
61
+ - Pickleball court
62
+ - Soccer field
63
+ - Sports club
64
+ - Sports complex
65
+ - Tennis court
66
+
67
+ ### 🎮 Giải trí (Entertainment)
68
+ - Board game club
69
+ - Disco club
70
+ - Game store
71
+ - Movie theater
72
+ - Musical club
73
+
74
+ ---
75
+
76
+ ## API Usage
77
+
78
+ ```bash
79
+ POST /api/v1/nearby
80
+ {
81
+ "lat": 16.0626442,
82
+ "lng": 108.2462143,
83
+ "max_distance_km": 5,
84
+ "category": "coffee"
85
+ }
86
+ ```
87
+
88
+ > **Note**: Category filter sử dụng `CONTAINS` nên chỉ cần keyword (vd: `coffee` match `Coffee shop`).
docs/phase1.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ĐỀ XUẤT GIẢI PHÁP: HỆ THỐNG MULTI-MODAL CONTEXTUAL AGENT (MMCA)
2
+
3
+ ## 1. Tổng quan (Executive Summary)
4
+ Dự án nhằm mục tiêu xây dựng một **Intelligent Agent (Tác nhân thông minh)** có khả năng xử lý đa phương thức (văn bản, hình ảnh) và dữ liệu không gian (địa điểm).
5
+
6
+ Giải pháp sử dụng kiến trúc **Model Context Protocol (MCP)** để tiêu chuẩn hóa kết nối giữa Agent và các công cụ dữ liệu. Hệ thống kết hợp sức mạnh của **Vector Database (Supabase/pgvector)** cho việc tìm kiếm ngữ nghĩa/hình ảnh và **Graph Database (Neo4j)** cho việc truy vấn các mối quan hệ địa lý phức tạp.
7
+
8
+ ## 2. Kiến trúc Hệ thống (Architecture)
9
+
10
+
11
+
12
+ Hệ thống hoạt động theo mô hình **Agent-Centric Orchestration**, trong đó Agent chính đóng vai trò là nhạc trưởng, phân tích intent (ý định) của người dùng để gọi đúng công cụ (Tool) thông qua giao thức MCP.
13
+
14
+ ### Các thành phần chính (Tech Stack):
15
+ 1. **Orchestrator (Agent):** LLM (Large Language Model) có khả năng Function Calling.
16
+ 2. **MCP Server:** Middleware chứa logic của 3 tools.
17
+ 3. **Vector Store:** **Supabase** với extension **pgvector**.
18
+ 4. **Knowledge Graph:** **Neo4j** (hỗ trợ Spatial & Graph algorithms).
19
+
20
+ ---
21
+
22
+ ## 3. Chi tiết các Công cụ MCP (MCP Tools Definition)
23
+
24
+ Agent sẽ được kết nối với một MCP Server expose ra 3 tools chính sau đây:
25
+
26
+ ### Tool 1: `retrieve_context_text` (RAG Text)
27
+ * **Mục đích:** Tìm kiếm thông tin chi tiết, mô tả, đánh giá hoặc menu từ kho dữ liệu văn bản.
28
+ * **Công nghệ:** **Supabase + pgvector**.
29
+ * **Cơ chế:**
30
+ 1. Input query được chuyển đổi thành vector (embedding).
31
+ 2. Thực hiện Similarity Search (Cosine Similarity) trên bảng `text_embeddings` trong Supabase.
32
+ 3. Trả về các đoạn text (chunks) có độ tương đồng cao nhất.
33
+ * **Use-case:** "Tìm các quán có món Phở được review là nước dùng đậm đà."
34
+
35
+ ### Tool 2: `retrieve_similar_visuals` (RAG Image)
36
+ * **Mục đích:** Tìm kiếm các địa điểm hoặc vật thể có đặc điểm hình ảnh tương đồng với ảnh đầu vào (Vibe search/Visual search).
37
+ * **Công nghệ:** **Supabase + pgvector** (Lưu trữ Image Embeddings - ví dụ dùng model CLIP).
38
+ * **Cơ chế:**
39
+ 1. Ảnh input được đưa qua model embedding để lấy vector.
40
+ 2. Truy vấn pgvector để tìm các ảnh đã lưu trữ có vector gần nhất.
41
+ 3. Map từ ID ảnh sang thông tin thực thể (ví dụ: tìm quán cafe có phong cách decor giống ảnh này).
42
+ * **Use-case:** "Tìm chỗ nào có không gian giống trong bức ảnh này."
43
+
44
+ ### Tool 3: `find_nearby_places` (Graph Spatial)
45
+ * **Mục đích:** Tìm kiếm địa điểm dựa trên vị trí địa lý và loại hình dịch vụ (POIs).
46
+ * **Công nghệ:** **Neo4j** (Spatial capabilities & Relationship traversal).
47
+ * **Cơ chế:**
48
+ 1. Sử dụng Point data type trong Neo4j để lưu tọa độ.
49
+ 2. Query Cypher để tìm các node `Place` có quan hệ `NEAR` hoặc tính khoảng cách Euclidean/Haversine từ điểm input.
50
+ 3. Filter theo Label/Property (ví dụ: `Type: 'Cafe'`, `Type: 'Restaurant'`).
51
+ * **Use-case:** "Tìm quán cafe gần khách sạn Hilton nhất."
52
+
53
+ ---
54
+
55
+ ## 4. Luồng xử lý của Agent (Agent Prompting & Workflow)
56
+
57
+ Agent sẽ được cấu hình với **System Prompt** đặc thù để có khả năng "ReAct" (Reasoning + Acting):
58
+
59
+ > **System Prompt Strategy:**
60
+ > *"Bạn là một trợ lý du lịch thông minh. Bạn có quyền truy cập vào 3 công cụ qua MCP.
61
+ > - Khi người dùng hỏi về vị trí, khoảng cách, hoặc 'gần đây', hãy ưu tiên dùng `find_nearby_places`.
62
+ > - Khi người dùng đưa ra một bức ảnh hoặc mô tả về màu sắc, phong cách hình ảnh, hãy dùng `retrieve_similar_visuals`.
63
+ > - Khi người dùng hỏi chi tiết về nội dung, menu, đánh giá, hãy dùng `retrieve_context_text`.
64
+ > Lưu ý: Bạn có thể cần gọi nhiều tools tuần tự hoặc song song để tổng hợp câu trả lời cuối cùng."*
65
+
66
+ ### Kịch bản ví dụ (User Journey):
67
+ **User Input:** *"Tìm cho tôi quán cafe nào gần khách sạn Rex, có không gian xanh mát giống như bức ảnh tôi gửi này và xem menu có bán Bạc Xỉu không?"*
68
+
69
+ **Agent Execution Plan:**
70
+ 1. **Step 1 (Graph):** Gọi `find_nearby_places(location="Rex Hotel", type="Cafe", radius=500m)`.
71
+ * *Result:* List [Cafe A, Cafe B, Cafe C].
72
+ 2. **Step 2 (RAG Image):** Với danh sách trên, gọi `retrieve_similar_visuals(image=input_img, filter_ids=[A, B, C])`.
73
+ * *Result:* Cafe B có điểm tương đồng cao nhất (về không gian xanh).
74
+ 3. **Step 3 (RAG Text):** Gọi `retrieve_context_text(query="menu Bạc Xỉu", entity_id="Cafe B")`.
75
+ * *Result:* Tìm thấy text "Cafe B nổi tiếng với món Bạc Xỉu cốt dừa".
76
+ 4. **Final Response:** "Tôi tìm thấy **Cafe B** cách khách sạn Rex 200m. Quán có không gian vườn xanh rất giống ảnh bạn gửi và trong menu có món Bạc Xỉu được đánh giá cao."
77
+
78
+ ---
79
+
80
+ ## 5. Tại sao chọn Tech Stack này? (Justification)
81
+
82
+ | Công nghệ | Vai trò | Tại sao chọn? |
83
+ | :--- | :--- | :--- |
84
+ | **MCP (Model Context Protocol)** | Giao tiếp | Chuẩn hóa việc kết nối LLM với các nguồn dữ liệu rời rạc, dễ dàng mở rộng thêm tool sau này mà không cần sửa code Agent quá nhiều. |
85
+ | **Neo4j** | Graph DB | Xử lý truy vấn không gian (Spatial) và các mối quan hệ (ví dụ: "quán cafe gần rạp phim") tốt hơn và trực quan hơn nhiều so với SQL truyền thống. |
86
+ | **Supabase + pgvector** | Vector DB | Giải pháp Open-source mạnh mẽ, tích hợp sẵn PostgreSQL, chi phí thấp và hiệu năng cao cho việc lưu trữ embeddings văn bản và hình ảnh. |
docs/schema_supabase.txt ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- WARNING: This schema is for context only and is not meant to be run.
2
+ -- Table order and constraints may not be valid for execution.
3
+
4
+ CREATE TABLE public.bookings (
5
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
6
+ user_id uuid NOT NULL,
7
+ itinerary_id uuid,
8
+ stop_id uuid,
9
+ provider text NOT NULL,
10
+ type text NOT NULL,
11
+ external_id text NOT NULL,
12
+ status text NOT NULL DEFAULT 'pending'::text,
13
+ price numeric,
14
+ currency text NOT NULL DEFAULT 'VND'::text,
15
+ place_id text,
16
+ raw_request jsonb,
17
+ raw_response jsonb,
18
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
19
+ updated_at timestamp with time zone NOT NULL DEFAULT now(),
20
+ CONSTRAINT bookings_pkey PRIMARY KEY (id),
21
+ CONSTRAINT bookings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id),
22
+ CONSTRAINT bookings_itinerary_id_fkey FOREIGN KEY (itinerary_id) REFERENCES public.itineraries(id),
23
+ CONSTRAINT bookings_stop_id_fkey FOREIGN KEY (stop_id) REFERENCES public.itinerary_stops(id)
24
+ );
25
+ CREATE TABLE public.itineraries (
26
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
27
+ user_id uuid NOT NULL,
28
+ title text NOT NULL,
29
+ start_date date,
30
+ end_date date,
31
+ total_days integer NOT NULL CHECK (total_days >= 1),
32
+ total_budget numeric,
33
+ currency text NOT NULL DEFAULT 'VND'::text,
34
+ meta jsonb,
35
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
36
+ updated_at timestamp with time zone NOT NULL DEFAULT now(),
37
+ CONSTRAINT itineraries_pkey PRIMARY KEY (id),
38
+ CONSTRAINT itineraries_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id)
39
+ );
40
+ CREATE TABLE public.itinerary_stops (
41
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
42
+ itinerary_id uuid NOT NULL,
43
+ day_index integer NOT NULL CHECK (day_index >= 1),
44
+ order_index integer NOT NULL CHECK (order_index >= 1),
45
+ place_id text NOT NULL,
46
+ arrival_time timestamp with time zone,
47
+ stay_minutes integer,
48
+ notes text,
49
+ tags ARRAY,
50
+ snapshot jsonb,
51
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
52
+ updated_at timestamp with time zone NOT NULL DEFAULT now(),
53
+ CONSTRAINT itinerary_stops_pkey PRIMARY KEY (id),
54
+ CONSTRAINT itinerary_stops_itinerary_id_fkey FOREIGN KEY (itinerary_id) REFERENCES public.itineraries(id)
55
+ );
56
+ CREATE TABLE public.place_image_embeddings (
57
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
58
+ place_id text NOT NULL,
59
+ embedding USER-DEFINED,
60
+ image_url text,
61
+ metadata jsonb,
62
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
63
+ CONSTRAINT place_image_embeddings_pkey PRIMARY KEY (id)
64
+ );
65
+ CREATE TABLE public.place_text_embeddings (
66
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
67
+ place_id text NOT NULL,
68
+ embedding USER-DEFINED,
69
+ content_type text,
70
+ source_text text,
71
+ metadata jsonb,
72
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
73
+ CONSTRAINT place_text_embeddings_pkey PRIMARY KEY (id)
74
+ );
75
+ CREATE TABLE public.places_metadata (
76
+ place_id text NOT NULL,
77
+ name text NOT NULL,
78
+ name_vi text,
79
+ category text,
80
+ address text,
81
+ rating numeric,
82
+ price_min numeric,
83
+ price_max numeric,
84
+ tags ARRAY DEFAULT '{}'::text[],
85
+ coordinates USER-DEFINED,
86
+ raw_data jsonb DEFAULT '{}'::jsonb,
87
+ created_at timestamp with time zone DEFAULT now(),
88
+ updated_at timestamp with time zone DEFAULT now(),
89
+ CONSTRAINT places_metadata_pkey PRIMARY KEY (place_id)
90
+ );
91
+ CREATE TABLE public.profiles (
92
+ id uuid NOT NULL,
93
+ full_name text NOT NULL DEFAULT ''::text,
94
+ phone text,
95
+ role text NOT NULL DEFAULT 'tourist'::text CHECK (role = ANY (ARRAY['tourist'::text, 'driver'::text, 'admin'::text])),
96
+ locale text NOT NULL DEFAULT 'vi_VN'::text,
97
+ avatar_url text,
98
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
99
+ updated_at timestamp with time zone NOT NULL DEFAULT now(),
100
+ CONSTRAINT profiles_pkey PRIMARY KEY (id),
101
+ CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id)
102
+ );
103
+ CREATE TABLE public.spatial_ref_sys (
104
+ srid integer NOT NULL CHECK (srid > 0 AND srid <= 998999),
105
+ auth_name character varying,
106
+ auth_srid integer,
107
+ srtext character varying,
108
+ proj4text character varying,
109
+ CONSTRAINT spatial_ref_sys_pkey PRIMARY KEY (srid)
110
+ );
pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "localmate-backend-v2"
3
+ version = "0.2.0"
4
+ description = "LocalMate Da Nang - Multi-Modal Contextual Agent API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.115.0",
9
+ "uvicorn[standard]>=0.32.0",
10
+ "sqlalchemy>=2.0.0",
11
+ "asyncpg>=0.30.0",
12
+ "greenlet>=3.0.0",
13
+ "pydantic>=2.10.0",
14
+ "pydantic-settings>=2.6.0",
15
+ "supabase>=2.10.0",
16
+ "neo4j>=5.26.0",
17
+ "google-genai>=1.0.0",
18
+ "pgvector>=0.3.0",
19
+ "python-dotenv>=1.0.0",
20
+ "httpx>=0.28.0",
21
+ "python-multipart>=0.0.9",
22
+ # Image embedding (SigLIP local)
23
+ "torch>=2.0.0",
24
+ "open_clip_torch>=2.24.0",
25
+ "pillow>=10.0.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0.0",
31
+ "pytest-asyncio>=0.24.0",
32
+ "httpx>=0.28.0",
33
+ ]
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["app"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ testpaths = ["tests"]
45
+ asyncio_default_fixture_loop_scope = "function"
tests/react_comparison_report.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LocalMate Agent Test Report
2
+
3
+ **Generated:** 2025-12-17 05:11:43
4
+
5
+ ## Summary
6
+
7
+ | Metric | Single Mode | ReAct Mode |
8
+ |--------|-------------|------------|
9
+ | Total Tests | 1 | 1 |
10
+ | Success | 1 | 1 |
11
+ | Avg Duration | 10653ms | 13297ms |
12
+
13
+ ---
14
+
15
+ ## Detailed Results
16
+
17
+ ### Test Case 1: Simple text search - no location
18
+
19
+ **Query:** `Quán cafe view đẹp`
20
+
21
+ #### Single Mode
22
+
23
+ - **Status:** ✅ Success
24
+ - **Duration:** 10653ms
25
+ - **Tools Used:** retrieve_context_text
26
+
27
+ **Workflow:**
28
+ - Intent Analysis
29
+ Tool: `None` | Results: 0
30
+ - Tool Planning
31
+ Tool: `None` | Results: 0
32
+ - Execute retrieve_context_text
33
+ Tool: `retrieve_context_text` | Results: 5
34
+ - LLM Synthesis
35
+ Tool: `None` | Results: 0
36
+
37
+ **Response Preview:**
38
+ > Chào bạn! Dựa trên yêu cầu "quán cafe view đẹp" của bạn, mình gợi ý một số địa điểm nổi bật ở Đà Nẵng sau đây nhé:
39
+
40
+ ☕ **Top 3 quán cafe view đẹp đáng thử:**
41
+
42
+ 1. **Nhớ Một Người**
43
+ - Địa chỉ: 06 Tr...
44
+
45
+ #### ReAct Mode
46
+
47
+ - **Status:** ✅ Success
48
+ - **Duration:** 13297ms
49
+ - **Tools Used:** retrieve_context_text
50
+ - **Steps:** 3
51
+ - **Intent Detected:** react_multi_step
52
+
53
+ **Workflow Steps:**
54
+ - Step 1: User đang tìm quán cafe có view đẹp ở Đà Nẵng. Đây...
55
+ Tool: `retrieve_context_text` | Results: 10
56
+ - Step 2: User đang tìm quán cafe có view đẹp ở Đà Nẵng. Tôi...
57
+ Tool: `retrieve_context_text` | Results: 10
58
+ - Step 3: Tôi đã có tổng cộng 10 kết quả từ hai lần gọi retr...
59
+ Tool: `None` | Results: 0
60
+
61
+ **Response Preview:**
62
+ > Chào bạn! Dưới đây là một số quán cafe có view đẹp tại Đà Nẵng mà bạn có thể tham khảo:
63
+
64
+ ☕ **Nhớ Một Người**
65
+ - Địa chỉ: 06 Trường Thi 5, Hòa Thuận Tây, Hải Châu
66
+ - Rating: 4.9/5 ⭐
67
+ - Đặc điểm: Khô...
68
+
69
+ ---
70
+
71
+ ## Analysis
72
+
73
+ ### Tool Usage Comparison
74
+
75
+ | Test | Single Mode Tools | ReAct Mode Tools | ReAct Steps |
76
+ |------|-------------------|------------------|-------------|
77
+ | 1 | retrieve_context_text | retrieve_context_text | 3 |
78
+
79
+
80
+ ### Key Observations
81
+
82
+ 1. **Multi-tool queries**: ReAct mode can chain multiple tools for complex queries
83
+ 2. **Single-tool queries**: Both modes perform similarly for simple queries
84
+ 3. **Reasoning steps**: ReAct mode shows explicit reasoning before each tool call
85
+
tests/test_react_comparison.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LocalMate Agent Test Script - Single vs ReAct Mode Comparison
3
+
4
+ Tests 10 queries in both modes:
5
+ - Single mode: configurable delay between queries
6
+ - ReAct mode: configurable delay between queries
7
+ - Configurable delay between modes
8
+
9
+ Generates detailed report with all step inputs/outputs.
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import time
15
+ from datetime import datetime
16
+ import httpx
17
+
18
+ # =============================================================================
19
+ # CONFIGURATION - Adjust these values as needed
20
+ # =============================================================================
21
+
22
+ # API Settings
23
+ API_BASE = "http://localhost:8000/api/v1"
24
+ USER_ID = "test_comparison"
25
+
26
+ # Delay Settings (in seconds)
27
+ SINGLE_MODE_DELAY = 10 # Delay between queries in single mode
28
+ REACT_MODE_DELAY = 60 # Delay between queries in ReAct mode
29
+ MODE_SWITCH_DELAY = 60 # Delay between switching modes
30
+ REQUEST_TIMEOUT = 120 # Timeout for each API request
31
+
32
+ # =============================================================================
33
+
34
+ # Test Cases - 10 queries covering different scenarios
35
+ TEST_CASES = [
36
+ {
37
+ "id": 1,
38
+ "query": "Quán cafe view đẹp",
39
+ "description": "Simple text search - no location",
40
+ "expected_tools": ["retrieve_context_text"],
41
+ },
42
+ # {
43
+ # "id": 2,
44
+ # "query": "Nhà hàng gần bãi biển Mỹ Khê",
45
+ # "description": "Location-based search",
46
+ # "expected_tools": ["find_nearby_places"],
47
+ # },
48
+ # {
49
+ # "id": 3,
50
+ # "query": "Quán cafe có không gian xanh mát gần Cầu Rồng",
51
+ # "description": "Complex: location + feature (should use multiple tools in ReAct)",
52
+ # "expected_tools": ["find_nearby_places", "retrieve_context_text"],
53
+ # },
54
+ # {
55
+ # "id": 4,
56
+ # "query": "Phở ngon giá rẻ",
57
+ # "description": "Food-specific text search",
58
+ # "expected_tools": ["retrieve_context_text"],
59
+ # },
60
+ # {
61
+ # "id": 5,
62
+ # "query": "Địa điểm checkin đẹp gần Bà Nà",
63
+ # "description": "Location + activity type",
64
+ # "expected_tools": ["find_nearby_places"],
65
+ # },
66
+ # {
67
+ # "id": 6,
68
+ # "query": "Quán ăn hải sản có view sông gần trung tâm",
69
+ # "description": "Complex: location + category + feature",
70
+ # "expected_tools": ["find_nearby_places", "retrieve_context_text"],
71
+ # },
72
+ # {
73
+ # "id": 7,
74
+ # "query": "Khách sạn 5 sao gần biển",
75
+ # "description": "Hotel + location search",
76
+ # "expected_tools": ["find_nearby_places"],
77
+ # },
78
+ # {
79
+ # "id": 8,
80
+ # "query": "Quán bar có view đẹp về đêm",
81
+ # "description": "Nightlife text search",
82
+ # "expected_tools": ["retrieve_context_text"],
83
+ # },
84
+ # {
85
+ # "id": 9,
86
+ # "query": "Cafe rooftop gần Sơn Trà có coffee ngon",
87
+ # "description": "Complex: location + feature + quality",
88
+ # "expected_tools": ["find_nearby_places", "retrieve_context_text"],
89
+ # },
90
+ # {
91
+ # "id": 10,
92
+ # "query": "Nhà hàng Việt Nam authentic gần Rex Hotel",
93
+ # "description": "Specific location + category + style",
94
+ # "expected_tools": ["find_nearby_places", "retrieve_context_text"],
95
+ # },
96
+ ]
97
+
98
+
99
+ async def run_test(client: httpx.AsyncClient, test_case: dict, react_mode: bool) -> dict:
100
+ """Run a single test case and return results."""
101
+ start_time = time.time()
102
+
103
+ try:
104
+ response = await client.post(
105
+ f"{API_BASE}/chat",
106
+ json={
107
+ "message": test_case["query"],
108
+ "user_id": USER_ID,
109
+ "provider": "MegaLLM",
110
+ "react_mode": react_mode,
111
+ "max_steps": 5,
112
+ },
113
+ timeout=float(REQUEST_TIMEOUT),
114
+ )
115
+
116
+ duration = (time.time() - start_time) * 1000
117
+
118
+ if response.status_code == 200:
119
+ data = response.json()
120
+ return {
121
+ "success": True,
122
+ "test_id": test_case["id"],
123
+ "query": test_case["query"],
124
+ "description": test_case["description"],
125
+ "react_mode": react_mode,
126
+ "response": data.get("response", "")[:300],
127
+ "workflow": data.get("workflow", {}),
128
+ "tools_used": data.get("tools_used", []),
129
+ "api_duration_ms": data.get("duration_ms", 0),
130
+ "total_duration_ms": duration,
131
+ }
132
+ else:
133
+ return {
134
+ "success": False,
135
+ "test_id": test_case["id"],
136
+ "query": test_case["query"],
137
+ "react_mode": react_mode,
138
+ "error": f"HTTP {response.status_code}: {response.text[:200]}",
139
+ "total_duration_ms": duration,
140
+ }
141
+
142
+ except Exception as e:
143
+ return {
144
+ "success": False,
145
+ "test_id": test_case["id"],
146
+ "query": test_case["query"],
147
+ "react_mode": react_mode,
148
+ "error": str(e),
149
+ "total_duration_ms": (time.time() - start_time) * 1000,
150
+ }
151
+
152
+
153
+ def format_workflow_steps(workflow: dict) -> str:
154
+ """Format workflow steps for report."""
155
+ steps = workflow.get("steps", [])
156
+ if not steps:
157
+ return "No steps recorded"
158
+
159
+ lines = []
160
+ for step in steps:
161
+ tool = step.get("tool", "N/A")
162
+ purpose = step.get("purpose", "")
163
+ results = step.get("results", 0)
164
+ lines.append(f" - {step.get('step', 'Unknown')}")
165
+ lines.append(f" Tool: `{tool}` | Results: {results}")
166
+
167
+ return "\n".join(lines)
168
+
169
+
170
+ def generate_report(single_results: list, react_results: list) -> str:
171
+ """Generate detailed markdown report."""
172
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
173
+
174
+ report = f"""# LocalMate Agent Test Report
175
+
176
+ **Generated:** {timestamp}
177
+
178
+ ## Summary
179
+
180
+ | Metric | Single Mode | ReAct Mode |
181
+ |--------|-------------|------------|
182
+ | Total Tests | {len(single_results)} | {len(react_results)} |
183
+ | Success | {sum(1 for r in single_results if r.get('success'))} | {sum(1 for r in react_results if r.get('success'))} |
184
+ | Avg Duration | {sum(r.get('api_duration_ms', 0) for r in single_results if r.get('success')) / max(1, sum(1 for r in single_results if r.get('success'))):.0f}ms | {sum(r.get('api_duration_ms', 0) for r in react_results if r.get('success')) / max(1, sum(1 for r in react_results if r.get('success'))):.0f}ms |
185
+
186
+ ---
187
+
188
+ ## Detailed Results
189
+
190
+ """
191
+
192
+ for i, (single, react) in enumerate(zip(single_results, react_results)):
193
+ test_id = single.get("test_id", i + 1)
194
+ query = single.get("query", "N/A")
195
+ description = single.get("description", "")
196
+
197
+ report += f"""### Test Case {test_id}: {description}
198
+
199
+ **Query:** `{query}`
200
+
201
+ #### Single Mode
202
+
203
+ """
204
+ if single.get("success"):
205
+ report += f"""- **Status:** ✅ Success
206
+ - **Duration:** {single.get('api_duration_ms', 0):.0f}ms
207
+ - **Tools Used:** {', '.join(single.get('tools_used', [])) or 'None'}
208
+
209
+ **Workflow:**
210
+ {format_workflow_steps(single.get('workflow', {}))}
211
+
212
+ **Response Preview:**
213
+ > {single.get('response', 'N/A')[:200]}...
214
+
215
+ """
216
+ else:
217
+ report += f"""- **Status:** ❌ Failed
218
+ - **Error:** {single.get('error', 'Unknown')}
219
+
220
+ """
221
+
222
+ report += """#### ReAct Mode
223
+
224
+ """
225
+ if react.get("success"):
226
+ workflow = react.get("workflow", {})
227
+ report += f"""- **Status:** ✅ Success
228
+ - **Duration:** {react.get('api_duration_ms', 0):.0f}ms
229
+ - **Tools Used:** {', '.join(react.get('tools_used', [])) or 'None'}
230
+ - **Steps:** {len(workflow.get('steps', []))}
231
+ - **Intent Detected:** {workflow.get('intent_detected', 'N/A')}
232
+
233
+ **Workflow Steps:**
234
+ {format_workflow_steps(workflow)}
235
+
236
+ **Response Preview:**
237
+ > {react.get('response', 'N/A')[:200]}...
238
+
239
+ """
240
+ else:
241
+ report += f"""- **Status:** ❌ Failed
242
+ - **Error:** {react.get('error', 'Unknown')}
243
+
244
+ """
245
+
246
+ report += "---\n\n"
247
+
248
+ # Comparison analysis
249
+ report += """## Analysis
250
+
251
+ ### Tool Usage Comparison
252
+
253
+ | Test | Single Mode Tools | ReAct Mode Tools | ReAct Steps |
254
+ |------|-------------------|------------------|-------------|
255
+ """
256
+
257
+ for single, react in zip(single_results, react_results):
258
+ test_id = single.get("test_id", "?")
259
+ single_tools = ", ".join(single.get("tools_used", [])) if single.get("success") else "❌"
260
+ react_tools = ", ".join(react.get("tools_used", [])) if react.get("success") else "❌"
261
+ react_steps = len(react.get("workflow", {}).get("steps", [])) if react.get("success") else 0
262
+ report += f"| {test_id} | {single_tools} | {react_tools} | {react_steps} |\n"
263
+
264
+ report += """
265
+
266
+ ### Key Observations
267
+
268
+ 1. **Multi-tool queries**: ReAct mode can chain multiple tools for complex queries
269
+ 2. **Single-tool queries**: Both modes perform similarly for simple queries
270
+ 3. **Reasoning steps**: ReAct mode shows explicit reasoning before each tool call
271
+
272
+ """
273
+
274
+ return report
275
+
276
+
277
+ async def main():
278
+ """Main test runner."""
279
+ print("=" * 60)
280
+ print("LocalMate Agent Mode Comparison Test")
281
+ print("=" * 60)
282
+ print()
283
+
284
+ single_results = []
285
+ react_results = []
286
+
287
+ async with httpx.AsyncClient() as client:
288
+ # Test Single Mode
289
+ print(f"📌 Running Single Mode Tests ({SINGLE_MODE_DELAY}s delay)...")
290
+ print("-" * 40)
291
+
292
+ for test in TEST_CASES:
293
+ print(f" Test {test['id']}: {test['query'][:40]}...")
294
+ result = await run_test(client, test, react_mode=False)
295
+ single_results.append(result)
296
+
297
+ status = "✅" if result.get("success") else "❌"
298
+ tools = ", ".join(result.get("tools_used", [])) or "None"
299
+ print(f" {status} Tools: {tools} | {result.get('api_duration_ms', 0):.0f}ms")
300
+
301
+ if test["id"] < len(TEST_CASES):
302
+ await asyncio.sleep(SINGLE_MODE_DELAY)
303
+
304
+ print()
305
+ print(f"⏸️ Waiting {MODE_SWITCH_DELAY}s before ReAct mode...")
306
+ await asyncio.sleep(MODE_SWITCH_DELAY)
307
+
308
+ # Test ReAct Mode
309
+ print()
310
+ print(f"🧠 Running ReAct Mode Tests ({REACT_MODE_DELAY}s delay)...")
311
+ print("-" * 40)
312
+
313
+ for test in TEST_CASES:
314
+ print(f" Test {test['id']}: {test['query'][:40]}...")
315
+ result = await run_test(client, test, react_mode=True)
316
+ react_results.append(result)
317
+
318
+ status = "✅" if result.get("success") else "❌"
319
+ tools = ", ".join(result.get("tools_used", [])) or "None"
320
+ steps = len(result.get("workflow", {}).get("steps", []))
321
+ print(f" {status} Tools: {tools} | Steps: {steps} | {result.get('api_duration_ms', 0):.0f}ms")
322
+
323
+ if test["id"] < len(TEST_CASES):
324
+ await asyncio.sleep(REACT_MODE_DELAY)
325
+
326
+ # Generate report
327
+ print()
328
+ print("📝 Generating report...")
329
+ report = generate_report(single_results, react_results)
330
+
331
+ # Use absolute path based on script location
332
+ import os
333
+ script_dir = os.path.dirname(os.path.abspath(__file__))
334
+ report_path = os.path.join(script_dir, "react_comparison_report.md")
335
+ with open(report_path, "w", encoding="utf-8") as f:
336
+ f.write(report)
337
+
338
+ print(f"✅ Report saved to: {report_path}")
339
+ print()
340
+ print("=" * 60)
341
+ print("Test Complete!")
342
+ print("=" * 60)
343
+
344
+
345
+ if __name__ == "__main__":
346
+ asyncio.run(main())