Commit
·
ca7a2c2
0
Parent(s):
Initial HF deployment
Browse files- .dockerignore +36 -0
- .env.example +24 -0
- .gitignore +8 -0
- DEPLOYMENT.md +45 -0
- Dockerfile +64 -0
- README.md +56 -0
- app/__init__.py +1 -0
- app/agent/__init__.py +1 -0
- app/agent/mmca_agent.py +478 -0
- app/agent/react_agent.py +335 -0
- app/agent/reasoning.py +162 -0
- app/agent/state.py +96 -0
- app/api/__init__.py +1 -0
- app/api/router.py +469 -0
- app/core/__init__.py +1 -0
- app/core/config.py +45 -0
- app/main.py +101 -0
- app/mcp/__init__.py +1 -0
- app/mcp/tools/__init__.py +118 -0
- app/mcp/tools/graph_tool.py +433 -0
- app/mcp/tools/text_tool.py +173 -0
- app/mcp/tools/visual_tool.py +169 -0
- app/planner/__init__.py +1 -0
- app/planner/models.py +100 -0
- app/planner/router.py +253 -0
- app/planner/service.py +297 -0
- app/planner/tsp.py +200 -0
- app/shared/__init__.py +1 -0
- app/shared/chat_history.py +161 -0
- app/shared/db/__init__.py +1 -0
- app/shared/db/session.py +30 -0
- app/shared/integrations/__init__.py +1 -0
- app/shared/integrations/embedding_client.py +120 -0
- app/shared/integrations/gemini_client.py +127 -0
- app/shared/integrations/megallm_client.py +133 -0
- app/shared/integrations/neo4j_client.py +52 -0
- app/shared/integrations/siglip_client.py +146 -0
- app/shared/integrations/supabase_client.py +11 -0
- app/shared/logger.py +208 -0
- app/shared/models/__init__.py +1 -0
- app/shared/models/base.py +26 -0
- docs/TRIP_PLANNER_PLAN.md +426 -0
- docs/neo4j_test_report.md +88 -0
- docs/phase1.md +86 -0
- docs/schema_supabase.txt +110 -0
- pyproject.toml +45 -0
- tests/react_comparison_report.md +85 -0
- 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())
|