Commit ·
102dd4f
0
Parent(s):
first commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .cursor/settings.json +7 -0
- .env.example +32 -0
- .gitignore +12 -0
- Dockerfile +14 -0
- README.md +33 -0
- app/__init__.py +3 -0
- app/ai/__init__.py +4 -0
- app/ai/orchestrator.py +138 -0
- app/ai/providers.py +154 -0
- app/ai/tool_schemas.py +357 -0
- app/api/__init__.py +3 -0
- app/api/deps.py +25 -0
- app/api/routes.py +244 -0
- app/config.py +53 -0
- app/database/__init__.py +3 -0
- app/database/supabase.py +460 -0
- app/main.py +26 -0
- app/models/api.py +53 -0
- app/models/domain.py +43 -0
- app/services/__init__.py +3 -0
- app/services/admin_service.py +66 -0
- app/services/container.py +51 -0
- app/services/conversation_service.py +170 -0
- app/services/embedding_service.py +90 -0
- app/services/trip_indexing.py +45 -0
- app/tools/__init__.py +3 -0
- app/tools/handlers.py +742 -0
- app/tools/registry.py +23 -0
- app/utils/departure.py +192 -0
- app/utils/logging.py +8 -0
- app/utils/time.py +6 -0
- app/whatsapp/client.py +46 -0
- app/whatsapp/parser.py +43 -0
- app/whatsapp/security.py +22 -0
- docker-compose.yml +10 -0
- main.py +3 -0
- prompts/falsa_info.md +24 -0
- prompts/system.md +30 -0
- prompts/system_driver.md +22 -0
- prompts/system_new_user.md +26 -0
- prompts/system_passenger.md +24 -0
- pyproject.toml +34 -0
- requirements.txt +12 -0
- scripts/seed_info.py +28 -0
- scripts/setup_and_seed.sh +11 -0
- scripts/sync_trips.py +29 -0
- scripts/test_scripts.py +39 -0
- supabase/migrations/202605210001_initial_schema.sql +135 -0
- supabase/migrations/202605310001_driver_trip_departure_date_time.sql +55 -0
- supabase/migrations/202606020001_supabase_jina_vectors.sql +212 -0
.cursor/settings.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"plugins": {
|
| 3 |
+
"supabase": {
|
| 4 |
+
"enabled": true
|
| 5 |
+
}
|
| 6 |
+
}
|
| 7 |
+
}
|
.env.example
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
APP_NAME=FALSA
|
| 2 |
+
ENVIRONMENT=development
|
| 3 |
+
APP_TIMEZONE=Asia/Aden
|
| 4 |
+
LOG_LEVEL=INFO
|
| 5 |
+
|
| 6 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 7 |
+
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
| 8 |
+
|
| 9 |
+
PINECONE_API_KEY=your-pinecone-api-key
|
| 10 |
+
PINECONE_CLOUD=aws
|
| 11 |
+
PINECONE_REGION=us-east-1
|
| 12 |
+
PINECONE_INFO_INDEX=falsa-info
|
| 13 |
+
PINECONE_TRIPS_INDEX=falsa-trips
|
| 14 |
+
PINECONE_EMBED_MODEL=multilingual-e5-large
|
| 15 |
+
PINECONE_NAMESPACE=default
|
| 16 |
+
|
| 17 |
+
GROQ_API_KEY=your-groq-api-key
|
| 18 |
+
GROQ_MODEL=your-groq-tool-calling-model
|
| 19 |
+
HF_TOKEN=your-hugging-face-token
|
| 20 |
+
HF_MODEL=your-hugging-face-tool-calling-model
|
| 21 |
+
AI_TEMPERATURE=0.2
|
| 22 |
+
AI_MAX_TOOL_ITERATIONS=3
|
| 23 |
+
REQUEST_TIMEOUT_SECONDS=20
|
| 24 |
+
|
| 25 |
+
WHATSAPP_GRAPH_URL=https://graph.facebook.com
|
| 26 |
+
WHATSAPP_API_VERSION=v20.0
|
| 27 |
+
WHATSAPP_VERIFY_TOKEN=choose-a-webhook-verify-token
|
| 28 |
+
WHATSAPP_APP_SECRET=your-meta-app-secret
|
| 29 |
+
WHATSAPP_ACCESS_TOKEN=your-whatsapp-cloud-api-token
|
| 30 |
+
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
|
| 31 |
+
|
| 32 |
+
ADMIN_API_KEY=choose-a-long-random-admin-key
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
.deps/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
.coverage
|
| 10 |
+
htmlcov/
|
| 11 |
+
tests/notes.txt
|
| 12 |
+
whatsappDebug/
|
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY . .
|
| 12 |
+
|
| 13 |
+
EXPOSE 8000
|
| 14 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FALSA
|
| 2 |
+
|
| 3 |
+
FALSA is an async FastAPI backend for WhatsApp-based AI travel customer service.
|
| 4 |
+
It stores conversations in Supabase, retrieves short-term chat context, uses Groq
|
| 5 |
+
with Hugging Face fallback, calls local tools for FALSA info/trip search/booking
|
| 6 |
+
leads, stores vector embeddings in Supabase with Jina Embeddings, and sends replies
|
| 7 |
+
through Meta WhatsApp Cloud API.
|
| 8 |
+
|
| 9 |
+
## Quick Start
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
cp .env.example .env
|
| 13 |
+
pip install -r requirements.txt
|
| 14 |
+
uvicorn main:app --reload
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Run checks:
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
pytest
|
| 21 |
+
ruff check .
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## Main Endpoints
|
| 25 |
+
|
| 26 |
+
- `GET /healthz`
|
| 27 |
+
- `GET /webhooks/whatsapp`
|
| 28 |
+
- `POST /webhooks/whatsapp`
|
| 29 |
+
- `POST /admin/seed-info`
|
| 30 |
+
- `POST /admin/sync-trips`
|
| 31 |
+
|
| 32 |
+
Apply the SQL in `supabase/migrations` to create the Supabase schema, including the
|
| 33 |
+
pgvector tables and RPC functions, before using the production services.
|
app/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__all__ = ["__version__"]
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
app/ai/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.ai.orchestrator import AIOrchestrator
|
| 2 |
+
from app.ai.providers import GroqChatProvider, HuggingFaceChatProvider
|
| 3 |
+
|
| 4 |
+
__all__ = ["AIOrchestrator", "GroqChatProvider", "HuggingFaceChatProvider"]
|
app/ai/orchestrator.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from app.ai.providers import (
|
| 6 |
+
ChatProvider,
|
| 7 |
+
InvalidToolCallGenerationError,
|
| 8 |
+
ProviderError,
|
| 9 |
+
RetryableProviderError,
|
| 10 |
+
)
|
| 11 |
+
from app.models.domain import AIProviderResponse, ToolCall
|
| 12 |
+
from app.tools.registry import ToolRegistry
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AIOrchestrator:
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
*,
|
| 21 |
+
primary: ChatProvider,
|
| 22 |
+
fallback: ChatProvider,
|
| 23 |
+
temperature: float,
|
| 24 |
+
max_tool_iterations: int,
|
| 25 |
+
) -> None:
|
| 26 |
+
self.primary = primary
|
| 27 |
+
self.fallback = fallback
|
| 28 |
+
self.temperature = temperature
|
| 29 |
+
self.max_tool_iterations = max_tool_iterations
|
| 30 |
+
|
| 31 |
+
async def generate_reply(
|
| 32 |
+
self,
|
| 33 |
+
*,
|
| 34 |
+
messages: list[dict[str, Any]],
|
| 35 |
+
tools: list[dict[str, Any]],
|
| 36 |
+
registry: ToolRegistry,
|
| 37 |
+
) -> str:
|
| 38 |
+
try:
|
| 39 |
+
return await self._run_provider(
|
| 40 |
+
self.primary,
|
| 41 |
+
messages=messages,
|
| 42 |
+
tools=tools,
|
| 43 |
+
registry=registry,
|
| 44 |
+
temperature=self.temperature,
|
| 45 |
+
)
|
| 46 |
+
except InvalidToolCallGenerationError:
|
| 47 |
+
logger.warning("Primary provider generated invalid tool call; retrying once")
|
| 48 |
+
try:
|
| 49 |
+
return await self._run_provider(
|
| 50 |
+
self.primary,
|
| 51 |
+
messages=messages,
|
| 52 |
+
tools=tools,
|
| 53 |
+
registry=registry,
|
| 54 |
+
temperature=max(self.temperature - 0.2, 0.1),
|
| 55 |
+
)
|
| 56 |
+
except RetryableProviderError:
|
| 57 |
+
logger.warning("Primary retry failed; falling back to Hugging Face")
|
| 58 |
+
except RetryableProviderError:
|
| 59 |
+
logger.warning("Primary provider failed; falling back to Hugging Face")
|
| 60 |
+
|
| 61 |
+
return await self._run_provider(
|
| 62 |
+
self.fallback,
|
| 63 |
+
messages=messages,
|
| 64 |
+
tools=tools,
|
| 65 |
+
registry=registry,
|
| 66 |
+
temperature=self.temperature,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
async def _run_provider(
|
| 70 |
+
self,
|
| 71 |
+
provider: ChatProvider,
|
| 72 |
+
*,
|
| 73 |
+
messages: list[dict[str, Any]],
|
| 74 |
+
tools: list[dict[str, Any]],
|
| 75 |
+
registry: ToolRegistry,
|
| 76 |
+
temperature: float,
|
| 77 |
+
) -> str:
|
| 78 |
+
working_messages = [dict(message) for message in messages]
|
| 79 |
+
# need to be edited to reponse quackly instead in enter in a for loop
|
| 80 |
+
for _ in range(self.max_tool_iterations + 1):
|
| 81 |
+
response = await provider.chat(
|
| 82 |
+
working_messages,
|
| 83 |
+
tools=tools,
|
| 84 |
+
tool_choice="auto",
|
| 85 |
+
temperature=temperature,
|
| 86 |
+
)
|
| 87 |
+
if not response.tool_calls:
|
| 88 |
+
content = (response.content or "").strip()
|
| 89 |
+
if content:
|
| 90 |
+
return content
|
| 91 |
+
raise ProviderError(f"{provider.name} returned an empty response")
|
| 92 |
+
logger.info("----++-----"+str(response))
|
| 93 |
+
working_messages.append(_assistant_tool_message(response))
|
| 94 |
+
for tool_call in response.tool_calls:
|
| 95 |
+
# logger.warning("++++++++"+str(tool_call)+ "&&&"+ str(registry))
|
| 96 |
+
result = await _execute_tool_call(registry, tool_call)
|
| 97 |
+
logger.warning("+++++++++++++"+str(result))
|
| 98 |
+
working_messages.append(
|
| 99 |
+
{
|
| 100 |
+
"role": "tool",
|
| 101 |
+
"tool_call_id": tool_call.id,
|
| 102 |
+
"name": tool_call.name,
|
| 103 |
+
"content": json.dumps(result, ensure_ascii=False),
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
"I found that this request needs extra checking. "
|
| 109 |
+
"A support team member will follow up with you shortly."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _assistant_tool_message(response: AIProviderResponse) -> dict[str, Any]:
|
| 114 |
+
if response.raw_message:
|
| 115 |
+
return response.raw_message
|
| 116 |
+
return {
|
| 117 |
+
"role": "assistant",
|
| 118 |
+
"content": response.content,
|
| 119 |
+
"tool_calls": [
|
| 120 |
+
{
|
| 121 |
+
"id": tool_call.id,
|
| 122 |
+
"type": "function",
|
| 123 |
+
"function": {"name": tool_call.name, "arguments": tool_call.arguments},
|
| 124 |
+
}
|
| 125 |
+
for tool_call in response.tool_calls
|
| 126 |
+
],
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def _execute_tool_call(registry: ToolRegistry, tool_call: ToolCall) -> dict[str, Any]:
|
| 131 |
+
try:
|
| 132 |
+
arguments = json.loads(tool_call.arguments or "{}")
|
| 133 |
+
if not isinstance(arguments, dict):
|
| 134 |
+
raise ValueError("Tool arguments must be a JSON object")
|
| 135 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 136 |
+
return {"ok": False, "error": f"Invalid tool arguments: {exc}", "data": {}}
|
| 137 |
+
|
| 138 |
+
return (await registry.execute(tool_call.name, arguments)).to_payload()
|
app/ai/providers.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Protocol
|
| 2 |
+
|
| 3 |
+
from app.config import Settings
|
| 4 |
+
from app.models.domain import AIProviderResponse, ToolCall
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ProviderError(RuntimeError):
|
| 8 |
+
pass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class RetryableProviderError(ProviderError):
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class InvalidToolCallGenerationError(RetryableProviderError):
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ChatProvider(Protocol):
|
| 20 |
+
name: str
|
| 21 |
+
|
| 22 |
+
async def chat(
|
| 23 |
+
self,
|
| 24 |
+
messages: list[dict[str, Any]],
|
| 25 |
+
*,
|
| 26 |
+
tools: list[dict[str, Any]] | None = None,
|
| 27 |
+
tool_choice: str | dict[str, Any] | None = "auto",
|
| 28 |
+
temperature: float = 0.2,
|
| 29 |
+
) -> AIProviderResponse:
|
| 30 |
+
...
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class OpenAICompatibleChatProvider:
|
| 34 |
+
name = "openai-compatible"
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
*,
|
| 39 |
+
api_key: str,
|
| 40 |
+
base_url: str,
|
| 41 |
+
model: str,
|
| 42 |
+
timeout: float,
|
| 43 |
+
name: str,
|
| 44 |
+
) -> None:
|
| 45 |
+
self.api_key = api_key
|
| 46 |
+
self.base_url = base_url
|
| 47 |
+
self.model = model
|
| 48 |
+
self.timeout = timeout
|
| 49 |
+
self.name = name
|
| 50 |
+
self._client: Any | None = None
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def client(self) -> Any:
|
| 54 |
+
if self._client is None:
|
| 55 |
+
from openai import AsyncOpenAI
|
| 56 |
+
|
| 57 |
+
self._client = AsyncOpenAI(
|
| 58 |
+
api_key=self.api_key,
|
| 59 |
+
base_url=self.base_url,
|
| 60 |
+
timeout=self.timeout,
|
| 61 |
+
)
|
| 62 |
+
return self._client
|
| 63 |
+
|
| 64 |
+
async def chat(
|
| 65 |
+
self,
|
| 66 |
+
messages: list[dict[str, Any]],
|
| 67 |
+
*,
|
| 68 |
+
tools: list[dict[str, Any]] | None = None,
|
| 69 |
+
tool_choice: str | dict[str, Any] | None = "auto",
|
| 70 |
+
temperature: float = 0.2,
|
| 71 |
+
) -> AIProviderResponse:
|
| 72 |
+
try:
|
| 73 |
+
kwargs: dict[str, Any] = {
|
| 74 |
+
"model": self.model,
|
| 75 |
+
"messages": messages,
|
| 76 |
+
"temperature": temperature,
|
| 77 |
+
}
|
| 78 |
+
if tools:
|
| 79 |
+
kwargs["tools"] = tools
|
| 80 |
+
kwargs["tool_choice"] = tool_choice
|
| 81 |
+
|
| 82 |
+
completion = await self.client.chat.completions.create(**kwargs)
|
| 83 |
+
message = completion.choices[0].message
|
| 84 |
+
return _normalize_openai_message(message)
|
| 85 |
+
except Exception as exc: # noqa: BLE001
|
| 86 |
+
raise _provider_error_from_exception(exc, self.name) from exc
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class GroqChatProvider(OpenAICompatibleChatProvider):
|
| 90 |
+
def __init__(self, settings: Settings) -> None:
|
| 91 |
+
super().__init__(
|
| 92 |
+
api_key=settings.groq_api_key,
|
| 93 |
+
base_url="https://api.groq.com/openai/v1",
|
| 94 |
+
model=settings.groq_model,
|
| 95 |
+
timeout=settings.request_timeout_seconds,
|
| 96 |
+
name="groq",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class HuggingFaceChatProvider(OpenAICompatibleChatProvider):
|
| 101 |
+
def __init__(self, settings: Settings) -> None:
|
| 102 |
+
super().__init__(
|
| 103 |
+
api_key=settings.hf_token,
|
| 104 |
+
base_url="https://router.huggingface.co/v1",
|
| 105 |
+
model=settings.hf_model,
|
| 106 |
+
timeout=settings.request_timeout_seconds,
|
| 107 |
+
name="huggingface",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _normalize_openai_message(message: Any) -> AIProviderResponse:
|
| 112 |
+
raw_message: dict[str, Any]
|
| 113 |
+
if hasattr(message, "model_dump"):
|
| 114 |
+
raw_message = message.model_dump(exclude_none=True)
|
| 115 |
+
elif isinstance(message, dict):
|
| 116 |
+
raw_message = message
|
| 117 |
+
else:
|
| 118 |
+
raw_message = {}
|
| 119 |
+
|
| 120 |
+
tool_calls = []
|
| 121 |
+
for tool_call in raw_message.get("tool_calls") or []:
|
| 122 |
+
function = tool_call.get("function") or {}
|
| 123 |
+
tool_calls.append(
|
| 124 |
+
ToolCall(
|
| 125 |
+
id=tool_call.get("id") or function.get("name", "tool-call"),
|
| 126 |
+
name=function.get("name", ""),
|
| 127 |
+
arguments=function.get("arguments") or "{}",
|
| 128 |
+
)
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return AIProviderResponse(
|
| 132 |
+
content=raw_message.get("content"),
|
| 133 |
+
tool_calls=tool_calls,
|
| 134 |
+
raw_message=raw_message,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError:
|
| 139 |
+
status_code = getattr(exc, "status_code", None)
|
| 140 |
+
body = getattr(exc, "body", None)
|
| 141 |
+
message = str(exc)
|
| 142 |
+
if body:
|
| 143 |
+
message = f"{message} {body}"
|
| 144 |
+
|
| 145 |
+
if status_code == 400 and "failed_generation" in message:
|
| 146 |
+
return InvalidToolCallGenerationError(f"{provider_name} generated an invalid tool call")
|
| 147 |
+
|
| 148 |
+
if status_code in {408, 409, 429} or (isinstance(status_code, int) and status_code >= 500):
|
| 149 |
+
return RetryableProviderError(f"{provider_name} retryable failure: {message}")
|
| 150 |
+
|
| 151 |
+
if exc.__class__.__name__ in {"APITimeoutError", "APIConnectionError", "RateLimitError"}:
|
| 152 |
+
return RetryableProviderError(f"{provider_name} network/rate failure: {message}")
|
| 153 |
+
|
| 154 |
+
return ProviderError(f"{provider_name} failure: {message}")
|
app/ai/tool_schemas.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from app.models.domain import UserMode
|
| 4 |
+
|
| 5 |
+
_ABOUT_FALSA = {
|
| 6 |
+
"type": "function",
|
| 7 |
+
"function": {
|
| 8 |
+
"name": "about_falsa",
|
| 9 |
+
"description": (
|
| 10 |
+
"Retrieve official FALSA company, FAQ, policy, or pricing information."
|
| 11 |
+
),
|
| 12 |
+
"parameters": {
|
| 13 |
+
"type": "object",
|
| 14 |
+
"properties": {
|
| 15 |
+
"query": {
|
| 16 |
+
"type": "string",
|
| 17 |
+
"description": "The customer's question about FALSA.",
|
| 18 |
+
},
|
| 19 |
+
"language": {
|
| 20 |
+
"type": "string",
|
| 21 |
+
"enum": ["ar", "en"],
|
| 22 |
+
"description": "Customer language for the result.",
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
"required": ["query", "language"],
|
| 26 |
+
"additionalProperties": False,
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
_SEARCH_TRIPS = {
|
| 32 |
+
"type": "function",
|
| 33 |
+
"function": {
|
| 34 |
+
"name": "search_trips",
|
| 35 |
+
"description": (
|
| 36 |
+
"Search active car or bus trips. "
|
| 37 |
+
"Use when the customer asks for travel options."
|
| 38 |
+
),
|
| 39 |
+
"parameters": {
|
| 40 |
+
"type": "object",
|
| 41 |
+
"properties": {
|
| 42 |
+
"departure": {
|
| 43 |
+
"type": "string",
|
| 44 |
+
"description": "Departure city or area in Arabic.",
|
| 45 |
+
},
|
| 46 |
+
"destination": {
|
| 47 |
+
"type": "string",
|
| 48 |
+
"description": "Destination city or area in Arabic.",
|
| 49 |
+
},
|
| 50 |
+
"travel_datetime": {
|
| 51 |
+
"type": "string",
|
| 52 |
+
"description": (
|
| 53 |
+
"Optional requested date/time in ISO format. Times are interpreted "
|
| 54 |
+
"in Asia/Aden and normalized to morning, noon, or night."
|
| 55 |
+
),
|
| 56 |
+
},
|
| 57 |
+
"travel_date": {
|
| 58 |
+
"type": "string",
|
| 59 |
+
"description": (
|
| 60 |
+
"Optional requested trip date as YYYY-MM-DD in Asia/Aden. Use English digits."
|
| 61 |
+
),
|
| 62 |
+
},
|
| 63 |
+
"travel_time": {
|
| 64 |
+
"type": "string",
|
| 65 |
+
"enum": ["صباح", "ظهر", "ليل"],
|
| 66 |
+
"description": (
|
| 67 |
+
"Optional requested trip time bucket in Arabic. If the customer also provides "
|
| 68 |
+
"an exact time, provide the matching Arabic bucket: صباح before 12:00, ظهر from 12:00-17:59, ليل from 18:00."
|
| 69 |
+
),
|
| 70 |
+
},
|
| 71 |
+
"travel_time_exact": {
|
| 72 |
+
"type": "string",
|
| 73 |
+
"description": (
|
| 74 |
+
"Optional exact requested time as HH:MM in Asia/Aden, for example "
|
| 75 |
+
"06:00. Use English digits and colon formatting. Also provide the corresponding Arabic travel_time bucket when possible."
|
| 76 |
+
),
|
| 77 |
+
},
|
| 78 |
+
"seats": {
|
| 79 |
+
"type": "integer",
|
| 80 |
+
"minimum": 1,
|
| 81 |
+
"description": "Optional number of seats requested.",
|
| 82 |
+
},
|
| 83 |
+
"vehicle_type": {
|
| 84 |
+
"type": "string",
|
| 85 |
+
"description": "Optional car type in Arabic, for example سيارة or باص.",
|
| 86 |
+
},
|
| 87 |
+
"vector_query_text": {
|
| 88 |
+
"type": "string",
|
| 89 |
+
"description": (
|
| 90 |
+
"Natural-language semantic search text containing the route, date, "
|
| 91 |
+
"time, seats, and vehicle preferences extracted from the customer."
|
| 92 |
+
),
|
| 93 |
+
},
|
| 94 |
+
},
|
| 95 |
+
"required": ["departure", "destination", "vector_query_text"],
|
| 96 |
+
"additionalProperties": False,
|
| 97 |
+
},
|
| 98 |
+
},
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
_CREATE_BOOKING_LEAD = {
|
| 102 |
+
"type": "function",
|
| 103 |
+
"function": {
|
| 104 |
+
"name": "create_booking_lead",
|
| 105 |
+
"description": (
|
| 106 |
+
"Create a pending booking lead and notify the driver. "
|
| 107 |
+
"This does not reserve seats or confirm payment."
|
| 108 |
+
),
|
| 109 |
+
"parameters": {
|
| 110 |
+
"type": "object",
|
| 111 |
+
"properties": {
|
| 112 |
+
"trip_id": {"type": "string", "description": "Selected trip ID."},
|
| 113 |
+
"requested_seats": {
|
| 114 |
+
"type": "integer",
|
| 115 |
+
"minimum": 1,
|
| 116 |
+
"description": "Number of seats requested by the customer.",
|
| 117 |
+
},
|
| 118 |
+
"notes": {
|
| 119 |
+
"type": "string",
|
| 120 |
+
"description": "Optional customer notes or pickup details.",
|
| 121 |
+
},
|
| 122 |
+
},
|
| 123 |
+
"required": ["trip_id", "requested_seats"],
|
| 124 |
+
"additionalProperties": False,
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
_CREATE_DRIVER_ACCOUNT = {
|
| 130 |
+
"type": "function",
|
| 131 |
+
"function": {
|
| 132 |
+
"name": "create_driver_account",
|
| 133 |
+
"description": (
|
| 134 |
+
"Register the current WhatsApp sender as a FALSA driver. "
|
| 135 |
+
"Phone number is taken automatically from the chat session."
|
| 136 |
+
),
|
| 137 |
+
"parameters": {
|
| 138 |
+
"type": "object",
|
| 139 |
+
"properties": {
|
| 140 |
+
"name": {
|
| 141 |
+
"type": "string",
|
| 142 |
+
"description": "Driver full legal name.",
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
"required": ["name"],
|
| 146 |
+
"additionalProperties": False,
|
| 147 |
+
},
|
| 148 |
+
},
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
_CHECK_DRIVER_INFO = {
|
| 152 |
+
"type": "function",
|
| 153 |
+
"function": {
|
| 154 |
+
"name": "check_driver_info",
|
| 155 |
+
"description": (
|
| 156 |
+
"Retrieve the registered driver's account details, registered vehicles, and active trip summary. "
|
| 157 |
+
"Use when a driver asks about their own profile or vehicle status."
|
| 158 |
+
),
|
| 159 |
+
"parameters": {
|
| 160 |
+
"type": "object",
|
| 161 |
+
"properties": {},
|
| 162 |
+
"required": [],
|
| 163 |
+
"additionalProperties": False,
|
| 164 |
+
},
|
| 165 |
+
},
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
_CHECK_DRIVER_TRIPS = {
|
| 169 |
+
"type": "function",
|
| 170 |
+
"function": {
|
| 171 |
+
"name": "check_driver_trips",
|
| 172 |
+
"description": (
|
| 173 |
+
"List all upcoming active trips for the registered driver. "
|
| 174 |
+
"A trip is considered upcoming if it has status active and has not yet departed."
|
| 175 |
+
),
|
| 176 |
+
"parameters": {
|
| 177 |
+
"type": "object",
|
| 178 |
+
"properties": {},
|
| 179 |
+
"required": [],
|
| 180 |
+
"additionalProperties": False,
|
| 181 |
+
},
|
| 182 |
+
},
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
_ADD_DRIVER_CAR = {
|
| 186 |
+
"type": "function",
|
| 187 |
+
"function": {
|
| 188 |
+
"name": "add_driver_car",
|
| 189 |
+
"description": (
|
| 190 |
+
"Register a new vehicle for the current WhatsApp driver. "
|
| 191 |
+
"Only the car name is required; plate number and seat count are optional."
|
| 192 |
+
),
|
| 193 |
+
"parameters": {
|
| 194 |
+
"type": "object",
|
| 195 |
+
"properties": {
|
| 196 |
+
"name": {
|
| 197 |
+
"type": "string",
|
| 198 |
+
"description": "Vehicle name or type in Arabic.",
|
| 199 |
+
},
|
| 200 |
+
"plate_number": {
|
| 201 |
+
"type": "string",
|
| 202 |
+
"description": "Optional vehicle plate number.",
|
| 203 |
+
},
|
| 204 |
+
"seat_count": {
|
| 205 |
+
"type": "integer",
|
| 206 |
+
"minimum": 1,
|
| 207 |
+
"description": "Optional number of seats in the vehicle.",
|
| 208 |
+
},
|
| 209 |
+
},
|
| 210 |
+
"required": ["name"],
|
| 211 |
+
"additionalProperties": False,
|
| 212 |
+
},
|
| 213 |
+
},
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
_ADD_TRIP_BY_DRIVER = {
|
| 217 |
+
"type": "function",
|
| 218 |
+
"function": {
|
| 219 |
+
"name": "add_trip_by_driver",
|
| 220 |
+
"description": (
|
| 221 |
+
"Create an active trip for the registered driver on this WhatsApp number. "
|
| 222 |
+
"Phone is taken from the chat session. Optional car, seat, and price fields "
|
| 223 |
+
"default from the driver's most recent trip or sole registered vehicle."
|
| 224 |
+
),
|
| 225 |
+
"parameters": {
|
| 226 |
+
"type": "object",
|
| 227 |
+
"properties": {
|
| 228 |
+
"departure": {
|
| 229 |
+
"type": "string",
|
| 230 |
+
"description": "Departure city or area in Arabic.",
|
| 231 |
+
},
|
| 232 |
+
"destination": {
|
| 233 |
+
"type": "string",
|
| 234 |
+
"description": "Destination city or area in Arabic.",
|
| 235 |
+
},
|
| 236 |
+
"departure_date": {
|
| 237 |
+
"type": "string",
|
| 238 |
+
"description": "Trip date as YYYY-MM-DD in Asia/Aden.",
|
| 239 |
+
},
|
| 240 |
+
"departure_time": {
|
| 241 |
+
"type": "string",
|
| 242 |
+
"description": (
|
| 243 |
+
"Trip time bucket: morning, noon, night, or Arabic "
|
| 244 |
+
"صباح / ظهر / ليل."
|
| 245 |
+
),
|
| 246 |
+
},
|
| 247 |
+
"vehicle_type": {
|
| 248 |
+
"type": "string",
|
| 249 |
+
"description": (
|
| 250 |
+
"Optional vehicle name or type in Arabic, for example "
|
| 251 |
+
"سيارة or باص. Matched against the driver's registered cars."
|
| 252 |
+
),
|
| 253 |
+
},
|
| 254 |
+
"available_seats": {
|
| 255 |
+
"type": "integer",
|
| 256 |
+
"minimum": 0,
|
| 257 |
+
"description": "Optional seats available for booking.",
|
| 258 |
+
},
|
| 259 |
+
"total_seats": {
|
| 260 |
+
"type": "integer",
|
| 261 |
+
"minimum": 1,
|
| 262 |
+
"description": "Optional total vehicle seats for this trip.",
|
| 263 |
+
},
|
| 264 |
+
"price": {
|
| 265 |
+
"type": "number",
|
| 266 |
+
"minimum": 0,
|
| 267 |
+
"description": "Optional trip price.",
|
| 268 |
+
},
|
| 269 |
+
},
|
| 270 |
+
"required": ["departure", "destination", "departure_date", "departure_time"],
|
| 271 |
+
"additionalProperties": False,
|
| 272 |
+
},
|
| 273 |
+
},
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
_SWITCH_TO_DRIVER = {
|
| 277 |
+
"type": "function",
|
| 278 |
+
"function": {
|
| 279 |
+
"name": "switch_to_driver",
|
| 280 |
+
"description": (
|
| 281 |
+
"Switch this sender to driver mode. "
|
| 282 |
+
"Requires an existing driver account; use create_driver_account first if needed."
|
| 283 |
+
),
|
| 284 |
+
"parameters": {
|
| 285 |
+
"type": "object",
|
| 286 |
+
"properties": {},
|
| 287 |
+
"required": [],
|
| 288 |
+
"additionalProperties": False,
|
| 289 |
+
},
|
| 290 |
+
},
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
_SWITCH_TO_PASSENGER = {
|
| 294 |
+
"type": "function",
|
| 295 |
+
"function": {
|
| 296 |
+
"name": "switch_to_passenger",
|
| 297 |
+
"description": (
|
| 298 |
+
"Switch this sender to passenger mode so they can search and book trips."
|
| 299 |
+
),
|
| 300 |
+
"parameters": {
|
| 301 |
+
"type": "object",
|
| 302 |
+
"properties": {
|
| 303 |
+
"name": {
|
| 304 |
+
"type": "string",
|
| 305 |
+
"description": "Optional passenger display name.",
|
| 306 |
+
},
|
| 307 |
+
},
|
| 308 |
+
"required": [],
|
| 309 |
+
"additionalProperties": False,
|
| 310 |
+
},
|
| 311 |
+
},
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
_TOOL_SCHEMAS: dict[str, dict[str, Any]] = {
|
| 315 |
+
"about_falsa": _ABOUT_FALSA,
|
| 316 |
+
"search_trips": _SEARCH_TRIPS,
|
| 317 |
+
"create_booking_lead": _CREATE_BOOKING_LEAD,
|
| 318 |
+
"create_driver_account": _CREATE_DRIVER_ACCOUNT,
|
| 319 |
+
"check_driver_info": _CHECK_DRIVER_INFO,
|
| 320 |
+
"check_driver_trips": _CHECK_DRIVER_TRIPS,
|
| 321 |
+
"add_driver_car": _ADD_DRIVER_CAR,
|
| 322 |
+
"add_trip_by_driver": _ADD_TRIP_BY_DRIVER,
|
| 323 |
+
"switch_to_driver": _SWITCH_TO_DRIVER,
|
| 324 |
+
"switch_to_passenger": _SWITCH_TO_PASSENGER,
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
_TOOLS_BY_MODE: dict[UserMode, list[str]] = {
|
| 328 |
+
"new_user": [
|
| 329 |
+
"about_falsa",
|
| 330 |
+
"create_driver_account",
|
| 331 |
+
"switch_to_driver",
|
| 332 |
+
"switch_to_passenger",
|
| 333 |
+
],
|
| 334 |
+
"driver": [
|
| 335 |
+
"about_falsa",
|
| 336 |
+
"check_driver_info",
|
| 337 |
+
"check_driver_trips",
|
| 338 |
+
"add_driver_car",
|
| 339 |
+
"add_trip_by_driver",
|
| 340 |
+
"switch_to_passenger",
|
| 341 |
+
],
|
| 342 |
+
"passenger": [
|
| 343 |
+
"about_falsa",
|
| 344 |
+
"search_trips",
|
| 345 |
+
"create_booking_lead",
|
| 346 |
+
"create_driver_account",
|
| 347 |
+
"switch_to_driver",
|
| 348 |
+
],
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def get_tool_schemas(user_mode: UserMode = "new_user") -> list[dict[str, Any]]:
|
| 353 |
+
return [_TOOL_SCHEMAS[name] for name in _TOOLS_BY_MODE[user_mode]]
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def get_all_tool_schemas() -> list[dict[str, Any]]:
|
| 357 |
+
return list(_TOOL_SCHEMAS.values())
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.api.routes import router
|
| 2 |
+
|
| 3 |
+
__all__ = ["router"]
|
app/api/deps.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Header, HTTPException, Request, status
|
| 2 |
+
|
| 3 |
+
from app.services.container import ServiceContainer
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def get_container(request: Request) -> ServiceContainer:
|
| 7 |
+
container = getattr(request.app.state, "container", None)
|
| 8 |
+
if container is None:
|
| 9 |
+
raise HTTPException(
|
| 10 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 11 |
+
detail="Service container is not initialized",
|
| 12 |
+
)
|
| 13 |
+
return container
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def verify_admin_api_key(
|
| 17 |
+
request: Request,
|
| 18 |
+
x_admin_api_key: str | None = Header(default=None, alias="X-Admin-Api-Key"),
|
| 19 |
+
) -> None:
|
| 20 |
+
expected = get_container(request).settings.admin_api_key
|
| 21 |
+
if not x_admin_api_key or x_admin_api_key != expected:
|
| 22 |
+
raise HTTPException(
|
| 23 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 24 |
+
detail="Invalid admin API key",
|
| 25 |
+
)
|
app/api/routes.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated, Any
|
| 2 |
+
|
| 3 |
+
from fastapi import (
|
| 4 |
+
APIRouter,
|
| 5 |
+
BackgroundTasks,
|
| 6 |
+
Depends,
|
| 7 |
+
HTTPException,
|
| 8 |
+
Query,
|
| 9 |
+
Request,
|
| 10 |
+
Response,
|
| 11 |
+
status,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
from app.api.deps import get_container, verify_admin_api_key
|
| 15 |
+
from app.models.api import (
|
| 16 |
+
HealthResponse,
|
| 17 |
+
DriverDebugRequest,
|
| 18 |
+
LLMToolCallRequest,
|
| 19 |
+
LLMToolCallResponse,
|
| 20 |
+
JinaEmbeddingRequest,
|
| 21 |
+
JinaEmbeddingResponse,
|
| 22 |
+
SeedInfoResponse,
|
| 23 |
+
SyncTripsResponse,
|
| 24 |
+
WebhookAcceptedResponse,
|
| 25 |
+
WebhookDebugResponse,
|
| 26 |
+
)
|
| 27 |
+
from app.services.container import ServiceContainer
|
| 28 |
+
from app.whatsapp.parser import parse_inbound_messages
|
| 29 |
+
from app.whatsapp.security import verify_meta_signature, verify_webhook_challenge
|
| 30 |
+
from app.ai.tool_schemas import get_all_tool_schemas
|
| 31 |
+
import json
|
| 32 |
+
|
| 33 |
+
router = APIRouter()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/healthz", response_model=HealthResponse)
|
| 37 |
+
async def healthz(container: Annotated[ServiceContainer, Depends(get_container)]) -> HealthResponse:
|
| 38 |
+
return HealthResponse(service=container.settings.app_name)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("/webhooks/whatsapp")
|
| 42 |
+
async def verify_whatsapp_webhook(
|
| 43 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 44 |
+
hub_mode: Annotated[str | None, Query(alias="hub.mode")] = None,
|
| 45 |
+
hub_verify_token: Annotated[str | None, Query(alias="hub.verify_token")] = None,
|
| 46 |
+
hub_challenge: Annotated[str | None, Query(alias="hub.challenge")] = None,
|
| 47 |
+
) -> Response:
|
| 48 |
+
if verify_webhook_challenge(
|
| 49 |
+
hub_mode,
|
| 50 |
+
hub_verify_token,
|
| 51 |
+
container.settings.whatsapp_verify_token,
|
| 52 |
+
):
|
| 53 |
+
return Response(content=hub_challenge or "", media_type="text/plain")
|
| 54 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid verify token")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.post("/webhooks/whatsapp", response_model=WebhookAcceptedResponse)
|
| 58 |
+
async def receive_whatsapp_webhook(
|
| 59 |
+
request: Request,
|
| 60 |
+
background_tasks: BackgroundTasks,
|
| 61 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 62 |
+
) -> WebhookAcceptedResponse:
|
| 63 |
+
body = await request.body()
|
| 64 |
+
signature = request.headers.get("x-hub-signature-256")
|
| 65 |
+
if not verify_meta_signature(body, signature, container.settings.whatsapp_app_secret):
|
| 66 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
|
| 67 |
+
|
| 68 |
+
payload: dict[str, Any] = await request.json()
|
| 69 |
+
messages = parse_inbound_messages(payload)
|
| 70 |
+
for inbound in messages:
|
| 71 |
+
background_tasks.add_task(container.conversation.handle_inbound_message, inbound)
|
| 72 |
+
|
| 73 |
+
return WebhookAcceptedResponse(messages=len(messages))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post(
|
| 77 |
+
"/webhooks/whatsapp/debug",
|
| 78 |
+
response_model=WebhookDebugResponse,
|
| 79 |
+
)
|
| 80 |
+
async def receive_whatsapp_webhook_debug(
|
| 81 |
+
request: Request,
|
| 82 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 83 |
+
) -> WebhookDebugResponse:
|
| 84 |
+
body = await request.body()
|
| 85 |
+
signature = request.headers.get("x-hub-signature-256")
|
| 86 |
+
# if not verify_meta_signature(body, signature, container.settings.whatsapp_app_secret):
|
| 87 |
+
# raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
|
| 88 |
+
|
| 89 |
+
payload: dict[str, Any] = await request.json()
|
| 90 |
+
messages = parse_inbound_messages(payload)
|
| 91 |
+
replies: list[str] = []
|
| 92 |
+
for inbound in messages:
|
| 93 |
+
reply = await container.conversation.handle_inbound_message(inbound)
|
| 94 |
+
if reply is not None:
|
| 95 |
+
replies.append(reply)
|
| 96 |
+
|
| 97 |
+
return WebhookDebugResponse(messages=len(messages), replies=replies)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post(
|
| 101 |
+
"/admin/jina-embed",
|
| 102 |
+
response_model=JinaEmbeddingResponse,
|
| 103 |
+
dependencies=[Depends(verify_admin_api_key)],
|
| 104 |
+
)
|
| 105 |
+
async def jina_embed_query(
|
| 106 |
+
request: JinaEmbeddingRequest,
|
| 107 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 108 |
+
) -> JinaEmbeddingResponse:
|
| 109 |
+
embedding = await container.embeddings.embed_query(request.text)
|
| 110 |
+
return JinaEmbeddingResponse(
|
| 111 |
+
text=request.text,
|
| 112 |
+
embedding=embedding,
|
| 113 |
+
dimensions=len(embedding),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.post(
|
| 118 |
+
"/admin/llm-tool-call",
|
| 119 |
+
response_model=LLMToolCallResponse,
|
| 120 |
+
dependencies=[Depends(verify_admin_api_key)],
|
| 121 |
+
)
|
| 122 |
+
async def llm_tool_call_debug(
|
| 123 |
+
request: LLMToolCallRequest,
|
| 124 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 125 |
+
) -> LLMToolCallResponse:
|
| 126 |
+
# Call the primary provider directly with the tool schemas and return generated tool calls
|
| 127 |
+
provider = container.ai.primary
|
| 128 |
+
tools = get_all_tool_schemas()
|
| 129 |
+
response = await provider.chat(
|
| 130 |
+
messages=[{"role": "user", "content": request.message}],
|
| 131 |
+
tools=tools,
|
| 132 |
+
tool_choice="auto",
|
| 133 |
+
temperature=container.settings.ai_temperature,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
tool_calls: list[dict[str, Any]] = []
|
| 137 |
+
tool_results: list[dict[str, Any]] = []
|
| 138 |
+
registry = container.conversation._tool_registry(
|
| 139 |
+
{"id": "admin-debug", "phone_number": "", "name": "admin-debug"},
|
| 140 |
+
sender_phone="admin-debug",
|
| 141 |
+
user_mode="passenger",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
for tc in response.tool_calls:
|
| 145 |
+
try:
|
| 146 |
+
args = json.loads(tc.arguments or "{}")
|
| 147 |
+
except Exception:
|
| 148 |
+
args = tc.arguments
|
| 149 |
+
tool_calls.append({"name": tc.name, "arguments": args})
|
| 150 |
+
|
| 151 |
+
execution_result = await registry.execute(tc.name, args if isinstance(args, dict) else {})
|
| 152 |
+
tool_results.append(
|
| 153 |
+
{
|
| 154 |
+
"tool_call_id": tc.id,
|
| 155 |
+
"name": tc.name,
|
| 156 |
+
"arguments": args,
|
| 157 |
+
"result": execution_result.to_payload(),
|
| 158 |
+
}
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
return LLMToolCallResponse(
|
| 162 |
+
llm_response=(response.content or "").strip() or None,
|
| 163 |
+
tool_calls=tool_calls,
|
| 164 |
+
tool_results=tool_results,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@router.post(
|
| 169 |
+
"/admin/driver-debug",
|
| 170 |
+
response_model=LLMToolCallResponse,
|
| 171 |
+
dependencies=[Depends(verify_admin_api_key)],
|
| 172 |
+
)
|
| 173 |
+
async def driver_service_debug(
|
| 174 |
+
request: DriverDebugRequest,
|
| 175 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 176 |
+
) -> LLMToolCallResponse:
|
| 177 |
+
provider = container.ai.primary
|
| 178 |
+
tools = get_all_tool_schemas()
|
| 179 |
+
response = await provider.chat(
|
| 180 |
+
messages=[{"role": "user", "content": request.message}],
|
| 181 |
+
tools=tools,
|
| 182 |
+
tool_choice="auto",
|
| 183 |
+
temperature=container.settings.ai_temperature,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
tool_calls: list[dict[str, Any]] = []
|
| 187 |
+
tool_results: list[dict[str, Any]] = []
|
| 188 |
+
customer = {
|
| 189 |
+
"id": "debug",
|
| 190 |
+
"phone_number": request.client_number,
|
| 191 |
+
"name": "driver-debug",
|
| 192 |
+
}
|
| 193 |
+
registry = container.conversation._tool_registry(
|
| 194 |
+
customer,
|
| 195 |
+
sender_phone=request.client_number,
|
| 196 |
+
user_mode="driver",
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
for tc in response.tool_calls:
|
| 200 |
+
try:
|
| 201 |
+
args = json.loads(tc.arguments or "{}")
|
| 202 |
+
except Exception:
|
| 203 |
+
args = tc.arguments
|
| 204 |
+
tool_calls.append({"name": tc.name, "arguments": args})
|
| 205 |
+
|
| 206 |
+
execution_result = await registry.execute(tc.name, args if isinstance(args, dict) else {})
|
| 207 |
+
tool_results.append(
|
| 208 |
+
{
|
| 209 |
+
"tool_call_id": tc.id,
|
| 210 |
+
"name": tc.name,
|
| 211 |
+
"arguments": args,
|
| 212 |
+
"result": execution_result.to_payload(),
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
return LLMToolCallResponse(
|
| 217 |
+
llm_response=(response.content or "").strip() or None,
|
| 218 |
+
tool_calls=tool_calls,
|
| 219 |
+
tool_results=tool_results,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.post(
|
| 224 |
+
"/admin/seed-info",
|
| 225 |
+
response_model=SeedInfoResponse,
|
| 226 |
+
dependencies=[Depends(verify_admin_api_key)],
|
| 227 |
+
)
|
| 228 |
+
async def seed_info(
|
| 229 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 230 |
+
) -> SeedInfoResponse:
|
| 231 |
+
return SeedInfoResponse(indexed_chunks=await container.admin.seed_info())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
@router.post(
|
| 235 |
+
"/admin/sync-trips",
|
| 236 |
+
response_model=SyncTripsResponse,
|
| 237 |
+
dependencies=[Depends(verify_admin_api_key)],
|
| 238 |
+
)
|
| 239 |
+
async def sync_trips(
|
| 240 |
+
container: Annotated[ServiceContainer, Depends(get_container)],
|
| 241 |
+
) -> SyncTripsResponse:
|
| 242 |
+
return SyncTripsResponse(indexed_trips=await container.admin.sync_trips())
|
| 243 |
+
|
| 244 |
+
|
app/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from pydantic import Field, HttpUrl
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
model_config = SettingsConfigDict(
|
| 13 |
+
env_file=BASE_DIR / ".env",
|
| 14 |
+
env_file_encoding="utf-8",
|
| 15 |
+
extra="ignore",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
app_name: str = "FALSA"
|
| 19 |
+
environment: str = "development"
|
| 20 |
+
app_timezone: str = "Asia/Aden"
|
| 21 |
+
log_level: str = "INFO"
|
| 22 |
+
|
| 23 |
+
supabase_url: HttpUrl
|
| 24 |
+
supabase_service_role_key: str = Field(min_length=1)
|
| 25 |
+
|
| 26 |
+
jina_api_key: str = Field(min_length=1)
|
| 27 |
+
jina_embedding_model: str = "jina-embeddings-v5-text-small"
|
| 28 |
+
jina_embedding_dimensions: int = 1024
|
| 29 |
+
jina_embedding_endpoint: str = "https://api.jina.ai/v1/embeddings"
|
| 30 |
+
jina_query_task: str = "retrieval.query"
|
| 31 |
+
jina_passage_task: str = "retrieval.passage"
|
| 32 |
+
|
| 33 |
+
groq_api_key: str = Field(min_length=1)
|
| 34 |
+
groq_model: str = Field(min_length=1)
|
| 35 |
+
hf_token: str = Field(min_length=1)
|
| 36 |
+
hf_model: str = Field(min_length=1)
|
| 37 |
+
ai_temperature: float = 0.2
|
| 38 |
+
ai_max_tool_iterations: int = 3
|
| 39 |
+
request_timeout_seconds: float = 20.0
|
| 40 |
+
|
| 41 |
+
whatsapp_graph_url: str = "https://graph.facebook.com"
|
| 42 |
+
whatsapp_api_version: str = "v20.0"
|
| 43 |
+
whatsapp_verify_token: str = Field(min_length=1)
|
| 44 |
+
whatsapp_app_secret: str = Field(min_length=1)
|
| 45 |
+
whatsapp_access_token: str = Field(min_length=1)
|
| 46 |
+
whatsapp_phone_number_id: str = Field(min_length=1)
|
| 47 |
+
|
| 48 |
+
admin_api_key: str = Field(min_length=1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@lru_cache
|
| 52 |
+
def get_settings() -> Settings:
|
| 53 |
+
return Settings()
|
app/database/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.database.supabase import SupabaseRepository, create_supabase_client
|
| 2 |
+
|
| 3 |
+
__all__ = ["SupabaseRepository", "create_supabase_client"]
|
app/database/supabase.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import date, time
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from app.config import Settings
|
| 6 |
+
from app.utils.departure import (
|
| 7 |
+
DepartureRequest,
|
| 8 |
+
not_departed_bucket_filter,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def create_supabase_client(settings: Settings) -> Any:
|
| 15 |
+
from supabase import acreate_client
|
| 16 |
+
|
| 17 |
+
return await acreate_client(
|
| 18 |
+
str(settings.supabase_url),
|
| 19 |
+
settings.supabase_service_role_key,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _response_data(response: Any) -> Any:
|
| 24 |
+
if hasattr(response, "data"):
|
| 25 |
+
return response.data
|
| 26 |
+
if isinstance(response, dict):
|
| 27 |
+
return response.get("data", response)
|
| 28 |
+
return response
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SupabaseRepository:
|
| 32 |
+
def __init__(self, client: Any) -> None:
|
| 33 |
+
self.client = client
|
| 34 |
+
|
| 35 |
+
async def upsert_customer(
|
| 36 |
+
self,
|
| 37 |
+
*,
|
| 38 |
+
phone_number: str,
|
| 39 |
+
name: str | None = None,
|
| 40 |
+
preferred_language: str | None = None,
|
| 41 |
+
) -> dict[str, Any]:
|
| 42 |
+
payload = {
|
| 43 |
+
"phone_number": phone_number,
|
| 44 |
+
"name": name,
|
| 45 |
+
"preferred_language": preferred_language,
|
| 46 |
+
}
|
| 47 |
+
payload = {key: value for key, value in payload.items() if value is not None}
|
| 48 |
+
response = await (
|
| 49 |
+
self.client.table("customers")
|
| 50 |
+
.upsert(payload, on_conflict="phone_number")
|
| 51 |
+
.execute()
|
| 52 |
+
)
|
| 53 |
+
data = _response_data(response)
|
| 54 |
+
return data[0] if isinstance(data, list) else data
|
| 55 |
+
|
| 56 |
+
async def update_customer_user_mode(
|
| 57 |
+
self,
|
| 58 |
+
*,
|
| 59 |
+
customer_id: str,
|
| 60 |
+
user_mode: str,
|
| 61 |
+
) -> dict[str, Any]:
|
| 62 |
+
response = await (
|
| 63 |
+
self.client.table("customers")
|
| 64 |
+
.update({"user_mode": user_mode})
|
| 65 |
+
.eq("id", customer_id)
|
| 66 |
+
.execute()
|
| 67 |
+
)
|
| 68 |
+
data = _response_data(response)
|
| 69 |
+
return data[0] if isinstance(data, list) else data
|
| 70 |
+
|
| 71 |
+
async def update_customer_name(
|
| 72 |
+
self,
|
| 73 |
+
*,
|
| 74 |
+
customer_id: str,
|
| 75 |
+
name: str,
|
| 76 |
+
) -> dict[str, Any]:
|
| 77 |
+
response = await (
|
| 78 |
+
self.client.table("customers")
|
| 79 |
+
.update({"name": name})
|
| 80 |
+
.eq("id", customer_id)
|
| 81 |
+
.execute()
|
| 82 |
+
)
|
| 83 |
+
data = _response_data(response)
|
| 84 |
+
return data[0] if isinstance(data, list) else data
|
| 85 |
+
|
| 86 |
+
async def message_exists(self, whatsapp_message_id: str) -> bool:
|
| 87 |
+
response = await (
|
| 88 |
+
self.client.table("messages")
|
| 89 |
+
.select("id")
|
| 90 |
+
.eq("whatsapp_message_id", whatsapp_message_id)
|
| 91 |
+
.limit(1)
|
| 92 |
+
.execute()
|
| 93 |
+
)
|
| 94 |
+
data = _response_data(response)
|
| 95 |
+
return bool(data)
|
| 96 |
+
|
| 97 |
+
async def create_message(
|
| 98 |
+
self,
|
| 99 |
+
*,
|
| 100 |
+
customer_id: str,
|
| 101 |
+
sender_type: str,
|
| 102 |
+
message: str,
|
| 103 |
+
whatsapp_message_id: str | None = None,
|
| 104 |
+
metadata: dict[str, Any] | None = None,
|
| 105 |
+
) -> dict[str, Any]:
|
| 106 |
+
payload = {
|
| 107 |
+
"customer_id": customer_id,
|
| 108 |
+
"sender_type": sender_type,
|
| 109 |
+
"message": message,
|
| 110 |
+
"whatsapp_message_id": whatsapp_message_id,
|
| 111 |
+
"metadata": metadata or {},
|
| 112 |
+
}
|
| 113 |
+
response = await self.client.table("messages").insert(payload).execute()
|
| 114 |
+
data = _response_data(response)
|
| 115 |
+
return data[0] if isinstance(data, list) else data
|
| 116 |
+
|
| 117 |
+
async def get_recent_context_messages(
|
| 118 |
+
self,
|
| 119 |
+
*,
|
| 120 |
+
customer_id: str,
|
| 121 |
+
current_message_id: str,
|
| 122 |
+
limit: int = 4,
|
| 123 |
+
) -> list[dict[str, Any]]:
|
| 124 |
+
current_response = await (
|
| 125 |
+
self.client.table("messages")
|
| 126 |
+
.select("*")
|
| 127 |
+
.eq("id", current_message_id)
|
| 128 |
+
.single()
|
| 129 |
+
.execute()
|
| 130 |
+
)
|
| 131 |
+
current = _response_data(current_response)
|
| 132 |
+
current_created_at = current.get("created_at")
|
| 133 |
+
|
| 134 |
+
prior_query = (
|
| 135 |
+
self.client.table("messages")
|
| 136 |
+
.select("*")
|
| 137 |
+
.eq("customer_id", customer_id)
|
| 138 |
+
.neq("id", current_message_id)
|
| 139 |
+
.order("created_at", desc=True)
|
| 140 |
+
.limit(limit)
|
| 141 |
+
)
|
| 142 |
+
if current_created_at:
|
| 143 |
+
prior_query = prior_query.lt("created_at", current_created_at)
|
| 144 |
+
|
| 145 |
+
prior_response = await prior_query.execute()
|
| 146 |
+
prior = _response_data(prior_response) or []
|
| 147 |
+
return list(reversed(prior)) + [current]
|
| 148 |
+
|
| 149 |
+
async def list_active_trips(self) -> list[dict[str, Any]]:
|
| 150 |
+
query = (
|
| 151 |
+
self.client.table("driver_trips")
|
| 152 |
+
.select("*, drivers(*), driver_cars(*)")
|
| 153 |
+
.eq("status", "active")
|
| 154 |
+
.gt("available_seats", 0)
|
| 155 |
+
)
|
| 156 |
+
query = (
|
| 157 |
+
self._apply_not_departed_filter(query)
|
| 158 |
+
.order("departure_date")
|
| 159 |
+
.order("departure_time")
|
| 160 |
+
)
|
| 161 |
+
response = await query.execute()
|
| 162 |
+
return _response_data(response) or []
|
| 163 |
+
|
| 164 |
+
async def get_trips_by_ids(self, trip_ids: list[str]) -> list[dict[str, Any]]:
|
| 165 |
+
if not trip_ids:
|
| 166 |
+
return []
|
| 167 |
+
response = await (
|
| 168 |
+
self.client.table("driver_trips")
|
| 169 |
+
.select("*, drivers(*), driver_cars(*)")
|
| 170 |
+
.in_("id", trip_ids)
|
| 171 |
+
.execute()
|
| 172 |
+
)
|
| 173 |
+
return _response_data(response) or []
|
| 174 |
+
|
| 175 |
+
async def search_active_trips(
|
| 176 |
+
self,
|
| 177 |
+
*,
|
| 178 |
+
departure: str | None = None,
|
| 179 |
+
destination: str | None = None,
|
| 180 |
+
seats: int | None = None,
|
| 181 |
+
vehicle_type: str | None = None,
|
| 182 |
+
departure_request: DepartureRequest | None = None,
|
| 183 |
+
) -> list[dict[str, Any]]:
|
| 184 |
+
query = (
|
| 185 |
+
self.client.table("driver_trips")
|
| 186 |
+
.select("*, drivers(*), driver_cars(*)")
|
| 187 |
+
.eq("status", "active")
|
| 188 |
+
.gt("available_seats", 0)
|
| 189 |
+
)
|
| 190 |
+
query = self._apply_departure_request_filter(query, departure_request)
|
| 191 |
+
query = query.order("departure_date").order("departure_time")
|
| 192 |
+
if departure:
|
| 193 |
+
query = query.ilike("departure", f"%{departure}%")
|
| 194 |
+
if destination:
|
| 195 |
+
query = query.ilike("destination", f"%{destination}%")
|
| 196 |
+
if seats:
|
| 197 |
+
query = query.gte("available_seats", seats)
|
| 198 |
+
if vehicle_type:
|
| 199 |
+
query = query.ilike("driver_cars.car_type", f"%{vehicle_type}%")
|
| 200 |
+
response = await query.limit(10).execute()
|
| 201 |
+
return _response_data(response) or []
|
| 202 |
+
|
| 203 |
+
async def search_info_chunks_by_vector(
|
| 204 |
+
self,
|
| 205 |
+
*,
|
| 206 |
+
query_embedding: list[float],
|
| 207 |
+
match_count: int = 5,
|
| 208 |
+
) -> list[dict[str, Any]]:
|
| 209 |
+
response = await self.client.rpc(
|
| 210 |
+
"match_falsa_info",
|
| 211 |
+
{
|
| 212 |
+
"query_embedding": query_embedding,
|
| 213 |
+
"match_count": match_count,
|
| 214 |
+
"match_threshold": 0.0,
|
| 215 |
+
},
|
| 216 |
+
).execute()
|
| 217 |
+
return _response_data(response) or []
|
| 218 |
+
|
| 219 |
+
async def search_trips_by_vector(
|
| 220 |
+
self,
|
| 221 |
+
*,
|
| 222 |
+
query_embedding: list[float],
|
| 223 |
+
departure: str,
|
| 224 |
+
destination: str,
|
| 225 |
+
departure_date: date | None = None,
|
| 226 |
+
departure_time: str | None = None,
|
| 227 |
+
requested_time: time | None = None,
|
| 228 |
+
seats: int = 1,
|
| 229 |
+
vehicle_type: str | None = None,
|
| 230 |
+
match_count: int = 10,
|
| 231 |
+
) -> list[dict[str, Any]]:
|
| 232 |
+
try:
|
| 233 |
+
response = await self.client.rpc(
|
| 234 |
+
"match_active_trips",
|
| 235 |
+
{
|
| 236 |
+
"query_embedding": query_embedding,
|
| 237 |
+
"match_count": match_count,
|
| 238 |
+
"match_threshold": 0.0,
|
| 239 |
+
"filter_departure": departure,
|
| 240 |
+
"filter_destination": destination,
|
| 241 |
+
"filter_departure_date": (
|
| 242 |
+
departure_date.isoformat() if departure_date else None
|
| 243 |
+
),
|
| 244 |
+
"filter_departure_time": departure_time,
|
| 245 |
+
"filter_requested_time": (
|
| 246 |
+
requested_time.isoformat(timespec="minutes") if requested_time else None
|
| 247 |
+
),
|
| 248 |
+
"filter_seats": seats,
|
| 249 |
+
"filter_vehicle_type": vehicle_type,
|
| 250 |
+
},
|
| 251 |
+
).execute()
|
| 252 |
+
return _response_data(response) or []
|
| 253 |
+
except Exception as exc: # noqa: BLE001
|
| 254 |
+
logger.warning(
|
| 255 |
+
"Supabase match_active_trips RPC failed; falling back to regular active trips search: %s",
|
| 256 |
+
exc,
|
| 257 |
+
)
|
| 258 |
+
return []
|
| 259 |
+
|
| 260 |
+
async def upsert_info_chunks(self, chunks: list[dict[str, Any]]) -> int:
|
| 261 |
+
if not chunks:
|
| 262 |
+
return 0
|
| 263 |
+
response = await self.client.table("falsa_info_chunks").upsert(chunks).execute()
|
| 264 |
+
data = _response_data(response)
|
| 265 |
+
return len(data) if isinstance(data, list) else len(chunks)
|
| 266 |
+
|
| 267 |
+
async def upsert_trip_embeddings(self, trip_embeddings: list[dict[str, Any]]) -> int:
|
| 268 |
+
if not trip_embeddings:
|
| 269 |
+
return 0
|
| 270 |
+
response = await (
|
| 271 |
+
self.client.table("driver_trip_embeddings")
|
| 272 |
+
.upsert(trip_embeddings, on_conflict="trip_id")
|
| 273 |
+
.execute()
|
| 274 |
+
)
|
| 275 |
+
data = _response_data(response)
|
| 276 |
+
return len(data) if isinstance(data, list) else len(trip_embeddings)
|
| 277 |
+
|
| 278 |
+
def _apply_departure_request_filter(
|
| 279 |
+
self,
|
| 280 |
+
query: Any,
|
| 281 |
+
departure_request: DepartureRequest | None,
|
| 282 |
+
) -> Any:
|
| 283 |
+
if not departure_request:
|
| 284 |
+
return self._apply_not_departed_filter(query)
|
| 285 |
+
|
| 286 |
+
today, remaining_buckets = not_departed_bucket_filter()
|
| 287 |
+
if departure_request.departure_date:
|
| 288 |
+
query = query.eq("departure_date", departure_request.departure_date.isoformat())
|
| 289 |
+
if departure_request.departure_time:
|
| 290 |
+
return query.eq("departure_time", departure_request.departure_time)
|
| 291 |
+
if departure_request.departure_date == today:
|
| 292 |
+
return query.in_("departure_time", list(remaining_buckets))
|
| 293 |
+
return query
|
| 294 |
+
|
| 295 |
+
query = self._apply_not_departed_filter(query)
|
| 296 |
+
if departure_request.departure_time:
|
| 297 |
+
return query.eq("departure_time", departure_request.departure_time)
|
| 298 |
+
return query
|
| 299 |
+
|
| 300 |
+
def _apply_not_departed_filter(self, query: Any) -> Any:
|
| 301 |
+
today, remaining_buckets = not_departed_bucket_filter()
|
| 302 |
+
bucket_list = ",".join(remaining_buckets)
|
| 303 |
+
return query.or_(
|
| 304 |
+
f"departure_date.gt.{today.isoformat()},"
|
| 305 |
+
f"and(departure_date.eq.{today.isoformat()},departure_time.in.({bucket_list}))"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
async def get_trip_by_id(self, trip_id: str) -> dict[str, Any] | None:
|
| 309 |
+
response = await (
|
| 310 |
+
self.client.table("driver_trips")
|
| 311 |
+
.select("*, drivers(*), driver_cars(*)")
|
| 312 |
+
.eq("id", trip_id)
|
| 313 |
+
.maybe_single()
|
| 314 |
+
.execute()
|
| 315 |
+
)
|
| 316 |
+
return _response_data(response)
|
| 317 |
+
|
| 318 |
+
async def get_driver_by_phone(self, phone_number: str) -> dict[str, Any] | None:
|
| 319 |
+
response = await (
|
| 320 |
+
self.client.table("drivers")
|
| 321 |
+
.select("*")
|
| 322 |
+
.eq("phone_number", phone_number)
|
| 323 |
+
.maybe_single()
|
| 324 |
+
.execute()
|
| 325 |
+
)
|
| 326 |
+
return _response_data(response)
|
| 327 |
+
|
| 328 |
+
async def create_driver(self, *, name: str, phone_number: str) -> dict[str, Any]:
|
| 329 |
+
response = await (
|
| 330 |
+
self.client.table("drivers")
|
| 331 |
+
.insert({"name": name, "phone_number": phone_number, "status": "active"})
|
| 332 |
+
.execute()
|
| 333 |
+
)
|
| 334 |
+
data = _response_data(response)
|
| 335 |
+
driver = data[0] if isinstance(data, list) else data
|
| 336 |
+
await (
|
| 337 |
+
self.client.table("driver_wallet")
|
| 338 |
+
.insert({"driver_id": driver["id"], "balance": 0})
|
| 339 |
+
.execute()
|
| 340 |
+
)
|
| 341 |
+
return driver
|
| 342 |
+
|
| 343 |
+
async def get_driver_latest_trip(self, driver_id: str) -> dict[str, Any] | None:
|
| 344 |
+
response = await (
|
| 345 |
+
self.client.table("driver_trips")
|
| 346 |
+
.select("*, driver_cars(*)")
|
| 347 |
+
.eq("driver_id", driver_id)
|
| 348 |
+
.order("created_at", desc=True)
|
| 349 |
+
.limit(1)
|
| 350 |
+
.maybe_single()
|
| 351 |
+
.execute()
|
| 352 |
+
)
|
| 353 |
+
return _response_data(response)
|
| 354 |
+
|
| 355 |
+
async def list_driver_cars(self, driver_id: str) -> list[dict[str, Any]]:
|
| 356 |
+
response = await (
|
| 357 |
+
self.client.table("driver_cars")
|
| 358 |
+
.select("*")
|
| 359 |
+
.eq("driver_id", driver_id)
|
| 360 |
+
.execute()
|
| 361 |
+
)
|
| 362 |
+
return _response_data(response) or []
|
| 363 |
+
|
| 364 |
+
async def list_driver_trips(self, driver_id: str) -> list[dict[str, Any]]:
|
| 365 |
+
query = (
|
| 366 |
+
self.client.table("driver_trips")
|
| 367 |
+
.select("*, driver_cars(*)")
|
| 368 |
+
.eq("driver_id", driver_id)
|
| 369 |
+
.eq("status", "active")
|
| 370 |
+
)
|
| 371 |
+
query = self._apply_not_departed_filter(query).order("departure_date").order("departure_time")
|
| 372 |
+
response = await query.execute()
|
| 373 |
+
return _response_data(response) or []
|
| 374 |
+
|
| 375 |
+
async def create_driver_car(
|
| 376 |
+
self,
|
| 377 |
+
*,
|
| 378 |
+
driver_id: str,
|
| 379 |
+
car_type: str,
|
| 380 |
+
plate_number: str | None = None,
|
| 381 |
+
seat_count: int | None = None,
|
| 382 |
+
) -> dict[str, Any]:
|
| 383 |
+
payload: dict[str, Any] = {
|
| 384 |
+
"driver_id": driver_id,
|
| 385 |
+
"car_type": car_type,
|
| 386 |
+
}
|
| 387 |
+
if plate_number is not None:
|
| 388 |
+
payload["plate_number"] = plate_number
|
| 389 |
+
if seat_count is not None:
|
| 390 |
+
payload["seat_count"] = seat_count
|
| 391 |
+
|
| 392 |
+
response = await self.client.table("driver_cars").insert(payload).execute()
|
| 393 |
+
data = _response_data(response)
|
| 394 |
+
return data[0] if isinstance(data, list) else data
|
| 395 |
+
|
| 396 |
+
async def create_driver_trip(
|
| 397 |
+
self,
|
| 398 |
+
*,
|
| 399 |
+
driver_id: str,
|
| 400 |
+
car_id: str | None,
|
| 401 |
+
departure: str,
|
| 402 |
+
destination: str,
|
| 403 |
+
departure_date: date,
|
| 404 |
+
departure_time: str,
|
| 405 |
+
available_seats: int,
|
| 406 |
+
total_seats: int,
|
| 407 |
+
price: float,
|
| 408 |
+
) -> dict[str, Any]:
|
| 409 |
+
payload = {
|
| 410 |
+
"driver_id": driver_id,
|
| 411 |
+
"car_id": car_id,
|
| 412 |
+
"departure": departure,
|
| 413 |
+
"destination": destination,
|
| 414 |
+
"departure_date": departure_date.isoformat(),
|
| 415 |
+
"departure_time": departure_time,
|
| 416 |
+
"available_seats": available_seats,
|
| 417 |
+
"total_seats": total_seats,
|
| 418 |
+
"price": price,
|
| 419 |
+
"status": "active",
|
| 420 |
+
}
|
| 421 |
+
response = await self.client.table("driver_trips").insert(payload).execute()
|
| 422 |
+
data = _response_data(response)
|
| 423 |
+
trip = data[0] if isinstance(data, list) else data
|
| 424 |
+
return await self.get_trip_by_id(str(trip["id"])) or trip
|
| 425 |
+
|
| 426 |
+
async def create_booking_lead(
|
| 427 |
+
self,
|
| 428 |
+
*,
|
| 429 |
+
customer_id: str,
|
| 430 |
+
trip_id: str,
|
| 431 |
+
requested_seats: int,
|
| 432 |
+
notes: str | None,
|
| 433 |
+
) -> dict[str, Any]:
|
| 434 |
+
payload = {
|
| 435 |
+
"customer_id": customer_id,
|
| 436 |
+
"trip_id": trip_id,
|
| 437 |
+
"requested_seats": requested_seats,
|
| 438 |
+
"status": "pending",
|
| 439 |
+
"notes": notes,
|
| 440 |
+
"driver_notification_status": "not_sent",
|
| 441 |
+
}
|
| 442 |
+
response = await self.client.table("booking_leads").insert(payload).execute()
|
| 443 |
+
data = _response_data(response)
|
| 444 |
+
return data[0] if isinstance(data, list) else data
|
| 445 |
+
|
| 446 |
+
async def update_booking_lead_notification(
|
| 447 |
+
self,
|
| 448 |
+
*,
|
| 449 |
+
lead_id: str,
|
| 450 |
+
status: str,
|
| 451 |
+
metadata: dict[str, Any] | None = None,
|
| 452 |
+
) -> dict[str, Any]:
|
| 453 |
+
payload: dict[str, Any] = {"driver_notification_status": status}
|
| 454 |
+
if metadata is not None:
|
| 455 |
+
payload["metadata"] = metadata
|
| 456 |
+
response = (
|
| 457 |
+
await self.client.table("booking_leads").update(payload).eq("id", lead_id).execute()
|
| 458 |
+
)
|
| 459 |
+
data = _response_data(response)
|
| 460 |
+
return data[0] if isinstance(data, list) else data
|
app/main.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import AsyncIterator
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
|
| 6 |
+
from app.api.routes import router
|
| 7 |
+
from app.config import Settings, get_settings
|
| 8 |
+
from app.services.container import ServiceContainer
|
| 9 |
+
from app.utils.logging import configure_logging
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def create_app(
|
| 13 |
+
*,
|
| 14 |
+
settings: Settings | None = None,
|
| 15 |
+
container: ServiceContainer | None = None,
|
| 16 |
+
) -> FastAPI:
|
| 17 |
+
@asynccontextmanager
|
| 18 |
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
| 19 |
+
active_settings = settings or (container.settings if container else get_settings())
|
| 20 |
+
configure_logging(active_settings.log_level)
|
| 21 |
+
app.state.container = container or await ServiceContainer.from_settings(active_settings)
|
| 22 |
+
yield
|
| 23 |
+
|
| 24 |
+
app = FastAPI(title="FALSA API", version="0.1.0", lifespan=lifespan)
|
| 25 |
+
app.include_router(router)
|
| 26 |
+
return app
|
app/models/api.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class HealthResponse(BaseModel):
|
| 6 |
+
status: str = "ok"
|
| 7 |
+
service: str = "FALSA"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SeedInfoResponse(BaseModel):
|
| 11 |
+
indexed_chunks: int
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SyncTripsResponse(BaseModel):
|
| 15 |
+
indexed_trips: int
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class WebhookAcceptedResponse(BaseModel):
|
| 19 |
+
status: str = "accepted"
|
| 20 |
+
messages: int = Field(ge=0)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class WebhookDebugResponse(BaseModel):
|
| 24 |
+
status: str = "accepted"
|
| 25 |
+
messages: int = Field(ge=0)
|
| 26 |
+
replies: list[str] = Field(default_factory=list)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class JinaEmbeddingRequest(BaseModel):
|
| 30 |
+
text: str = Field(min_length=1)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class JinaEmbeddingResponse(BaseModel):
|
| 34 |
+
status: str = "ok"
|
| 35 |
+
text: str
|
| 36 |
+
embedding: list[float]
|
| 37 |
+
dimensions: int
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class LLMToolCallRequest(BaseModel):
|
| 41 |
+
message: str = Field(min_length=1)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class DriverDebugRequest(BaseModel):
|
| 45 |
+
message: str = Field(min_length=1)
|
| 46 |
+
client_number: str = Field(min_length=1)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class LLMToolCallResponse(BaseModel):
|
| 50 |
+
status: str = "ok"
|
| 51 |
+
llm_response: str | None = None
|
| 52 |
+
tool_calls: list[dict[str, Any]]
|
| 53 |
+
tool_results: list[dict[str, Any]] = Field(default_factory=list)
|
app/models/domain.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import Any, Literal
|
| 3 |
+
|
| 4 |
+
SenderType = Literal["customer", "assistant", "driver", "system"]
|
| 5 |
+
UserMode = Literal["new_user", "driver", "passenger"]
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(slots=True)
|
| 9 |
+
class WhatsAppInboundMessage:
|
| 10 |
+
message_id: str
|
| 11 |
+
from_phone: str
|
| 12 |
+
text: str
|
| 13 |
+
timestamp: str | None = None
|
| 14 |
+
profile_name: str | None = None
|
| 15 |
+
phone_number_id: str | None = None
|
| 16 |
+
raw: dict[str, Any] = field(default_factory=dict)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(slots=True)
|
| 20 |
+
class ToolCall:
|
| 21 |
+
id: str
|
| 22 |
+
name: str
|
| 23 |
+
arguments: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(slots=True)
|
| 27 |
+
class AIProviderResponse:
|
| 28 |
+
content: str | None = None
|
| 29 |
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 30 |
+
raw_message: dict[str, Any] = field(default_factory=dict)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(slots=True)
|
| 34 |
+
class ToolResult:
|
| 35 |
+
ok: bool
|
| 36 |
+
data: dict[str, Any]
|
| 37 |
+
error: str | None = None
|
| 38 |
+
|
| 39 |
+
def to_payload(self) -> dict[str, Any]:
|
| 40 |
+
payload: dict[str, Any] = {"ok": self.ok, "data": self.data}
|
| 41 |
+
if self.error:
|
| 42 |
+
payload["error"] = self.error
|
| 43 |
+
return payload
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.container import ServiceContainer
|
| 2 |
+
|
| 3 |
+
__all__ = ["ServiceContainer"]
|
app/services/admin_service.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from app.config import Settings
|
| 5 |
+
from app.database.supabase import SupabaseRepository
|
| 6 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 7 |
+
from app.services.trip_indexing import build_trip_embedding_record
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AdminService:
|
| 11 |
+
def __init__(
|
| 12 |
+
self,
|
| 13 |
+
*,
|
| 14 |
+
repository: SupabaseRepository,
|
| 15 |
+
embeddings: JinaEmbeddingService,
|
| 16 |
+
settings: Settings,
|
| 17 |
+
info_path: Path | None = None,
|
| 18 |
+
) -> None:
|
| 19 |
+
self.repository = repository
|
| 20 |
+
self.embeddings = embeddings
|
| 21 |
+
self.settings = settings
|
| 22 |
+
self.info_path = info_path or Path("prompts/falsa_info.md")
|
| 23 |
+
|
| 24 |
+
async def seed_info(self) -> int:
|
| 25 |
+
text = self.info_path.read_text(encoding="utf-8")
|
| 26 |
+
chunk_texts = _chunk_markdown(text)
|
| 27 |
+
embeddings = await self.embeddings.embed_passages(chunk_texts)
|
| 28 |
+
chunks = []
|
| 29 |
+
for chunk, embedding in zip(chunk_texts, embeddings, strict=True):
|
| 30 |
+
digest = hashlib.sha256(chunk.encode("utf-8")).hexdigest()[:24]
|
| 31 |
+
chunks.append(
|
| 32 |
+
{
|
| 33 |
+
"id": f"info-{digest}",
|
| 34 |
+
"chunk_text": chunk,
|
| 35 |
+
"source": str(self.info_path),
|
| 36 |
+
"embedding": embedding,
|
| 37 |
+
"embedding_model": self.settings.jina_embedding_model,
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
return await self.repository.upsert_info_chunks(chunks)
|
| 41 |
+
|
| 42 |
+
async def sync_trips(self) -> int:
|
| 43 |
+
trips = await self.repository.list_active_trips()
|
| 44 |
+
trip_records = [
|
| 45 |
+
build_trip_embedding_record(trip, self.settings.jina_embedding_model)
|
| 46 |
+
for trip in trips
|
| 47 |
+
]
|
| 48 |
+
embeddings = await self.embeddings.embed_passages(
|
| 49 |
+
[record["chunk_text"] for record in trip_records]
|
| 50 |
+
)
|
| 51 |
+
for record, embedding in zip(trip_records, embeddings, strict=True):
|
| 52 |
+
record["embedding"] = embedding
|
| 53 |
+
return await self.repository.upsert_trip_embeddings(trip_records)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _chunk_markdown(text: str, *, max_chars: int = 1200) -> list[str]:
|
| 57 |
+
sections = [section.strip() for section in text.split("\n## ") if section.strip()]
|
| 58 |
+
chunks: list[str] = []
|
| 59 |
+
for index, section in enumerate(sections):
|
| 60 |
+
content = section if index == 0 else f"## {section}"
|
| 61 |
+
if len(content) <= max_chars:
|
| 62 |
+
chunks.append(content)
|
| 63 |
+
continue
|
| 64 |
+
for start in range(0, len(content), max_chars):
|
| 65 |
+
chunks.append(content[start : start + max_chars].strip())
|
| 66 |
+
return chunks
|
app/services/container.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
from app.ai.orchestrator import AIOrchestrator
|
| 4 |
+
from app.ai.providers import GroqChatProvider, HuggingFaceChatProvider
|
| 5 |
+
from app.config import Settings
|
| 6 |
+
from app.database.supabase import SupabaseRepository, create_supabase_client
|
| 7 |
+
from app.services.admin_service import AdminService
|
| 8 |
+
from app.services.conversation_service import ConversationService
|
| 9 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 10 |
+
from app.whatsapp.client import WhatsAppClient
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(slots=True)
|
| 14 |
+
class ServiceContainer:
|
| 15 |
+
settings: Settings
|
| 16 |
+
repository: SupabaseRepository
|
| 17 |
+
embeddings: JinaEmbeddingService
|
| 18 |
+
whatsapp: WhatsAppClient
|
| 19 |
+
ai: AIOrchestrator
|
| 20 |
+
conversation: ConversationService
|
| 21 |
+
admin: AdminService
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
async def from_settings(cls, settings: Settings) -> "ServiceContainer":
|
| 25 |
+
supabase_client = await create_supabase_client(settings)
|
| 26 |
+
repository = SupabaseRepository(supabase_client)
|
| 27 |
+
embeddings = JinaEmbeddingService(settings)
|
| 28 |
+
whatsapp = WhatsAppClient(settings)
|
| 29 |
+
ai = AIOrchestrator(
|
| 30 |
+
primary=GroqChatProvider(settings),
|
| 31 |
+
fallback=HuggingFaceChatProvider(settings),
|
| 32 |
+
temperature=settings.ai_temperature,
|
| 33 |
+
max_tool_iterations=settings.ai_max_tool_iterations,
|
| 34 |
+
)
|
| 35 |
+
conversation = ConversationService(
|
| 36 |
+
repository=repository,
|
| 37 |
+
embeddings=embeddings,
|
| 38 |
+
whatsapp=whatsapp,
|
| 39 |
+
ai=ai,
|
| 40 |
+
settings=settings,
|
| 41 |
+
)
|
| 42 |
+
admin = AdminService(repository=repository, embeddings=embeddings, settings=settings)
|
| 43 |
+
return cls(
|
| 44 |
+
settings=settings,
|
| 45 |
+
repository=repository,
|
| 46 |
+
embeddings=embeddings,
|
| 47 |
+
whatsapp=whatsapp,
|
| 48 |
+
ai=ai,
|
| 49 |
+
conversation=conversation,
|
| 50 |
+
admin=admin,
|
| 51 |
+
)
|
app/services/conversation_service.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from app.ai.orchestrator import AIOrchestrator
|
| 6 |
+
from app.ai.tool_schemas import get_tool_schemas
|
| 7 |
+
from app.config import Settings
|
| 8 |
+
from app.database.supabase import SupabaseRepository
|
| 9 |
+
from app.models.domain import UserMode, WhatsAppInboundMessage
|
| 10 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 11 |
+
from app.tools.handlers import FalsaToolHandlers
|
| 12 |
+
from app.tools.registry import ToolRegistry
|
| 13 |
+
from app.utils.time import now_in_timezone
|
| 14 |
+
from app.whatsapp.client import WhatsAppClient
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
_PROMPT_PATHS: dict[UserMode, Path] = {
|
| 19 |
+
"new_user": Path("prompts/system_new_user.md"),
|
| 20 |
+
"driver": Path("prompts/system_driver.md"),
|
| 21 |
+
"passenger": Path("prompts/system_passenger.md"),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
_TOOLS_BY_MODE: dict[UserMode, list[str]] = {
|
| 25 |
+
"new_user": [
|
| 26 |
+
"about_falsa",
|
| 27 |
+
"create_driver_account",
|
| 28 |
+
"switch_to_driver",
|
| 29 |
+
"switch_to_passenger",
|
| 30 |
+
],
|
| 31 |
+
"driver": [
|
| 32 |
+
"about_falsa",
|
| 33 |
+
"check_driver_info",
|
| 34 |
+
"check_driver_trips",
|
| 35 |
+
"add_driver_car",
|
| 36 |
+
"add_trip_by_driver",
|
| 37 |
+
"switch_to_passenger",
|
| 38 |
+
],
|
| 39 |
+
"passenger": [
|
| 40 |
+
"about_falsa",
|
| 41 |
+
"search_trips",
|
| 42 |
+
"create_booking_lead",
|
| 43 |
+
"create_driver_account",
|
| 44 |
+
"switch_to_driver",
|
| 45 |
+
],
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ConversationService:
|
| 50 |
+
def __init__(
|
| 51 |
+
self,
|
| 52 |
+
*,
|
| 53 |
+
repository: SupabaseRepository,
|
| 54 |
+
embeddings: JinaEmbeddingService,
|
| 55 |
+
whatsapp: WhatsAppClient,
|
| 56 |
+
ai: AIOrchestrator,
|
| 57 |
+
settings: Settings,
|
| 58 |
+
system_prompt_path: Path | None = None,
|
| 59 |
+
) -> None:
|
| 60 |
+
self.repository = repository
|
| 61 |
+
self.embeddings = embeddings
|
| 62 |
+
self.whatsapp = whatsapp
|
| 63 |
+
self.ai = ai
|
| 64 |
+
self.settings = settings
|
| 65 |
+
self.system_prompt_path = system_prompt_path
|
| 66 |
+
|
| 67 |
+
async def handle_inbound_message(self, inbound: WhatsAppInboundMessage) -> str | None:
|
| 68 |
+
if await self.repository.message_exists(inbound.message_id):
|
| 69 |
+
logger.info("Skipping duplicate WhatsApp message %s", inbound.message_id)
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
customer = await self.repository.upsert_customer(
|
| 73 |
+
phone_number=inbound.from_phone,
|
| 74 |
+
name=inbound.profile_name,
|
| 75 |
+
)
|
| 76 |
+
current_message = await self.repository.create_message(
|
| 77 |
+
customer_id=str(customer["id"]),
|
| 78 |
+
sender_type="customer",
|
| 79 |
+
message=inbound.text,
|
| 80 |
+
whatsapp_message_id=inbound.message_id,
|
| 81 |
+
metadata={"whatsapp": inbound.raw, "timestamp": inbound.timestamp},
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
context = await self.repository.get_recent_context_messages(
|
| 85 |
+
customer_id=str(customer["id"]),
|
| 86 |
+
current_message_id=str(current_message["id"]),
|
| 87 |
+
limit=4,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
user_mode = _resolve_user_mode(customer)
|
| 91 |
+
registry = self._tool_registry(customer, sender_phone=inbound.from_phone, user_mode=user_mode)
|
| 92 |
+
reply = await self.ai.generate_reply(
|
| 93 |
+
messages=self._ai_messages(context, user_mode=user_mode),
|
| 94 |
+
tools=get_tool_schemas(user_mode),
|
| 95 |
+
registry=registry,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
await self.repository.create_message(
|
| 99 |
+
customer_id=str(customer["id"]),
|
| 100 |
+
sender_type="assistant",
|
| 101 |
+
message=reply,
|
| 102 |
+
metadata={"provider_flow": "groq_primary_hf_fallback", "user_mode": user_mode},
|
| 103 |
+
)
|
| 104 |
+
# await self.whatsapp.send_text(inbound.from_phone, reply)
|
| 105 |
+
return reply
|
| 106 |
+
|
| 107 |
+
def _tool_registry(
|
| 108 |
+
self,
|
| 109 |
+
customer: dict[str, Any],
|
| 110 |
+
*,
|
| 111 |
+
sender_phone: str,
|
| 112 |
+
user_mode: UserMode,
|
| 113 |
+
) -> ToolRegistry:
|
| 114 |
+
handlers = FalsaToolHandlers(
|
| 115 |
+
repository=self.repository,
|
| 116 |
+
embeddings=self.embeddings,
|
| 117 |
+
whatsapp=self.whatsapp,
|
| 118 |
+
customer=customer,
|
| 119 |
+
sender_phone=sender_phone,
|
| 120 |
+
embedding_model=self.settings.jina_embedding_model,
|
| 121 |
+
)
|
| 122 |
+
registry = ToolRegistry()
|
| 123 |
+
for tool_name in _TOOLS_BY_MODE[user_mode]:
|
| 124 |
+
registry.register(tool_name, getattr(handlers, tool_name))
|
| 125 |
+
return registry
|
| 126 |
+
|
| 127 |
+
def _ai_messages(
|
| 128 |
+
self,
|
| 129 |
+
context: list[dict[str, Any]],
|
| 130 |
+
*,
|
| 131 |
+
user_mode: UserMode,
|
| 132 |
+
) -> list[dict[str, Any]]:
|
| 133 |
+
messages = [
|
| 134 |
+
{
|
| 135 |
+
"role": "system",
|
| 136 |
+
"content": self._system_prompt(user_mode),
|
| 137 |
+
}
|
| 138 |
+
]
|
| 139 |
+
for row in context:
|
| 140 |
+
role = _sender_to_ai_role(row.get("sender_type"))
|
| 141 |
+
messages.append({"role": role, "content": row.get("message") or ""})
|
| 142 |
+
return messages
|
| 143 |
+
|
| 144 |
+
def _system_prompt(self, user_mode: UserMode) -> str:
|
| 145 |
+
if self.system_prompt_path is not None:
|
| 146 |
+
template = self.system_prompt_path.read_text(encoding="utf-8")
|
| 147 |
+
else:
|
| 148 |
+
template = _PROMPT_PATHS[user_mode].read_text(encoding="utf-8")
|
| 149 |
+
current_datetime = now_in_timezone(self.settings.app_timezone).isoformat()
|
| 150 |
+
return template.format(
|
| 151 |
+
current_datetime=current_datetime,
|
| 152 |
+
timezone=self.settings.app_timezone,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _resolve_user_mode(customer: dict[str, Any]) -> UserMode:
|
| 157 |
+
mode = customer.get("user_mode")
|
| 158 |
+
if mode == "driver":
|
| 159 |
+
return "driver"
|
| 160 |
+
if mode == "passenger":
|
| 161 |
+
return "passenger"
|
| 162 |
+
return "new_user"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _sender_to_ai_role(sender_type: str | None) -> str:
|
| 166 |
+
if sender_type == "assistant":
|
| 167 |
+
return "assistant"
|
| 168 |
+
if sender_type == "customer":
|
| 169 |
+
return "user"
|
| 170 |
+
return "system"
|
app/services/embedding_service.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
|
| 5 |
+
from app.config import Settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class EmbeddingServiceError(RuntimeError):
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class JinaEmbeddingService:
|
| 13 |
+
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
| 14 |
+
self.settings = settings
|
| 15 |
+
self._client = client
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def client(self) -> httpx.AsyncClient:
|
| 19 |
+
if self._client is None:
|
| 20 |
+
self._client = httpx.AsyncClient(timeout=self.settings.request_timeout_seconds)
|
| 21 |
+
return self._client
|
| 22 |
+
|
| 23 |
+
async def embed_query(self, text: str) -> list[float]:
|
| 24 |
+
embeddings = await self.embed_texts([text], task=self.settings.jina_query_task)
|
| 25 |
+
return embeddings[0]
|
| 26 |
+
|
| 27 |
+
async def embed_passages(self, texts: list[str]) -> list[list[float]]:
|
| 28 |
+
return await self.embed_texts(texts, task=self.settings.jina_passage_task)
|
| 29 |
+
|
| 30 |
+
async def embed_texts(self, texts: list[str], *, task: str) -> list[list[float]]:
|
| 31 |
+
cleaned = [text.strip() for text in texts if text and text.strip()]
|
| 32 |
+
if not cleaned:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
payload: dict[str, Any] = {
|
| 36 |
+
"model": self.settings.jina_embedding_model,
|
| 37 |
+
"input": [
|
| 38 |
+
_with_retrieval_prefix(text, task, self.settings.jina_embedding_model)
|
| 39 |
+
for text in cleaned
|
| 40 |
+
],
|
| 41 |
+
"dimensions": self.settings.jina_embedding_dimensions,
|
| 42 |
+
"task": task,
|
| 43 |
+
"truncate": True,
|
| 44 |
+
}
|
| 45 |
+
headers = {
|
| 46 |
+
"Authorization": f"Bearer {self.settings.jina_api_key}",
|
| 47 |
+
"Content-Type": "application/json",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
response = await self.client.post(
|
| 52 |
+
self.settings.jina_embedding_endpoint,
|
| 53 |
+
headers=headers,
|
| 54 |
+
json=payload,
|
| 55 |
+
)
|
| 56 |
+
response.raise_for_status()
|
| 57 |
+
except httpx.HTTPError as exc:
|
| 58 |
+
raise EmbeddingServiceError(f"Jina embedding request failed: {exc}") from exc
|
| 59 |
+
|
| 60 |
+
body = response.json()
|
| 61 |
+
data = body.get("data")
|
| 62 |
+
if not isinstance(data, list):
|
| 63 |
+
raise EmbeddingServiceError("Jina embedding response did not include a data array")
|
| 64 |
+
|
| 65 |
+
embeddings = [_embedding_from_item(item) for item in sorted(data, key=_embedding_index)]
|
| 66 |
+
if len(embeddings) != len(cleaned):
|
| 67 |
+
raise EmbeddingServiceError("Jina embedding response count did not match input count")
|
| 68 |
+
return embeddings
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _with_retrieval_prefix(text: str, task: str, model: str) -> str:
|
| 72 |
+
if "v5" not in model:
|
| 73 |
+
return text
|
| 74 |
+
if task == "retrieval.query" and not text.startswith("Query:"):
|
| 75 |
+
return f"Query: {text}"
|
| 76 |
+
if task == "retrieval.passage" and not text.startswith("Document:"):
|
| 77 |
+
return f"Document: {text}"
|
| 78 |
+
return text
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _embedding_index(item: Any) -> int:
|
| 82 |
+
if isinstance(item, dict):
|
| 83 |
+
return int(item.get("index") or 0)
|
| 84 |
+
return 0
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _embedding_from_item(item: Any) -> list[float]:
|
| 88 |
+
if not isinstance(item, dict) or not isinstance(item.get("embedding"), list):
|
| 89 |
+
raise EmbeddingServiceError("Jina embedding item did not include an embedding array")
|
| 90 |
+
return [float(value) for value in item["embedding"]]
|
app/services/trip_indexing.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from app.database.supabase import SupabaseRepository
|
| 4 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 5 |
+
from app.utils.departure import trip_departure_bucket, trip_departure_date
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_trip_embedding_record(trip: dict[str, Any], embedding_model: str) -> dict[str, Any]:
|
| 9 |
+
driver = _first_or_dict(trip.get("drivers")) or {}
|
| 10 |
+
car = _first_or_dict(trip.get("driver_cars")) or {}
|
| 11 |
+
trip_id = str(trip.get("id") or trip.get("trip_id"))
|
| 12 |
+
departure_date = trip_departure_date(trip)
|
| 13 |
+
departure_time = trip_departure_bucket(trip)
|
| 14 |
+
chunk_text = (
|
| 15 |
+
f"Trip {trip_id}: {trip.get('departure')} to {trip.get('destination')} "
|
| 16 |
+
f"on {departure_date} during {departure_time}. "
|
| 17 |
+
f"Available seats: {trip.get('available_seats')} of {trip.get('total_seats')}. "
|
| 18 |
+
f"Vehicle: {car.get('car_type')}. Driver: {driver.get('name')}. "
|
| 19 |
+
f"Price: {trip.get('price')}. Status: {trip.get('status')}."
|
| 20 |
+
)
|
| 21 |
+
return {
|
| 22 |
+
"trip_id": trip_id,
|
| 23 |
+
"chunk_text": chunk_text,
|
| 24 |
+
"embedding_model": embedding_model,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def index_trip(
|
| 29 |
+
*,
|
| 30 |
+
repository: SupabaseRepository,
|
| 31 |
+
embeddings: JinaEmbeddingService,
|
| 32 |
+
embedding_model: str,
|
| 33 |
+
trip: dict[str, Any],
|
| 34 |
+
) -> None:
|
| 35 |
+
record = build_trip_embedding_record(trip, embedding_model)
|
| 36 |
+
record["embedding"] = (await embeddings.embed_passages([record["chunk_text"]]))[0]
|
| 37 |
+
await repository.upsert_trip_embeddings([record])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _first_or_dict(value: Any) -> dict[str, Any] | None:
|
| 41 |
+
if isinstance(value, list):
|
| 42 |
+
return value[0] if value else None
|
| 43 |
+
if isinstance(value, dict):
|
| 44 |
+
return value
|
| 45 |
+
return None
|
app/tools/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.tools.registry import ToolRegistry
|
| 2 |
+
|
| 3 |
+
__all__ = ["ToolRegistry"]
|
app/tools/handlers.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal, InvalidOperation
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from app.database.supabase import SupabaseRepository
|
| 5 |
+
from app.models.domain import ToolResult
|
| 6 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 7 |
+
from app.services.trip_indexing import index_trip
|
| 8 |
+
from app.utils.departure import (
|
| 9 |
+
_parse_date_value,
|
| 10 |
+
normalize_departure_bucket,
|
| 11 |
+
parse_departure_request,
|
| 12 |
+
parse_requested_clock_time,
|
| 13 |
+
trip_departure_bucket,
|
| 14 |
+
trip_departure_date,
|
| 15 |
+
trip_satisfies_departure_request,
|
| 16 |
+
)
|
| 17 |
+
from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class FalsaToolHandlers:
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
*,
|
| 24 |
+
repository: SupabaseRepository,
|
| 25 |
+
embeddings: JinaEmbeddingService,
|
| 26 |
+
whatsapp: WhatsAppClient,
|
| 27 |
+
customer: dict[str, Any],
|
| 28 |
+
sender_phone: str,
|
| 29 |
+
embedding_model: str,
|
| 30 |
+
) -> None:
|
| 31 |
+
self.repository = repository
|
| 32 |
+
self.embeddings = embeddings
|
| 33 |
+
self.whatsapp = whatsapp
|
| 34 |
+
self.customer = customer
|
| 35 |
+
self.sender_phone = sender_phone
|
| 36 |
+
self.embedding_model = embedding_model
|
| 37 |
+
|
| 38 |
+
async def about_falsa(self, arguments: dict[str, Any]) -> ToolResult:
|
| 39 |
+
query = str(arguments.get("query") or "").strip()
|
| 40 |
+
if not query:
|
| 41 |
+
return ToolResult(ok=False, data={}, error="query is required")
|
| 42 |
+
|
| 43 |
+
query_embedding = await self.embeddings.embed_query(query)
|
| 44 |
+
matches = await self.repository.search_info_chunks_by_vector(
|
| 45 |
+
query_embedding=query_embedding,
|
| 46 |
+
match_count=5,
|
| 47 |
+
)
|
| 48 |
+
if not matches:
|
| 49 |
+
return ToolResult(
|
| 50 |
+
ok=True,
|
| 51 |
+
data={
|
| 52 |
+
"answer": "No matching FALSA policy or FAQ content was found.",
|
| 53 |
+
"sources": [],
|
| 54 |
+
},
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return ToolResult(
|
| 58 |
+
ok=True,
|
| 59 |
+
data={
|
| 60 |
+
"answer_context": [
|
| 61 |
+
{
|
| 62 |
+
"text": match.get("chunk_text")
|
| 63 |
+
or match.get("metadata", {}).get("chunk_text"),
|
| 64 |
+
"source": match.get("source") or match.get("metadata", {}).get("source"),
|
| 65 |
+
"score": match.get("score") or match.get("similarity"),
|
| 66 |
+
}
|
| 67 |
+
for match in matches
|
| 68 |
+
],
|
| 69 |
+
},
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
async def search_trips(self, arguments: dict[str, Any]) -> ToolResult:
|
| 73 |
+
departure = _optional_string(arguments.get("departure"))
|
| 74 |
+
destination = _optional_string(arguments.get("destination"))
|
| 75 |
+
travel_date = _optional_string(arguments.get("travel_date"))
|
| 76 |
+
travel_time = _optional_string(arguments.get("travel_time"))
|
| 77 |
+
travel_time_exact = _optional_string(arguments.get("travel_time_exact"))
|
| 78 |
+
travel_datetime = _optional_string(arguments.get("travel_datetime"))
|
| 79 |
+
seats = _optional_int(arguments.get("seats")) or 1
|
| 80 |
+
vehicle_type = _optional_string(arguments.get("vehicle_type"))
|
| 81 |
+
vector_query_text = _optional_string(arguments.get("vector_query_text"))
|
| 82 |
+
departure_request = parse_departure_request(
|
| 83 |
+
travel_date=travel_date,
|
| 84 |
+
travel_time=travel_time,
|
| 85 |
+
travel_datetime=travel_datetime,
|
| 86 |
+
)
|
| 87 |
+
requested_time = parse_requested_clock_time(
|
| 88 |
+
travel_time=travel_time,
|
| 89 |
+
travel_datetime=travel_datetime,
|
| 90 |
+
exact_time=travel_time_exact,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if not departure or not destination:
|
| 94 |
+
return ToolResult(
|
| 95 |
+
ok=False,
|
| 96 |
+
data={},
|
| 97 |
+
error="departure and destination are required before searching trips",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
query = vector_query_text or _trip_vector_query_text(
|
| 101 |
+
departure=departure,
|
| 102 |
+
destination=destination,
|
| 103 |
+
travel_date=travel_date,
|
| 104 |
+
travel_time=travel_time,
|
| 105 |
+
travel_time_exact=travel_time_exact,
|
| 106 |
+
travel_datetime=travel_datetime,
|
| 107 |
+
seats=seats,
|
| 108 |
+
vehicle_type=vehicle_type,
|
| 109 |
+
)
|
| 110 |
+
query_embedding = await self.embeddings.embed_query(query)
|
| 111 |
+
trips = await self.repository.search_trips_by_vector(
|
| 112 |
+
query_embedding=query_embedding,
|
| 113 |
+
departure=departure,
|
| 114 |
+
destination=destination,
|
| 115 |
+
departure_date=departure_request.departure_date,
|
| 116 |
+
departure_time=departure_request.departure_time,
|
| 117 |
+
requested_time=requested_time,
|
| 118 |
+
seats=seats,
|
| 119 |
+
vehicle_type=vehicle_type,
|
| 120 |
+
match_count=10,
|
| 121 |
+
)
|
| 122 |
+
if not trips:
|
| 123 |
+
trips = await self.repository.search_active_trips(
|
| 124 |
+
departure=departure,
|
| 125 |
+
destination=destination,
|
| 126 |
+
seats=seats,
|
| 127 |
+
vehicle_type=vehicle_type,
|
| 128 |
+
departure_request=departure_request,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
alternate_alert = _alternate_time_alert(trips)
|
| 132 |
+
filtered = _sort_trip_summaries([
|
| 133 |
+
_trip_summary(trip)
|
| 134 |
+
for trip in trips
|
| 135 |
+
if _is_trip_match(
|
| 136 |
+
trip,
|
| 137 |
+
departure=departure,
|
| 138 |
+
destination=destination,
|
| 139 |
+
seats=seats,
|
| 140 |
+
vehicle_type=vehicle_type,
|
| 141 |
+
departure_request=departure_request,
|
| 142 |
+
)
|
| 143 |
+
])
|
| 144 |
+
|
| 145 |
+
return ToolResult(
|
| 146 |
+
ok=True,
|
| 147 |
+
data={
|
| 148 |
+
"matches": filtered[:5],
|
| 149 |
+
"count": len(filtered[:5]),
|
| 150 |
+
"alternate_alert": alternate_alert,
|
| 151 |
+
"note": (
|
| 152 |
+
"No active matching trips were found."
|
| 153 |
+
if not filtered
|
| 154 |
+
else _trip_search_note(alternate_alert)
|
| 155 |
+
),
|
| 156 |
+
},
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
async def create_booking_lead(self, arguments: dict[str, Any]) -> ToolResult:
|
| 160 |
+
trip_id = _optional_string(arguments.get("trip_id"))
|
| 161 |
+
requested_seats = _optional_int(arguments.get("requested_seats")) or 1
|
| 162 |
+
notes = _optional_string(arguments.get("notes"))
|
| 163 |
+
|
| 164 |
+
if not trip_id:
|
| 165 |
+
return ToolResult(ok=False, data={}, error="trip_id is required")
|
| 166 |
+
if requested_seats < 1:
|
| 167 |
+
return ToolResult(ok=False, data={}, error="requested_seats must be at least 1")
|
| 168 |
+
|
| 169 |
+
trip = await self.repository.get_trip_by_id(trip_id)
|
| 170 |
+
if not trip:
|
| 171 |
+
return ToolResult(ok=False, data={}, error="Trip was not found")
|
| 172 |
+
if trip.get("status") != "active":
|
| 173 |
+
return ToolResult(ok=False, data={}, error="Trip is not active")
|
| 174 |
+
if int(trip.get("available_seats") or 0) < requested_seats:
|
| 175 |
+
return ToolResult(
|
| 176 |
+
ok=False,
|
| 177 |
+
data={"available_seats": trip.get("available_seats")},
|
| 178 |
+
error="Not enough available seats",
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
lead = await self.repository.create_booking_lead(
|
| 182 |
+
customer_id=str(self.customer["id"]),
|
| 183 |
+
trip_id=trip_id,
|
| 184 |
+
requested_seats=requested_seats,
|
| 185 |
+
notes=notes,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
notification_status = "sent"
|
| 189 |
+
notification_error = None
|
| 190 |
+
try:
|
| 191 |
+
driver_phone = (_first_or_dict(trip.get("drivers")) or {}).get("phone_number")
|
| 192 |
+
if not driver_phone:
|
| 193 |
+
raise WhatsAppClientError("Driver phone number is missing")
|
| 194 |
+
await self.whatsapp.send_text(
|
| 195 |
+
driver_phone,
|
| 196 |
+
_driver_notification_text(
|
| 197 |
+
customer=self.customer,
|
| 198 |
+
trip=trip,
|
| 199 |
+
requested_seats=requested_seats,
|
| 200 |
+
notes=notes,
|
| 201 |
+
),
|
| 202 |
+
)
|
| 203 |
+
except Exception as exc: # noqa: BLE001
|
| 204 |
+
notification_status = "failed"
|
| 205 |
+
notification_error = str(exc)
|
| 206 |
+
|
| 207 |
+
await self.repository.update_booking_lead_notification(
|
| 208 |
+
lead_id=str(lead["id"]),
|
| 209 |
+
status=notification_status,
|
| 210 |
+
metadata={"error": notification_error} if notification_error else None,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
return ToolResult(
|
| 214 |
+
ok=True,
|
| 215 |
+
data={
|
| 216 |
+
"lead_id": lead["id"],
|
| 217 |
+
"status": "pending",
|
| 218 |
+
"driver_notification_status": notification_status,
|
| 219 |
+
"driver_notification_error": notification_error,
|
| 220 |
+
"message": "Booking lead created. Seats are not reserved until confirmed.",
|
| 221 |
+
},
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
async def create_driver_account(self, arguments: dict[str, Any]) -> ToolResult:
|
| 225 |
+
name = _optional_string(arguments.get("name"))
|
| 226 |
+
if not name:
|
| 227 |
+
return ToolResult(ok=False, data={}, error="name is required")
|
| 228 |
+
|
| 229 |
+
phone = self.sender_phone
|
| 230 |
+
existing = await self.repository.get_driver_by_phone(phone)
|
| 231 |
+
if existing:
|
| 232 |
+
return ToolResult(
|
| 233 |
+
ok=False,
|
| 234 |
+
data={"driver_id": existing["id"]},
|
| 235 |
+
error="Driver account already exists for this WhatsApp number",
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
driver = await self.repository.create_driver(name=name, phone_number=phone)
|
| 239 |
+
return ToolResult(
|
| 240 |
+
ok=True,
|
| 241 |
+
data={
|
| 242 |
+
"driver_id": driver["id"],
|
| 243 |
+
"name": driver.get("name"),
|
| 244 |
+
"phone_number": driver.get("phone_number"),
|
| 245 |
+
"message": "Driver account created successfully.",
|
| 246 |
+
},
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
async def check_driver_info(self, arguments: dict[str, Any]) -> ToolResult:
|
| 250 |
+
driver = await self.repository.get_driver_by_phone(self.sender_phone)
|
| 251 |
+
if not driver:
|
| 252 |
+
return ToolResult(
|
| 253 |
+
ok=False,
|
| 254 |
+
data={"action": "create_driver_account"},
|
| 255 |
+
error=(
|
| 256 |
+
"No driver account for this WhatsApp number. "
|
| 257 |
+
"Ask the sender to register with create_driver_account first."
|
| 258 |
+
),
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
cars = await self.repository.list_driver_cars(str(driver["id"]))
|
| 262 |
+
upcoming_trips = await self.repository.list_driver_trips(str(driver["id"]))
|
| 263 |
+
|
| 264 |
+
return ToolResult(
|
| 265 |
+
ok=True,
|
| 266 |
+
data={
|
| 267 |
+
"driver_id": driver["id"],
|
| 268 |
+
"name": driver.get("name"),
|
| 269 |
+
"phone_number": driver.get("phone_number"),
|
| 270 |
+
"status": driver.get("status"),
|
| 271 |
+
"vehicle_count": len(cars),
|
| 272 |
+
"active_trip_count": len(upcoming_trips),
|
| 273 |
+
"vehicles": [
|
| 274 |
+
{
|
| 275 |
+
"car_id": str(car.get("id")),
|
| 276 |
+
"name": car.get("car_type"),
|
| 277 |
+
"plate_number": car.get("plate_number"),
|
| 278 |
+
"seat_count": car.get("seat_count"),
|
| 279 |
+
}
|
| 280 |
+
for car in cars
|
| 281 |
+
],
|
| 282 |
+
"active_trips": [
|
| 283 |
+
_trip_summary(trip) for trip in upcoming_trips
|
| 284 |
+
],
|
| 285 |
+
},
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
async def check_driver_trips(self, arguments: dict[str, Any]) -> ToolResult:
|
| 289 |
+
driver = await self.repository.get_driver_by_phone(self.sender_phone)
|
| 290 |
+
if not driver:
|
| 291 |
+
return ToolResult(
|
| 292 |
+
ok=False,
|
| 293 |
+
data={"action": "create_driver_account"},
|
| 294 |
+
error=(
|
| 295 |
+
"No driver account for this WhatsApp number. "
|
| 296 |
+
"Ask the sender to register with create_driver_account first."
|
| 297 |
+
),
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
trips = await self.repository.list_driver_trips(str(driver["id"]))
|
| 301 |
+
return ToolResult(
|
| 302 |
+
ok=True,
|
| 303 |
+
data={
|
| 304 |
+
"driver_id": driver["id"],
|
| 305 |
+
"upcoming_trips": [_trip_summary(trip) for trip in trips],
|
| 306 |
+
"count": len(trips),
|
| 307 |
+
"message": (
|
| 308 |
+
"No upcoming active trips found."
|
| 309 |
+
if not trips
|
| 310 |
+
else "Upcoming active trips retrieved successfully."
|
| 311 |
+
),
|
| 312 |
+
},
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
async def add_driver_car(self, arguments: dict[str, Any]) -> ToolResult:
|
| 316 |
+
driver = await self.repository.get_driver_by_phone(self.sender_phone)
|
| 317 |
+
if not driver:
|
| 318 |
+
return ToolResult(
|
| 319 |
+
ok=False,
|
| 320 |
+
data={"action": "create_driver_account"},
|
| 321 |
+
error=(
|
| 322 |
+
"No driver account for this WhatsApp number. "
|
| 323 |
+
"Ask the sender to register with create_driver_account first."
|
| 324 |
+
),
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
car_type = _optional_string(arguments.get("name"))
|
| 328 |
+
if not car_type:
|
| 329 |
+
return ToolResult(ok=False, data={}, error="name is required")
|
| 330 |
+
|
| 331 |
+
plate_number = _optional_string(arguments.get("plate_number"))
|
| 332 |
+
seat_count = _optional_int(arguments.get("seat_count"))
|
| 333 |
+
if seat_count is not None and seat_count < 1:
|
| 334 |
+
return ToolResult(ok=False, data={}, error="seat_count must be at least 1")
|
| 335 |
+
|
| 336 |
+
car = await self.repository.create_driver_car(
|
| 337 |
+
driver_id=str(driver["id"]),
|
| 338 |
+
car_type=car_type,
|
| 339 |
+
plate_number=plate_number,
|
| 340 |
+
seat_count=seat_count,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
return ToolResult(
|
| 344 |
+
ok=True,
|
| 345 |
+
data={
|
| 346 |
+
"car_id": car.get("id"),
|
| 347 |
+
"name": car.get("car_type"),
|
| 348 |
+
"plate_number": car.get("plate_number"),
|
| 349 |
+
"seat_count": car.get("seat_count"),
|
| 350 |
+
"message": "Driver vehicle registered successfully.",
|
| 351 |
+
},
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
async def add_trip_by_driver(self, arguments: dict[str, Any]) -> ToolResult:
|
| 355 |
+
driver = await self.repository.get_driver_by_phone(self.sender_phone)
|
| 356 |
+
if not driver:
|
| 357 |
+
return ToolResult(
|
| 358 |
+
ok=False,
|
| 359 |
+
data={"action": "create_driver_account"},
|
| 360 |
+
error=(
|
| 361 |
+
"No driver account for this WhatsApp number. "
|
| 362 |
+
"Ask the sender to register with create_driver_account first."
|
| 363 |
+
),
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
departure = _optional_string(arguments.get("departure"))
|
| 367 |
+
destination = _optional_string(arguments.get("destination"))
|
| 368 |
+
if not departure or not destination:
|
| 369 |
+
return ToolResult(
|
| 370 |
+
ok=False,
|
| 371 |
+
data={},
|
| 372 |
+
error="departure and destination are required",
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
parsed_date = _parse_date_value(arguments.get("departure_date"))
|
| 376 |
+
if not parsed_date:
|
| 377 |
+
return ToolResult(
|
| 378 |
+
ok=False,
|
| 379 |
+
data={},
|
| 380 |
+
error="departure_date is required as YYYY-MM-DD",
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
departure_time = normalize_departure_bucket(arguments.get("departure_time"))
|
| 384 |
+
if not departure_time:
|
| 385 |
+
return ToolResult(
|
| 386 |
+
ok=False,
|
| 387 |
+
data={},
|
| 388 |
+
error="departure_time must be morning, noon, night, or Arabic صباح / ظهر / ليل",
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
latest_trip = await self.repository.get_driver_latest_trip(str(driver["id"]))
|
| 392 |
+
cars = await self.repository.list_driver_cars(str(driver["id"]))
|
| 393 |
+
|
| 394 |
+
vehicle_type = _optional_string(arguments.get("vehicle_type"))
|
| 395 |
+
matched_car = _resolve_driver_car(cars, vehicle_type=vehicle_type)
|
| 396 |
+
if matched_car is None and latest_trip:
|
| 397 |
+
matched_car = _resolve_driver_car(
|
| 398 |
+
cars,
|
| 399 |
+
vehicle_type=None,
|
| 400 |
+
car_id=_optional_string(latest_trip.get("car_id")),
|
| 401 |
+
)
|
| 402 |
+
if matched_car is None and len(cars) == 1:
|
| 403 |
+
matched_car = cars[0]
|
| 404 |
+
|
| 405 |
+
car_id = str(matched_car["id"]) if matched_car else None
|
| 406 |
+
|
| 407 |
+
available_seats = _optional_int(arguments.get("available_seats"))
|
| 408 |
+
if available_seats is None and latest_trip is not None:
|
| 409 |
+
available_seats = _optional_int(latest_trip.get("available_seats"))
|
| 410 |
+
|
| 411 |
+
total_seats = _optional_int(arguments.get("total_seats"))
|
| 412 |
+
if total_seats is None and latest_trip is not None:
|
| 413 |
+
total_seats = _optional_int(latest_trip.get("total_seats"))
|
| 414 |
+
|
| 415 |
+
if total_seats is None and matched_car is not None:
|
| 416 |
+
total_seats = _optional_int(matched_car.get("seat_count"))
|
| 417 |
+
|
| 418 |
+
price = _optional_price(arguments.get("price"))
|
| 419 |
+
if price is None and latest_trip is not None:
|
| 420 |
+
price = _optional_price(latest_trip.get("price"))
|
| 421 |
+
|
| 422 |
+
missing = [
|
| 423 |
+
field
|
| 424 |
+
for field, value in [
|
| 425 |
+
("available_seats", available_seats),
|
| 426 |
+
("total_seats", total_seats),
|
| 427 |
+
("price", price),
|
| 428 |
+
]
|
| 429 |
+
if value is None
|
| 430 |
+
]
|
| 431 |
+
if matched_car is None and not vehicle_type:
|
| 432 |
+
missing.insert(0, "vehicle_type")
|
| 433 |
+
if missing:
|
| 434 |
+
return ToolResult(
|
| 435 |
+
ok=False,
|
| 436 |
+
data={"missing_fields": missing},
|
| 437 |
+
error=f"Missing required trip fields: {', '.join(missing)}",
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
if not matched_car:
|
| 441 |
+
if vehicle_type:
|
| 442 |
+
return ToolResult(
|
| 443 |
+
ok=False,
|
| 444 |
+
data={},
|
| 445 |
+
error=(
|
| 446 |
+
f"No registered vehicle matches '{vehicle_type}'. "
|
| 447 |
+
"Ask the driver to use the exact car type or plate from their account."
|
| 448 |
+
),
|
| 449 |
+
)
|
| 450 |
+
return ToolResult(
|
| 451 |
+
ok=False,
|
| 452 |
+
data={},
|
| 453 |
+
error="No registered vehicle found for this driver",
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
if total_seats is None or total_seats < 1:
|
| 457 |
+
return ToolResult(ok=False, data={}, error="total_seats must be at least 1")
|
| 458 |
+
if available_seats is None or available_seats < 0:
|
| 459 |
+
return ToolResult(ok=False, data={}, error="available_seats must be at least 0")
|
| 460 |
+
if available_seats > total_seats:
|
| 461 |
+
return ToolResult(
|
| 462 |
+
ok=False,
|
| 463 |
+
data={},
|
| 464 |
+
error="available_seats cannot exceed total_seats",
|
| 465 |
+
)
|
| 466 |
+
if price is None or price < 0:
|
| 467 |
+
return ToolResult(ok=False, data={}, error="price must be zero or greater")
|
| 468 |
+
|
| 469 |
+
trip = await self.repository.create_driver_trip(
|
| 470 |
+
driver_id=str(driver["id"]),
|
| 471 |
+
car_id=car_id,
|
| 472 |
+
departure=departure,
|
| 473 |
+
destination=destination,
|
| 474 |
+
departure_date=parsed_date,
|
| 475 |
+
departure_time=departure_time,
|
| 476 |
+
available_seats=available_seats,
|
| 477 |
+
total_seats=total_seats,
|
| 478 |
+
price=price,
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
await index_trip(
|
| 482 |
+
repository=self.repository,
|
| 483 |
+
embeddings=self.embeddings,
|
| 484 |
+
embedding_model=self.embedding_model,
|
| 485 |
+
trip=trip,
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
return ToolResult(
|
| 489 |
+
ok=True,
|
| 490 |
+
data={
|
| 491 |
+
"trip_id": trip.get("id"),
|
| 492 |
+
"departure": trip.get("departure"),
|
| 493 |
+
"destination": trip.get("destination"),
|
| 494 |
+
"departure_date": parsed_date.isoformat(),
|
| 495 |
+
"departure_time": departure_time,
|
| 496 |
+
"available_seats": available_seats,
|
| 497 |
+
"total_seats": total_seats,
|
| 498 |
+
"price": price,
|
| 499 |
+
"car_id": car_id,
|
| 500 |
+
"indexed": True,
|
| 501 |
+
"message": "Trip created and indexed for search.",
|
| 502 |
+
},
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
async def switch_to_driver(self, arguments: dict[str, Any]) -> ToolResult:
|
| 506 |
+
driver = await self.repository.get_driver_by_phone(self.sender_phone)
|
| 507 |
+
if not driver:
|
| 508 |
+
return ToolResult(
|
| 509 |
+
ok=False,
|
| 510 |
+
data={"action": "create_driver_account"},
|
| 511 |
+
error=(
|
| 512 |
+
"No driver account for this WhatsApp number. "
|
| 513 |
+
"Use create_driver_account first, then switch_to_driver."
|
| 514 |
+
),
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
await self.repository.update_customer_user_mode(
|
| 518 |
+
customer_id=str(self.customer["id"]),
|
| 519 |
+
user_mode="driver",
|
| 520 |
+
)
|
| 521 |
+
self.customer["user_mode"] = "driver"
|
| 522 |
+
return ToolResult(
|
| 523 |
+
ok=True,
|
| 524 |
+
data={
|
| 525 |
+
"user_mode": "driver",
|
| 526 |
+
"driver_id": driver["id"],
|
| 527 |
+
"message": "Switched to driver mode.",
|
| 528 |
+
},
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
async def switch_to_passenger(self, arguments: dict[str, Any]) -> ToolResult:
|
| 532 |
+
name = _optional_string(arguments.get("name"))
|
| 533 |
+
if name:
|
| 534 |
+
await self.repository.update_customer_name(
|
| 535 |
+
customer_id=str(self.customer["id"]),
|
| 536 |
+
name=name,
|
| 537 |
+
)
|
| 538 |
+
self.customer["name"] = name
|
| 539 |
+
|
| 540 |
+
await self.repository.update_customer_user_mode(
|
| 541 |
+
customer_id=str(self.customer["id"]),
|
| 542 |
+
user_mode="passenger",
|
| 543 |
+
)
|
| 544 |
+
self.customer["user_mode"] = "passenger"
|
| 545 |
+
return ToolResult(
|
| 546 |
+
ok=True,
|
| 547 |
+
data={
|
| 548 |
+
"user_mode": "passenger",
|
| 549 |
+
"name": self.customer.get("name"),
|
| 550 |
+
"message": "Switched to passenger mode.",
|
| 551 |
+
},
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def _optional_string(value: Any) -> str | None:
|
| 556 |
+
if value is None:
|
| 557 |
+
return None
|
| 558 |
+
text = str(value).strip()
|
| 559 |
+
return text or None
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _optional_int(value: Any) -> int | None:
|
| 563 |
+
if value is None or value == "":
|
| 564 |
+
return None
|
| 565 |
+
return int(value)
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def _optional_price(value: Any) -> float | None:
|
| 569 |
+
if value is None or value == "":
|
| 570 |
+
return None
|
| 571 |
+
try:
|
| 572 |
+
return float(Decimal(str(value)))
|
| 573 |
+
except (InvalidOperation, ValueError):
|
| 574 |
+
return None
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _resolve_driver_car(
|
| 578 |
+
cars: list[dict[str, Any]],
|
| 579 |
+
*,
|
| 580 |
+
vehicle_type: str | None = None,
|
| 581 |
+
car_id: str | None = None,
|
| 582 |
+
) -> dict[str, Any] | None:
|
| 583 |
+
if car_id:
|
| 584 |
+
for car in cars:
|
| 585 |
+
if str(car.get("id")) == car_id:
|
| 586 |
+
return car
|
| 587 |
+
return None
|
| 588 |
+
|
| 589 |
+
if not vehicle_type:
|
| 590 |
+
return None
|
| 591 |
+
|
| 592 |
+
query = vehicle_type.lower()
|
| 593 |
+
matches = [
|
| 594 |
+
car
|
| 595 |
+
for car in cars
|
| 596 |
+
if query in str(car.get("car_type") or "").lower()
|
| 597 |
+
or query in str(car.get("plate_number") or "").lower()
|
| 598 |
+
]
|
| 599 |
+
if len(matches) == 1:
|
| 600 |
+
return matches[0]
|
| 601 |
+
if len(matches) > 1:
|
| 602 |
+
return None
|
| 603 |
+
return None
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def _trip_vector_query_text(
|
| 607 |
+
*,
|
| 608 |
+
departure: str,
|
| 609 |
+
destination: str,
|
| 610 |
+
travel_date: str | None,
|
| 611 |
+
travel_time: str | None,
|
| 612 |
+
travel_time_exact: str | None,
|
| 613 |
+
travel_datetime: str | None,
|
| 614 |
+
seats: int,
|
| 615 |
+
vehicle_type: str | None,
|
| 616 |
+
) -> str:
|
| 617 |
+
return " ".join(
|
| 618 |
+
part
|
| 619 |
+
for part in [
|
| 620 |
+
departure,
|
| 621 |
+
destination,
|
| 622 |
+
travel_date,
|
| 623 |
+
travel_time_exact,
|
| 624 |
+
travel_time,
|
| 625 |
+
travel_datetime,
|
| 626 |
+
f"{seats} seats",
|
| 627 |
+
vehicle_type,
|
| 628 |
+
]
|
| 629 |
+
if part
|
| 630 |
+
)
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
def _is_trip_match(
|
| 634 |
+
trip: dict[str, Any],
|
| 635 |
+
*,
|
| 636 |
+
departure: str,
|
| 637 |
+
destination: str,
|
| 638 |
+
seats: int,
|
| 639 |
+
vehicle_type: str | None,
|
| 640 |
+
departure_request: Any,
|
| 641 |
+
) -> bool:
|
| 642 |
+
if trip.get("status") != "active":
|
| 643 |
+
return False
|
| 644 |
+
if int(trip.get("available_seats") or 0) < seats:
|
| 645 |
+
return False
|
| 646 |
+
if departure.lower() not in str(trip.get("departure") or "").lower():
|
| 647 |
+
return False
|
| 648 |
+
if destination.lower() not in str(trip.get("destination") or "").lower():
|
| 649 |
+
return False
|
| 650 |
+
if vehicle_type:
|
| 651 |
+
car = _first_or_dict(trip.get("driver_cars")) or {}
|
| 652 |
+
car_type = car.get("car_type") or trip.get("car_type")
|
| 653 |
+
if vehicle_type.lower() not in str(car_type or "").lower():
|
| 654 |
+
return False
|
| 655 |
+
if not trip_satisfies_departure_request(trip, departure_request):
|
| 656 |
+
return False
|
| 657 |
+
return True
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _trip_summary(trip: dict[str, Any]) -> dict[str, Any]:
|
| 661 |
+
driver = _first_or_dict(trip.get("drivers")) or {}
|
| 662 |
+
car = _first_or_dict(trip.get("driver_cars")) or {}
|
| 663 |
+
return {
|
| 664 |
+
"trip_id": trip.get("trip_id") or trip.get("id"),
|
| 665 |
+
"departure": trip.get("departure"),
|
| 666 |
+
"destination": trip.get("destination"),
|
| 667 |
+
"departure_date": (
|
| 668 |
+
parsed_date.isoformat() if (parsed_date := trip_departure_date(trip)) else None
|
| 669 |
+
),
|
| 670 |
+
"departure_time": trip.get("departure_time"),
|
| 671 |
+
"departure_time_type": trip_departure_bucket(trip),
|
| 672 |
+
"available_seats": trip.get("available_seats"),
|
| 673 |
+
"total_seats": trip.get("total_seats"),
|
| 674 |
+
"price": trip.get("price"),
|
| 675 |
+
"driver_name": driver.get("name") or trip.get("driver_name"),
|
| 676 |
+
"car_type": car.get("car_type") or trip.get("car_type"),
|
| 677 |
+
"status": trip.get("status"),
|
| 678 |
+
"similarity": trip.get("similarity"),
|
| 679 |
+
"time_difference_minutes": trip.get("time_difference_minutes"),
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def _sort_trip_summaries(trips: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 684 |
+
bucket_order = {"morning": 0, "noon": 1, "night": 2}
|
| 685 |
+
return sorted(
|
| 686 |
+
trips,
|
| 687 |
+
key=lambda trip: (
|
| 688 |
+
str(trip.get("departure_date") or ""),
|
| 689 |
+
bucket_order.get(str(trip.get("departure_time_type") or ""), 99),
|
| 690 |
+
),
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def _first_or_dict(value: Any) -> dict[str, Any] | None:
|
| 695 |
+
if isinstance(value, list):
|
| 696 |
+
return value[0] if value else None
|
| 697 |
+
if isinstance(value, dict):
|
| 698 |
+
return value
|
| 699 |
+
return None
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def _alternate_time_alert(trips: list[dict[str, Any]]) -> str | None:
|
| 703 |
+
if not trips:
|
| 704 |
+
return None
|
| 705 |
+
raw_difference = trips[0].get("time_difference_minutes")
|
| 706 |
+
if raw_difference is None:
|
| 707 |
+
return None
|
| 708 |
+
try:
|
| 709 |
+
difference = int(raw_difference)
|
| 710 |
+
except (TypeError, ValueError):
|
| 711 |
+
return None
|
| 712 |
+
if difference <= 60:
|
| 713 |
+
return None
|
| 714 |
+
return (
|
| 715 |
+
"The closest available trip is more than 60 minutes away from the requested time. "
|
| 716 |
+
"Mention that it is an alternate time before listing the options."
|
| 717 |
+
)
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _trip_search_note(alternate_alert: str | None) -> str:
|
| 721 |
+
if alternate_alert:
|
| 722 |
+
return alternate_alert
|
| 723 |
+
return "Trips are available for handoff only; seats are not reserved yet."
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _driver_notification_text(
|
| 727 |
+
*,
|
| 728 |
+
customer: dict[str, Any],
|
| 729 |
+
trip: dict[str, Any],
|
| 730 |
+
requested_seats: int,
|
| 731 |
+
notes: str | None,
|
| 732 |
+
) -> str:
|
| 733 |
+
return (
|
| 734 |
+
"New FALSA booking lead\n"
|
| 735 |
+
f"Customer: {customer.get('name') or customer.get('phone_number')}\n"
|
| 736 |
+
f"Phone: {customer.get('phone_number')}\n"
|
| 737 |
+
f"Trip: {trip.get('departure')} -> {trip.get('destination')}\n"
|
| 738 |
+
f"Departure: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n"
|
| 739 |
+
f"Seats requested: {requested_seats}\n"
|
| 740 |
+
f"Notes: {notes or '-'}\n"
|
| 741 |
+
"Status: pending confirmation"
|
| 742 |
+
)
|
app/tools/registry.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Awaitable, Callable
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from app.models.domain import ToolResult
|
| 5 |
+
|
| 6 |
+
ToolHandler = Callable[[dict[str, Any]], Awaitable[ToolResult]]
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ToolRegistry:
|
| 10 |
+
def __init__(self) -> None:
|
| 11 |
+
self._handlers: dict[str, ToolHandler] = {}
|
| 12 |
+
|
| 13 |
+
def register(self, name: str, handler: ToolHandler) -> None:
|
| 14 |
+
self._handlers[name] = handler
|
| 15 |
+
|
| 16 |
+
async def execute(self, name: str, arguments: dict[str, Any]) -> ToolResult:
|
| 17 |
+
handler = self._handlers.get(name)
|
| 18 |
+
if handler is None:
|
| 19 |
+
return ToolResult(ok=False, data={}, error=f"Unknown tool: {name}")
|
| 20 |
+
try:
|
| 21 |
+
return await handler(arguments)
|
| 22 |
+
except Exception as exc: # noqa: BLE001
|
| 23 |
+
return ToolResult(ok=False, data={}, error=f"Tool {name} failed: {exc}")
|
app/utils/departure.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from datetime import date, datetime, time
|
| 6 |
+
from typing import Any, Literal
|
| 7 |
+
from zoneinfo import ZoneInfo
|
| 8 |
+
|
| 9 |
+
APP_TIMEZONE = "Asia/Aden"
|
| 10 |
+
DEPARTURE_BUCKETS = ("morning", "noon", "night")
|
| 11 |
+
DepartureBucket = Literal["morning", "noon", "night"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class DepartureRequest:
|
| 16 |
+
departure_date: date | None = None
|
| 17 |
+
departure_time: DepartureBucket | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def now_in_aden() -> datetime:
|
| 21 |
+
return datetime.now(tz=ZoneInfo(APP_TIMEZONE))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def normalize_departure_bucket(value: Any) -> DepartureBucket | None:
|
| 25 |
+
if value is None:
|
| 26 |
+
return None
|
| 27 |
+
text = str(value).strip().lower()
|
| 28 |
+
if text in DEPARTURE_BUCKETS:
|
| 29 |
+
return text # type: ignore[return-value]
|
| 30 |
+
|
| 31 |
+
parsed = _parse_datetime(text)
|
| 32 |
+
if parsed:
|
| 33 |
+
return bucket_for_time(parsed.astimezone(ZoneInfo(APP_TIMEZONE)).time())
|
| 34 |
+
|
| 35 |
+
parsed_time = _parse_time(text)
|
| 36 |
+
if parsed_time:
|
| 37 |
+
return bucket_for_time(parsed_time)
|
| 38 |
+
|
| 39 |
+
if "morning" in text or "صباح" in text:
|
| 40 |
+
return "morning"
|
| 41 |
+
if "noon" in text or "afternoon" in text or "ظهر" in text:
|
| 42 |
+
return "noon"
|
| 43 |
+
if "night" in text or "evening" in text or "ليل" in text or "مساء" in text:
|
| 44 |
+
return "night"
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def bucket_for_time(value: time) -> DepartureBucket:
|
| 49 |
+
if value.hour < 12:
|
| 50 |
+
return "morning"
|
| 51 |
+
if value.hour < 18:
|
| 52 |
+
return "noon"
|
| 53 |
+
return "night"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def parse_departure_request(
|
| 57 |
+
*,
|
| 58 |
+
travel_date: Any = None,
|
| 59 |
+
travel_time: Any = None,
|
| 60 |
+
travel_datetime: Any = None,
|
| 61 |
+
) -> DepartureRequest:
|
| 62 |
+
requested_date = _parse_date_value(travel_date)
|
| 63 |
+
requested_time = normalize_departure_bucket(travel_time)
|
| 64 |
+
|
| 65 |
+
if travel_datetime:
|
| 66 |
+
parsed_datetime = _parse_datetime(str(travel_datetime).strip())
|
| 67 |
+
if parsed_datetime:
|
| 68 |
+
aden_datetime = parsed_datetime.astimezone(ZoneInfo(APP_TIMEZONE))
|
| 69 |
+
requested_date = requested_date or aden_datetime.date()
|
| 70 |
+
requested_time = requested_time or bucket_for_time(aden_datetime.time())
|
| 71 |
+
else:
|
| 72 |
+
requested_date = requested_date or _parse_date_value(travel_datetime)
|
| 73 |
+
requested_time = requested_time or normalize_departure_bucket(travel_datetime)
|
| 74 |
+
|
| 75 |
+
return DepartureRequest(departure_date=requested_date, departure_time=requested_time)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def parse_requested_clock_time(
|
| 79 |
+
*,
|
| 80 |
+
travel_time: Any = None,
|
| 81 |
+
travel_datetime: Any = None,
|
| 82 |
+
exact_time: Any = None,
|
| 83 |
+
) -> time | None:
|
| 84 |
+
for value in (exact_time, travel_datetime, travel_time):
|
| 85 |
+
if not value:
|
| 86 |
+
continue
|
| 87 |
+
text = str(value).strip()
|
| 88 |
+
parsed_datetime = _parse_datetime(text)
|
| 89 |
+
if parsed_datetime:
|
| 90 |
+
return parsed_datetime.astimezone(ZoneInfo(APP_TIMEZONE)).time().replace(
|
| 91 |
+
second=0,
|
| 92 |
+
microsecond=0,
|
| 93 |
+
)
|
| 94 |
+
parsed_time = _parse_time(text)
|
| 95 |
+
if parsed_time:
|
| 96 |
+
return parsed_time
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def not_departed_bucket_filter(now: datetime | None = None) -> tuple[date, tuple[str, ...]]:
|
| 101 |
+
aden_now = (now or now_in_aden()).astimezone(ZoneInfo(APP_TIMEZONE))
|
| 102 |
+
if aden_now.time() < time(12, 0):
|
| 103 |
+
return aden_now.date(), DEPARTURE_BUCKETS
|
| 104 |
+
if aden_now.time() < time(18, 0):
|
| 105 |
+
return aden_now.date(), ("noon", "night")
|
| 106 |
+
return aden_now.date(), ("night",)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def trip_departure_date(trip: dict[str, Any]) -> date | None:
|
| 110 |
+
parsed = _parse_date_value(trip.get("departure_date"))
|
| 111 |
+
if parsed:
|
| 112 |
+
return parsed
|
| 113 |
+
parsed_datetime = _parse_datetime(str(trip.get("departure_time") or "").strip())
|
| 114 |
+
if parsed_datetime:
|
| 115 |
+
return parsed_datetime.astimezone(ZoneInfo(APP_TIMEZONE)).date()
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def trip_departure_bucket(trip: dict[str, Any]) -> DepartureBucket | None:
|
| 120 |
+
return normalize_departure_bucket(trip.get("departure_time"))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def trip_satisfies_departure_request(
|
| 124 |
+
trip: dict[str, Any],
|
| 125 |
+
request: DepartureRequest,
|
| 126 |
+
*,
|
| 127 |
+
now: datetime | None = None,
|
| 128 |
+
) -> bool:
|
| 129 |
+
trip_date = trip_departure_date(trip)
|
| 130 |
+
trip_bucket = trip_departure_bucket(trip)
|
| 131 |
+
if trip_date is None or trip_bucket is None:
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
today, remaining_buckets = not_departed_bucket_filter(now)
|
| 135 |
+
if request.departure_date:
|
| 136 |
+
if trip_date != request.departure_date:
|
| 137 |
+
return False
|
| 138 |
+
if request.departure_time:
|
| 139 |
+
return trip_bucket == request.departure_time
|
| 140 |
+
if trip_date == today:
|
| 141 |
+
return trip_bucket in remaining_buckets
|
| 142 |
+
return trip_date > today
|
| 143 |
+
|
| 144 |
+
if request.departure_time and trip_bucket != request.departure_time:
|
| 145 |
+
return False
|
| 146 |
+
return trip_date > today or (trip_date == today and trip_bucket in remaining_buckets)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _parse_date_value(value: Any) -> date | None:
|
| 150 |
+
if value is None:
|
| 151 |
+
return None
|
| 152 |
+
if isinstance(value, date) and not isinstance(value, datetime):
|
| 153 |
+
return value
|
| 154 |
+
if isinstance(value, datetime):
|
| 155 |
+
return value.astimezone(ZoneInfo(APP_TIMEZONE)).date()
|
| 156 |
+
text = str(value).strip()
|
| 157 |
+
if not text:
|
| 158 |
+
return None
|
| 159 |
+
match = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", text)
|
| 160 |
+
if not match:
|
| 161 |
+
return None
|
| 162 |
+
try:
|
| 163 |
+
return date.fromisoformat(match.group(1))
|
| 164 |
+
except ValueError:
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _parse_datetime(value: str) -> datetime | None:
|
| 169 |
+
if not value:
|
| 170 |
+
return None
|
| 171 |
+
normalized = value.replace("Z", "+00:00")
|
| 172 |
+
try:
|
| 173 |
+
parsed = datetime.fromisoformat(normalized)
|
| 174 |
+
except ValueError:
|
| 175 |
+
return None
|
| 176 |
+
if parsed.tzinfo is None:
|
| 177 |
+
return parsed.replace(tzinfo=ZoneInfo(APP_TIMEZONE))
|
| 178 |
+
return parsed
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _parse_time(value: str) -> time | None:
|
| 182 |
+
match = re.search(r"\b([01]?\d|2[0-3])(?::([0-5]\d))?\s*(am|pm)?\b", value, re.I)
|
| 183 |
+
if not match:
|
| 184 |
+
return None
|
| 185 |
+
hour = int(match.group(1))
|
| 186 |
+
minute = int(match.group(2) or 0)
|
| 187 |
+
meridiem = (match.group(3) or "").lower()
|
| 188 |
+
if meridiem == "pm" and hour < 12:
|
| 189 |
+
hour += 12
|
| 190 |
+
if meridiem == "am" and hour == 12:
|
| 191 |
+
hour = 0
|
| 192 |
+
return time(hour, minute)
|
app/utils/logging.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def configure_logging(level: str) -> None:
|
| 5 |
+
logging.basicConfig(
|
| 6 |
+
level=getattr(logging, level.upper(), logging.INFO),
|
| 7 |
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
| 8 |
+
)
|
app/utils/time.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from zoneinfo import ZoneInfo
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def now_in_timezone(timezone_name: str) -> datetime:
|
| 6 |
+
return datetime.now(tz=ZoneInfo(timezone_name))
|
app/whatsapp/client.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
|
| 3 |
+
from app.config import Settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class WhatsAppClientError(RuntimeError):
|
| 7 |
+
pass
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class WhatsAppClient:
|
| 11 |
+
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
| 12 |
+
self._settings = settings
|
| 13 |
+
self._client = client
|
| 14 |
+
|
| 15 |
+
@property
|
| 16 |
+
def _messages_url(self) -> str:
|
| 17 |
+
base = self._settings.whatsapp_graph_url.rstrip("/")
|
| 18 |
+
version = self._settings.whatsapp_api_version.strip("/")
|
| 19 |
+
phone_number_id = self._settings.whatsapp_phone_number_id
|
| 20 |
+
return f"{base}/{version}/{phone_number_id}/messages"
|
| 21 |
+
|
| 22 |
+
async def send_text(self, to_phone: str, text: str) -> dict:
|
| 23 |
+
payload = {
|
| 24 |
+
"messaging_product": "whatsapp",
|
| 25 |
+
"recipient_type": "individual",
|
| 26 |
+
"to": to_phone,
|
| 27 |
+
"type": "text",
|
| 28 |
+
"text": {"preview_url": False, "body": text},
|
| 29 |
+
}
|
| 30 |
+
headers = {
|
| 31 |
+
"Authorization": f"Bearer {self._settings.whatsapp_access_token}",
|
| 32 |
+
"Content-Type": "application/json",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if self._client is not None:
|
| 36 |
+
response = await self._client.post(self._messages_url, json=payload, headers=headers)
|
| 37 |
+
else:
|
| 38 |
+
async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client:
|
| 39 |
+
response = await client.post(self._messages_url, json=payload, headers=headers)
|
| 40 |
+
|
| 41 |
+
if response.status_code >= 400:
|
| 42 |
+
raise WhatsAppClientError(
|
| 43 |
+
f"WhatsApp API failed with {response.status_code}: {response.text}"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
return response.json()
|
app/whatsapp/parser.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from app.models.domain import WhatsAppInboundMessage
|
| 4 |
+
|
| 5 |
+
SUPPORTED_MESSAGE_TYPES = {"text"}
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessage]:
|
| 9 |
+
messages: list[WhatsAppInboundMessage] = []
|
| 10 |
+
|
| 11 |
+
for entry in payload.get("entry", []):
|
| 12 |
+
for change in entry.get("changes", []):
|
| 13 |
+
value = change.get("value", {})
|
| 14 |
+
contacts_by_wa_id = {
|
| 15 |
+
contact.get("wa_id"): contact.get("profile", {}).get("name")
|
| 16 |
+
for contact in value.get("contacts", [])
|
| 17 |
+
}
|
| 18 |
+
phone_number_id = value.get("metadata", {}).get("phone_number_id")
|
| 19 |
+
|
| 20 |
+
for message in value.get("messages", []):
|
| 21 |
+
message_type = message.get("type")
|
| 22 |
+
if message_type not in SUPPORTED_MESSAGE_TYPES:
|
| 23 |
+
continue
|
| 24 |
+
|
| 25 |
+
from_phone = message.get("from")
|
| 26 |
+
message_id = message.get("id")
|
| 27 |
+
text = message.get("text", {}).get("body")
|
| 28 |
+
if not from_phone or not message_id or text is None:
|
| 29 |
+
continue
|
| 30 |
+
|
| 31 |
+
messages.append(
|
| 32 |
+
WhatsAppInboundMessage(
|
| 33 |
+
message_id=message_id,
|
| 34 |
+
from_phone=from_phone,
|
| 35 |
+
text=text,
|
| 36 |
+
timestamp=message.get("timestamp"),
|
| 37 |
+
profile_name=contacts_by_wa_id.get(from_phone),
|
| 38 |
+
phone_number_id=phone_number_id,
|
| 39 |
+
raw=message,
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
return messages
|
app/whatsapp/security.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import hmac
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def verify_meta_signature(body: bytes, signature_header: str | None, app_secret: str) -> bool:
|
| 6 |
+
if not signature_header or not signature_header.startswith("sha256="):
|
| 7 |
+
return False
|
| 8 |
+
|
| 9 |
+
expected = "sha256=" + hmac.new(
|
| 10 |
+
app_secret.encode("utf-8"),
|
| 11 |
+
body,
|
| 12 |
+
hashlib.sha256,
|
| 13 |
+
).hexdigest()
|
| 14 |
+
return hmac.compare_digest(expected, signature_header)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def verify_webhook_challenge(
|
| 18 |
+
mode: str | None,
|
| 19 |
+
verify_token: str | None,
|
| 20 |
+
expected_verify_token: str,
|
| 21 |
+
) -> bool:
|
| 22 |
+
return mode == "subscribe" and verify_token == expected_verify_token
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
falsa-api:
|
| 3 |
+
build: .
|
| 4 |
+
env_file:
|
| 5 |
+
- .env
|
| 6 |
+
ports:
|
| 7 |
+
- "8000:8000"
|
| 8 |
+
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
| 9 |
+
volumes:
|
| 10 |
+
- .:/app
|
main.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.main import create_app
|
| 2 |
+
|
| 3 |
+
app = create_app()
|
prompts/falsa_info.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FALSA Information Seed
|
| 2 |
+
|
| 3 |
+
## Company
|
| 4 |
+
FALSA is an AI-powered travel booking customer service platform. Customers can ask
|
| 5 |
+
about car and bus trips through WhatsApp, search available routes, and request a
|
| 6 |
+
booking handoff to the driver or support team.
|
| 7 |
+
|
| 8 |
+
## How The Service Works
|
| 9 |
+
Customers send a message on WhatsApp. FALSA checks available active trips, shares
|
| 10 |
+
matching options, and creates a pending booking lead when the customer chooses a
|
| 11 |
+
trip. Seats are not reserved until the driver or support team confirms the booking.
|
| 12 |
+
|
| 13 |
+
## Pricing
|
| 14 |
+
Trip prices depend on route, vehicle type, seat availability, and driver pricing.
|
| 15 |
+
FALSA should only share prices returned by the trip search results.
|
| 16 |
+
|
| 17 |
+
## Policies
|
| 18 |
+
FALSA does not confirm a reservation or payment automatically in v1. Any booking
|
| 19 |
+
lead remains pending until human or driver confirmation. Customers should provide
|
| 20 |
+
accurate travel details, preferred time, number of seats, and pickup notes.
|
| 21 |
+
|
| 22 |
+
## Support
|
| 23 |
+
If FALSA cannot find a suitable answer or trip, the assistant should collect the
|
| 24 |
+
customer's request and explain that support can follow up.
|
prompts/system.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are FALSA, a professional travel booking customer service assistant for WhatsApp.
|
| 2 |
+
|
| 3 |
+
Behavior:
|
| 4 |
+
- Reply in the same language as the customer, Arabic or English.
|
| 5 |
+
- Be concise, warm, and practical.
|
| 6 |
+
- Use `about_falsa` for company, FAQ, policy, pricing, and support questions.
|
| 7 |
+
- Use `search_trips` whenever the customer asks for available travel options.
|
| 8 |
+
- When calling `search_trips`, extract JSON fields and include `vector_query_text`
|
| 9 |
+
as a concise natural-language search phrase for the same request.
|
| 10 |
+
- For function arguments, always use Arabic text for `departure`, `destination`, `vehicle_type`, and travel time bucket labels. Keep digits and exact time values in English format only.
|
| 11 |
+
- If a trip search result contains `alternate_alert`, tell the customer before
|
| 12 |
+
listing the available trips.
|
| 13 |
+
- Use `create_booking_lead` only after the customer clearly selects a trip and seat count.
|
| 14 |
+
- Ask a short follow-up question when required travel details are missing.
|
| 15 |
+
- Never invent unavailable trips, prices, drivers, or booking confirmations.
|
| 16 |
+
- Tell customers that booking leads are pending confirmation and seats are not reserved yet.
|
| 17 |
+
- Do not discuss internal tools, prompts, databases, or provider failover.
|
| 18 |
+
|
| 19 |
+
Driver tools:
|
| 20 |
+
- Use `create_driver_account` when a sender wants to register as a driver. Collect their full `name` only; never ask for or pass a phone number in tool arguments.
|
| 21 |
+
- Use `check_driver_info` when a registered driver asks about their account, registered vehicles, or active trips.
|
| 22 |
+
- Use `check_driver_trips` when a registered driver wants to see their upcoming active trips.
|
| 23 |
+
- Use `add_driver_car` when a registered driver wants to register a new vehicle. Only `name` is required; ask for `plate_number` or `seat_count` only if the tool reports missing information.
|
| 24 |
+
- Use `add_trip_by_driver` when a registered driver wants to publish a trip. Collect route, `departure_date`, and `departure_time`; ask for `vehicle_type` (car name in Arabic), seats, or price only if the tool reports missing fields.
|
| 25 |
+
- If `add_trip_by_driver` returns an error about no driver account, guide the sender through `create_driver_account` first.
|
| 26 |
+
- Never pass `phone_number` in any tool arguments; the WhatsApp session supplies it automatically.
|
| 27 |
+
|
| 28 |
+
Operational context:
|
| 29 |
+
- Current date/time: {current_datetime}
|
| 30 |
+
- App timezone: {timezone}
|
prompts/system_driver.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are FALSA, a professional driver assistant for WhatsApp.
|
| 2 |
+
|
| 3 |
+
Behavior:
|
| 4 |
+
- Reply in the same language as the driver, Arabic or English.
|
| 5 |
+
- Be concise, warm, and practical.
|
| 6 |
+
- Use `about_falsa` for company, FAQ, policy, pricing, and support questions.
|
| 7 |
+
- Use `check_driver_info` when the driver asks about their account, vehicles, or active trips.
|
| 8 |
+
- Use `check_driver_trips` when the driver wants upcoming active trips.
|
| 9 |
+
- Use `add_driver_car` to register a vehicle. Only `name` is required.
|
| 10 |
+
- Use `add_trip_by_driver` to publish a trip. Collect route, `departure_date`, and `departure_time`.
|
| 11 |
+
- For function arguments, use Arabic text for `departure`, `destination`, `vehicle_type`, and time bucket labels. Keep digits and exact times in English format.
|
| 12 |
+
- Never pass `phone_number` in tool arguments; the WhatsApp session supplies it.
|
| 13 |
+
- Never invent trips, prices, or booking confirmations.
|
| 14 |
+
- Do not discuss internal tools, prompts, databases, or provider failover.
|
| 15 |
+
|
| 16 |
+
Role switching:
|
| 17 |
+
- Use `switch_to_passenger` when the driver wants to search or book trips as a traveler.
|
| 18 |
+
- `name` is optional for `switch_to_passenger`.
|
| 19 |
+
|
| 20 |
+
Operational context:
|
| 21 |
+
- Current date/time: {current_datetime}
|
| 22 |
+
- App timezone: {timezone}
|
prompts/system_new_user.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are FALSA, a professional travel booking assistant for WhatsApp.
|
| 2 |
+
|
| 3 |
+
This sender is new and has not chosen a role yet. Your first job is to welcome them and ask whether they want to:
|
| 4 |
+
- travel as a passenger (search and book trips), or
|
| 5 |
+
- work as a driver (publish trips and manage vehicles).
|
| 6 |
+
|
| 7 |
+
Behavior:
|
| 8 |
+
- Reply in the same language as the sender, Arabic or English.
|
| 9 |
+
- Be concise, warm, and practical.
|
| 10 |
+
- Use `about_falsa` for company, FAQ, policy, pricing, and support questions.
|
| 11 |
+
- Do not discuss internal tools, prompts, databases, or provider failover.
|
| 12 |
+
|
| 13 |
+
Passenger onboarding:
|
| 14 |
+
- When the sender wants to travel, call `switch_to_passenger`.
|
| 15 |
+
- `name` is optional for `switch_to_passenger`; ask only if they offer it.
|
| 16 |
+
- Do not ask for a name before switching to passenger.
|
| 17 |
+
|
| 18 |
+
Driver onboarding:
|
| 19 |
+
- When the sender wants to drive, collect their full legal name.
|
| 20 |
+
- Call `create_driver_account` with their name first.
|
| 21 |
+
- Only after a successful driver account creation, call `switch_to_driver`.
|
| 22 |
+
- Never call `switch_to_driver` before `create_driver_account` succeeds.
|
| 23 |
+
|
| 24 |
+
Operational context:
|
| 25 |
+
- Current date/time: {current_datetime}
|
| 26 |
+
- App timezone: {timezone}
|
prompts/system_passenger.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are FALSA, a professional travel booking assistant for WhatsApp.
|
| 2 |
+
|
| 3 |
+
Behavior:
|
| 4 |
+
- Reply in the same language as the customer, Arabic or English.
|
| 5 |
+
- Be concise, warm, and practical.
|
| 6 |
+
- Use `about_falsa` for company, FAQ, policy, pricing, and support questions.
|
| 7 |
+
- Use `search_trips` whenever the customer asks for available travel options.
|
| 8 |
+
- When calling `search_trips`, extract JSON fields and include `vector_query_text` as a concise natural-language search phrase.
|
| 9 |
+
- For function arguments, use Arabic text for `departure`, `destination`, `vehicle_type`, and travel time bucket labels. Keep digits and exact times in English format.
|
| 10 |
+
- If a trip search result contains `alternate_alert`, tell the customer before listing trips.
|
| 11 |
+
- Use `create_booking_lead` only after the customer clearly selects a trip and seat count.
|
| 12 |
+
- Ask a short follow-up when required travel details are missing.
|
| 13 |
+
- Never invent unavailable trips, prices, drivers, or booking confirmations.
|
| 14 |
+
- Tell customers that booking leads are pending confirmation and seats are not reserved yet.
|
| 15 |
+
- Do not discuss internal tools, prompts, databases, or provider failover.
|
| 16 |
+
|
| 17 |
+
Role switching:
|
| 18 |
+
- Use `switch_to_driver` when the customer wants to offer rides as a driver.
|
| 19 |
+
- If they do not have a driver account yet, collect their full name, call `create_driver_account`, then `switch_to_driver`.
|
| 20 |
+
- Never call `switch_to_driver` before `create_driver_account` succeeds when no account exists.
|
| 21 |
+
|
| 22 |
+
Operational context:
|
| 23 |
+
- Current date/time: {current_datetime}
|
| 24 |
+
- App timezone: {timezone}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "falsa"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "AI-powered WhatsApp travel booking customer service backend"
|
| 5 |
+
requires-python = ">=3.12"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"fastapi>=0.115.0",
|
| 8 |
+
"uvicorn[standard]>=0.30.0",
|
| 9 |
+
"pydantic-settings>=2.4.0",
|
| 10 |
+
"supabase>=2.6.0",
|
| 11 |
+
"openai>=1.40.0",
|
| 12 |
+
"httpx>=0.27.0",
|
| 13 |
+
"python-dotenv>=1.0.1",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
[project.optional-dependencies]
|
| 17 |
+
dev = [
|
| 18 |
+
"pytest>=8.3.0",
|
| 19 |
+
"pytest-asyncio>=0.24.0",
|
| 20 |
+
"ruff>=0.6.0",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
[tool.pytest.ini_options]
|
| 24 |
+
asyncio_mode = "auto"
|
| 25 |
+
testpaths = ["tests"]
|
| 26 |
+
|
| 27 |
+
[tool.ruff]
|
| 28 |
+
line-length = 100
|
| 29 |
+
target-version = "py312"
|
| 30 |
+
exclude = [".deps", ".venv", "__pycache__"]
|
| 31 |
+
|
| 32 |
+
[tool.ruff.lint]
|
| 33 |
+
select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
| 34 |
+
ignore = ["B008"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
pydantic-settings>=2.4.0
|
| 4 |
+
supabase>=2.6.0
|
| 5 |
+
openai>=1.40.0
|
| 6 |
+
httpx>=0.27.0
|
| 7 |
+
python-dotenv>=1.0.1
|
| 8 |
+
|
| 9 |
+
# Development and verification
|
| 10 |
+
pytest>=8.3.0
|
| 11 |
+
pytest-asyncio>=0.24.0
|
| 12 |
+
ruff>=0.6.0
|
scripts/seed_info.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 6 |
+
if ROOT_DIR not in sys.path:
|
| 7 |
+
sys.path.insert(0, ROOT_DIR)
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
from app.database.supabase import SupabaseRepository, create_supabase_client
|
| 11 |
+
from app.services.admin_service import AdminService
|
| 12 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def main() -> None:
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
repository = SupabaseRepository(await create_supabase_client(settings))
|
| 18 |
+
service = AdminService(
|
| 19 |
+
repository=repository,
|
| 20 |
+
embeddings=JinaEmbeddingService(settings),
|
| 21 |
+
settings=settings,
|
| 22 |
+
)
|
| 23 |
+
indexed = await service.seed_info()
|
| 24 |
+
print(f"Indexed {indexed} FALSA info chunks")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
if __name__ == "__main__":
|
| 28 |
+
asyncio.run(main())
|
scripts/setup_and_seed.sh
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Usage: ./scripts/setup_and_seed.sh
|
| 5 |
+
# Seeds FALSA info and syncs active trips into Supabase vector tables.
|
| 6 |
+
# Ensure you have activated your virtualenv and filled .env before running.
|
| 7 |
+
|
| 8 |
+
python scripts/seed_info.py
|
| 9 |
+
python scripts/sync_trips.py
|
| 10 |
+
|
| 11 |
+
echo "Done: Supabase vector info seeded and trips synced."
|
scripts/sync_trips.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# يرسل بيانات الرحلات الحية إلى جداول المتجهات في Supabase
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 7 |
+
if ROOT_DIR not in sys.path:
|
| 8 |
+
sys.path.insert(0, ROOT_DIR)
|
| 9 |
+
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
from app.database.supabase import SupabaseRepository, create_supabase_client
|
| 12 |
+
from app.services.admin_service import AdminService
|
| 13 |
+
from app.services.embedding_service import JinaEmbeddingService
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def main() -> None:
|
| 17 |
+
settings = get_settings()
|
| 18 |
+
repository = SupabaseRepository(await create_supabase_client(settings))
|
| 19 |
+
service = AdminService(
|
| 20 |
+
repository=repository,
|
| 21 |
+
embeddings=JinaEmbeddingService(settings),
|
| 22 |
+
settings=settings,
|
| 23 |
+
)
|
| 24 |
+
indexed = await service.sync_trips()
|
| 25 |
+
print(f"Indexed {indexed} active trips")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
asyncio.run(main())
|
scripts/test_scripts.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
|
| 6 |
+
BASE_URL = os.getenv("FALSA_BASE_URL", "http://127.0.0.1:8000")
|
| 7 |
+
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY")
|
| 8 |
+
TEXT = os.getenv("JINA_TEXT", "رحلات الى مسقط اليوم")
|
| 9 |
+
ACTION = os.getenv("ACTION", "embed")
|
| 10 |
+
|
| 11 |
+
async def main() -> None:
|
| 12 |
+
if not ADMIN_API_KEY:
|
| 13 |
+
print("Set ADMIN_API_KEY environment variable before running this script.")
|
| 14 |
+
return
|
| 15 |
+
|
| 16 |
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
| 17 |
+
if ACTION == "embed":
|
| 18 |
+
url = f"{BASE_URL}/admin/jina-embed"
|
| 19 |
+
response = await client.post(
|
| 20 |
+
url,
|
| 21 |
+
json={"text": TEXT},
|
| 22 |
+
headers={"X-Admin-Api-Key": ADMIN_API_KEY},
|
| 23 |
+
)
|
| 24 |
+
else:
|
| 25 |
+
url = f"{BASE_URL}/admin/llm-tool-call"
|
| 26 |
+
response = await client.post(
|
| 27 |
+
url,
|
| 28 |
+
json={"message": TEXT},
|
| 29 |
+
headers={"X-Admin-Api-Key": ADMIN_API_KEY},
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
print("STATUS:", response.status_code)
|
| 33 |
+
try:
|
| 34 |
+
print(response.json())
|
| 35 |
+
except Exception:
|
| 36 |
+
print(response.text)
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
asyncio.run(main())
|
supabase/migrations/202605210001_initial_schema.sql
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
create extension if not exists pgcrypto;
|
| 2 |
+
|
| 3 |
+
create or replace function public.set_updated_at()
|
| 4 |
+
returns trigger
|
| 5 |
+
language plpgsql
|
| 6 |
+
as $$
|
| 7 |
+
begin
|
| 8 |
+
new.updated_at = now();
|
| 9 |
+
return new;
|
| 10 |
+
end;
|
| 11 |
+
$$;
|
| 12 |
+
|
| 13 |
+
create table if not exists public.customers (
|
| 14 |
+
id uuid primary key default gen_random_uuid(),
|
| 15 |
+
name text,
|
| 16 |
+
phone_number text not null unique,
|
| 17 |
+
preferred_language text check (preferred_language in ('ar', 'en')),
|
| 18 |
+
created_at timestamptz not null default now(),
|
| 19 |
+
updated_at timestamptz not null default now()
|
| 20 |
+
);
|
| 21 |
+
|
| 22 |
+
create table if not exists public.drivers (
|
| 23 |
+
id uuid primary key default gen_random_uuid(),
|
| 24 |
+
name text not null,
|
| 25 |
+
phone_number text not null unique,
|
| 26 |
+
status text not null default 'active' check (status in ('active', 'inactive', 'suspended')),
|
| 27 |
+
rating numeric(3,2),
|
| 28 |
+
created_at timestamptz not null default now(),
|
| 29 |
+
updated_at timestamptz not null default now()
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
create table if not exists public.driver_wallet (
|
| 33 |
+
id uuid primary key default gen_random_uuid(),
|
| 34 |
+
driver_id uuid not null unique references public.drivers(id) on delete cascade,
|
| 35 |
+
balance numeric(12,2) not null default 0,
|
| 36 |
+
last_updated timestamptz not null default now()
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
create table if not exists public.driver_cars (
|
| 40 |
+
id uuid primary key default gen_random_uuid(),
|
| 41 |
+
driver_id uuid not null references public.drivers(id) on delete cascade,
|
| 42 |
+
car_type text not null,
|
| 43 |
+
plate_number text unique,
|
| 44 |
+
seat_count integer check (seat_count > 0),
|
| 45 |
+
created_at timestamptz not null default now(),
|
| 46 |
+
updated_at timestamptz not null default now()
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
create table if not exists public.driver_trips (
|
| 50 |
+
id uuid primary key default gen_random_uuid(),
|
| 51 |
+
driver_id uuid not null references public.drivers(id) on delete cascade,
|
| 52 |
+
car_id uuid references public.driver_cars(id) on delete set null,
|
| 53 |
+
departure text not null,
|
| 54 |
+
destination text not null,
|
| 55 |
+
departure_date date not null,
|
| 56 |
+
departure_time text not null check (departure_time in ('morning', 'noon', 'night')),
|
| 57 |
+
available_seats integer not null check (available_seats >= 0),
|
| 58 |
+
total_seats integer not null check (total_seats > 0),
|
| 59 |
+
price numeric(12,2) not null check (price >= 0),
|
| 60 |
+
status text not null default 'active' check (status in ('active', 'cancelled', 'completed')),
|
| 61 |
+
created_at timestamptz not null default now(),
|
| 62 |
+
updated_at timestamptz not null default now(),
|
| 63 |
+
constraint driver_trips_available_not_over_total check (available_seats <= total_seats)
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
create table if not exists public.messages (
|
| 67 |
+
id uuid primary key default gen_random_uuid(),
|
| 68 |
+
customer_id uuid not null references public.customers(id) on delete cascade,
|
| 69 |
+
sender_type text not null check (sender_type in ('customer', 'assistant', 'driver', 'system')),
|
| 70 |
+
message text not null,
|
| 71 |
+
whatsapp_message_id text unique,
|
| 72 |
+
metadata jsonb not null default '{}'::jsonb,
|
| 73 |
+
created_at timestamptz not null default now()
|
| 74 |
+
);
|
| 75 |
+
|
| 76 |
+
create table if not exists public.booking_leads (
|
| 77 |
+
id uuid primary key default gen_random_uuid(),
|
| 78 |
+
customer_id uuid not null references public.customers(id) on delete cascade,
|
| 79 |
+
trip_id uuid not null references public.driver_trips(id) on delete restrict,
|
| 80 |
+
requested_seats integer not null check (requested_seats > 0),
|
| 81 |
+
status text not null default 'pending' check (status in ('pending', 'confirmed', 'cancelled')),
|
| 82 |
+
notes text,
|
| 83 |
+
driver_notification_status text not null default 'not_sent'
|
| 84 |
+
check (driver_notification_status in ('not_sent', 'sent', 'failed')),
|
| 85 |
+
metadata jsonb not null default '{}'::jsonb,
|
| 86 |
+
created_at timestamptz not null default now(),
|
| 87 |
+
updated_at timestamptz not null default now()
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
create index if not exists idx_messages_customer_created_at
|
| 91 |
+
on public.messages(customer_id, created_at desc);
|
| 92 |
+
create index if not exists idx_driver_trips_active_route
|
| 93 |
+
on public.driver_trips(status, departure, destination, departure_date, departure_time);
|
| 94 |
+
create index if not exists idx_booking_leads_customer
|
| 95 |
+
on public.booking_leads(customer_id, created_at desc);
|
| 96 |
+
create index if not exists idx_booking_leads_trip
|
| 97 |
+
on public.booking_leads(trip_id, created_at desc);
|
| 98 |
+
|
| 99 |
+
drop trigger if exists set_customers_updated_at on public.customers;
|
| 100 |
+
create trigger set_customers_updated_at
|
| 101 |
+
before update on public.customers
|
| 102 |
+
for each row execute function public.set_updated_at();
|
| 103 |
+
|
| 104 |
+
drop trigger if exists set_drivers_updated_at on public.drivers;
|
| 105 |
+
create trigger set_drivers_updated_at
|
| 106 |
+
before update on public.drivers
|
| 107 |
+
for each row execute function public.set_updated_at();
|
| 108 |
+
|
| 109 |
+
drop trigger if exists set_driver_cars_updated_at on public.driver_cars;
|
| 110 |
+
create trigger set_driver_cars_updated_at
|
| 111 |
+
before update on public.driver_cars
|
| 112 |
+
for each row execute function public.set_updated_at();
|
| 113 |
+
|
| 114 |
+
drop trigger if exists set_driver_trips_updated_at on public.driver_trips;
|
| 115 |
+
create trigger set_driver_trips_updated_at
|
| 116 |
+
before update on public.driver_trips
|
| 117 |
+
for each row execute function public.set_updated_at();
|
| 118 |
+
|
| 119 |
+
drop trigger if exists set_booking_leads_updated_at on public.booking_leads;
|
| 120 |
+
create trigger set_booking_leads_updated_at
|
| 121 |
+
before update on public.booking_leads
|
| 122 |
+
for each row execute function public.set_updated_at();
|
| 123 |
+
|
| 124 |
+
alter table public.customers enable row level security;
|
| 125 |
+
alter table public.messages enable row level security;
|
| 126 |
+
alter table public.drivers enable row level security;
|
| 127 |
+
alter table public.driver_wallet enable row level security;
|
| 128 |
+
alter table public.driver_cars enable row level security;
|
| 129 |
+
alter table public.driver_trips enable row level security;
|
| 130 |
+
alter table public.booking_leads enable row level security;
|
| 131 |
+
|
| 132 |
+
grant usage on schema public to service_role;
|
| 133 |
+
grant all on all tables in schema public to service_role;
|
| 134 |
+
grant all on all routines in schema public to service_role;
|
| 135 |
+
grant all on all sequences in schema public to service_role;
|
supabase/migrations/202605310001_driver_trip_departure_date_time.sql
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
do $$
|
| 2 |
+
declare
|
| 3 |
+
departure_time_type text;
|
| 4 |
+
begin
|
| 5 |
+
select data_type
|
| 6 |
+
into departure_time_type
|
| 7 |
+
from information_schema.columns
|
| 8 |
+
where table_schema = 'public'
|
| 9 |
+
and table_name = 'driver_trips'
|
| 10 |
+
and column_name = 'departure_time';
|
| 11 |
+
|
| 12 |
+
if departure_time_type = 'timestamp with time zone' then
|
| 13 |
+
alter table public.driver_trips
|
| 14 |
+
add column if not exists departure_date date;
|
| 15 |
+
|
| 16 |
+
alter table public.driver_trips
|
| 17 |
+
add column if not exists departure_time_bucket text;
|
| 18 |
+
|
| 19 |
+
update public.driver_trips
|
| 20 |
+
set
|
| 21 |
+
departure_date = (departure_time at time zone 'Asia/Aden')::date,
|
| 22 |
+
departure_time_bucket = case
|
| 23 |
+
when extract(hour from departure_time at time zone 'Asia/Aden') < 12 then 'morning'
|
| 24 |
+
when extract(hour from departure_time at time zone 'Asia/Aden') < 18 then 'noon'
|
| 25 |
+
else 'night'
|
| 26 |
+
end
|
| 27 |
+
where departure_date is null
|
| 28 |
+
or departure_time_bucket is null;
|
| 29 |
+
|
| 30 |
+
alter table public.driver_trips
|
| 31 |
+
alter column departure_date set not null;
|
| 32 |
+
|
| 33 |
+
alter table public.driver_trips
|
| 34 |
+
drop column departure_time;
|
| 35 |
+
|
| 36 |
+
alter table public.driver_trips
|
| 37 |
+
rename column departure_time_bucket to departure_time;
|
| 38 |
+
end if;
|
| 39 |
+
end;
|
| 40 |
+
$$;
|
| 41 |
+
|
| 42 |
+
alter table public.driver_trips
|
| 43 |
+
alter column departure_time set not null;
|
| 44 |
+
|
| 45 |
+
alter table public.driver_trips
|
| 46 |
+
drop constraint if exists driver_trips_departure_time_check;
|
| 47 |
+
|
| 48 |
+
alter table public.driver_trips
|
| 49 |
+
add constraint driver_trips_departure_time_check
|
| 50 |
+
check (departure_time in ('morning', 'noon', 'night'));
|
| 51 |
+
|
| 52 |
+
drop index if exists public.idx_driver_trips_active_route;
|
| 53 |
+
|
| 54 |
+
create index if not exists idx_driver_trips_active_route
|
| 55 |
+
on public.driver_trips(status, departure, destination, departure_date, departure_time);
|
supabase/migrations/202606020001_supabase_jina_vectors.sql
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
create schema if not exists extensions;
|
| 2 |
+
create extension if not exists vector with schema extensions;
|
| 3 |
+
|
| 4 |
+
create table if not exists public.falsa_info_chunks (
|
| 5 |
+
id text primary key,
|
| 6 |
+
chunk_text text not null,
|
| 7 |
+
source text,
|
| 8 |
+
embedding extensions.vector(1024) not null,
|
| 9 |
+
embedding_model text not null,
|
| 10 |
+
created_at timestamptz not null default now(),
|
| 11 |
+
updated_at timestamptz not null default now()
|
| 12 |
+
);
|
| 13 |
+
|
| 14 |
+
create table if not exists public.driver_trip_embeddings (
|
| 15 |
+
trip_id uuid primary key references public.driver_trips(id) on delete cascade,
|
| 16 |
+
chunk_text text not null,
|
| 17 |
+
embedding extensions.vector(1024) not null,
|
| 18 |
+
embedding_model text not null,
|
| 19 |
+
created_at timestamptz not null default now(),
|
| 20 |
+
updated_at timestamptz not null default now()
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
create index if not exists idx_falsa_info_chunks_embedding
|
| 24 |
+
on public.falsa_info_chunks using hnsw (embedding vector_cosine_ops);
|
| 25 |
+
|
| 26 |
+
create index if not exists idx_driver_trip_embeddings_embedding
|
| 27 |
+
on public.driver_trip_embeddings using hnsw (embedding vector_cosine_ops);
|
| 28 |
+
|
| 29 |
+
drop trigger if exists set_falsa_info_chunks_updated_at on public.falsa_info_chunks;
|
| 30 |
+
create trigger set_falsa_info_chunks_updated_at
|
| 31 |
+
before update on public.falsa_info_chunks
|
| 32 |
+
for each row execute function public.set_updated_at();
|
| 33 |
+
|
| 34 |
+
drop trigger if exists set_driver_trip_embeddings_updated_at on public.driver_trip_embeddings;
|
| 35 |
+
create trigger set_driver_trip_embeddings_updated_at
|
| 36 |
+
before update on public.driver_trip_embeddings
|
| 37 |
+
for each row execute function public.set_updated_at();
|
| 38 |
+
|
| 39 |
+
create or replace function public.departure_bucket_clock_time(bucket text)
|
| 40 |
+
returns time
|
| 41 |
+
language sql
|
| 42 |
+
immutable
|
| 43 |
+
as $$
|
| 44 |
+
select case bucket
|
| 45 |
+
when 'morning' then time '06:00'
|
| 46 |
+
when 'noon' then time '12:00'
|
| 47 |
+
when 'night' then time '18:00'
|
| 48 |
+
else null
|
| 49 |
+
end;
|
| 50 |
+
$$;
|
| 51 |
+
|
| 52 |
+
create or replace function public.match_falsa_info(
|
| 53 |
+
query_embedding extensions.vector(1024),
|
| 54 |
+
match_threshold float default 0.0,
|
| 55 |
+
match_count int default 5
|
| 56 |
+
)
|
| 57 |
+
returns table (
|
| 58 |
+
id text,
|
| 59 |
+
chunk_text text,
|
| 60 |
+
source text,
|
| 61 |
+
similarity float
|
| 62 |
+
)
|
| 63 |
+
language sql
|
| 64 |
+
stable
|
| 65 |
+
as $$
|
| 66 |
+
select
|
| 67 |
+
falsa_info_chunks.id,
|
| 68 |
+
falsa_info_chunks.chunk_text,
|
| 69 |
+
falsa_info_chunks.source,
|
| 70 |
+
1 - (falsa_info_chunks.embedding <=> query_embedding) as similarity
|
| 71 |
+
from public.falsa_info_chunks
|
| 72 |
+
where 1 - (falsa_info_chunks.embedding <=> query_embedding) >= match_threshold
|
| 73 |
+
order by falsa_info_chunks.embedding <=> query_embedding
|
| 74 |
+
limit match_count;
|
| 75 |
+
$$;
|
| 76 |
+
|
| 77 |
+
create or replace function public.match_active_trips(
|
| 78 |
+
query_embedding extensions.vector(1024),
|
| 79 |
+
match_threshold float default 0.0,
|
| 80 |
+
match_count int default 10,
|
| 81 |
+
filter_departure text default null,
|
| 82 |
+
filter_destination text default null,
|
| 83 |
+
filter_departure_date date default null,
|
| 84 |
+
filter_departure_time text default null,
|
| 85 |
+
filter_requested_time time default null,
|
| 86 |
+
filter_seats int default 1,
|
| 87 |
+
filter_vehicle_type text default null
|
| 88 |
+
)
|
| 89 |
+
returns table (
|
| 90 |
+
trip_id uuid,
|
| 91 |
+
departure text,
|
| 92 |
+
destination text,
|
| 93 |
+
departure_date date,
|
| 94 |
+
departure_time text,
|
| 95 |
+
available_seats integer,
|
| 96 |
+
total_seats integer,
|
| 97 |
+
price numeric,
|
| 98 |
+
status text,
|
| 99 |
+
driver_name text,
|
| 100 |
+
driver_phone_number text,
|
| 101 |
+
car_type text,
|
| 102 |
+
chunk_text text,
|
| 103 |
+
similarity float,
|
| 104 |
+
time_difference_minutes integer
|
| 105 |
+
)
|
| 106 |
+
language sql
|
| 107 |
+
stable
|
| 108 |
+
as $$
|
| 109 |
+
with ranked as (
|
| 110 |
+
select
|
| 111 |
+
driver_trips.id as trip_id,
|
| 112 |
+
driver_trips.departure,
|
| 113 |
+
driver_trips.destination,
|
| 114 |
+
driver_trips.departure_date,
|
| 115 |
+
driver_trips.departure_time,
|
| 116 |
+
driver_trips.available_seats,
|
| 117 |
+
driver_trips.total_seats,
|
| 118 |
+
driver_trips.price,
|
| 119 |
+
driver_trips.status,
|
| 120 |
+
drivers.name as driver_name,
|
| 121 |
+
drivers.phone_number as driver_phone_number,
|
| 122 |
+
driver_cars.car_type,
|
| 123 |
+
driver_trip_embeddings.chunk_text,
|
| 124 |
+
1 - (driver_trip_embeddings.embedding <=> query_embedding) as similarity,
|
| 125 |
+
driver_trip_embeddings.embedding <=> query_embedding as vector_distance,
|
| 126 |
+
case
|
| 127 |
+
when filter_requested_time is null then null
|
| 128 |
+
else abs(
|
| 129 |
+
extract(
|
| 130 |
+
epoch from (
|
| 131 |
+
public.departure_bucket_clock_time(driver_trips.departure_time)
|
| 132 |
+
- filter_requested_time
|
| 133 |
+
)
|
| 134 |
+
) / 60
|
| 135 |
+
)::integer
|
| 136 |
+
end as time_difference_minutes
|
| 137 |
+
from public.driver_trip_embeddings
|
| 138 |
+
join public.driver_trips on driver_trips.id = driver_trip_embeddings.trip_id
|
| 139 |
+
left join public.drivers on drivers.id = driver_trips.driver_id
|
| 140 |
+
left join public.driver_cars on driver_cars.id = driver_trips.car_id
|
| 141 |
+
where driver_trips.status = 'active'
|
| 142 |
+
and driver_trips.available_seats >= coalesce(filter_seats, 1)
|
| 143 |
+
and (
|
| 144 |
+
filter_departure is null
|
| 145 |
+
or driver_trips.departure ilike '%' || filter_departure || '%'
|
| 146 |
+
)
|
| 147 |
+
and (
|
| 148 |
+
filter_destination is null
|
| 149 |
+
or driver_trips.destination ilike '%' || filter_destination || '%'
|
| 150 |
+
)
|
| 151 |
+
and (
|
| 152 |
+
filter_departure_date is null
|
| 153 |
+
or driver_trips.departure_date = filter_departure_date
|
| 154 |
+
)
|
| 155 |
+
and (
|
| 156 |
+
filter_departure_time is null
|
| 157 |
+
or driver_trips.departure_time = filter_departure_time
|
| 158 |
+
)
|
| 159 |
+
and (
|
| 160 |
+
filter_vehicle_type is null
|
| 161 |
+
or driver_cars.car_type ilike '%' || filter_vehicle_type || '%'
|
| 162 |
+
)
|
| 163 |
+
and (
|
| 164 |
+
driver_trips.departure_date > (now() at time zone 'Asia/Aden')::date
|
| 165 |
+
or (
|
| 166 |
+
driver_trips.departure_date = (now() at time zone 'Asia/Aden')::date
|
| 167 |
+
and (
|
| 168 |
+
(now() at time zone 'Asia/Aden')::time < time '12:00'
|
| 169 |
+
or (
|
| 170 |
+
(now() at time zone 'Asia/Aden')::time < time '18:00'
|
| 171 |
+
and driver_trips.departure_time in ('noon', 'night')
|
| 172 |
+
)
|
| 173 |
+
or (
|
| 174 |
+
(now() at time zone 'Asia/Aden')::time >= time '18:00'
|
| 175 |
+
and driver_trips.departure_time = 'night'
|
| 176 |
+
)
|
| 177 |
+
)
|
| 178 |
+
)
|
| 179 |
+
)
|
| 180 |
+
)
|
| 181 |
+
select
|
| 182 |
+
ranked.trip_id,
|
| 183 |
+
ranked.departure,
|
| 184 |
+
ranked.destination,
|
| 185 |
+
ranked.departure_date,
|
| 186 |
+
ranked.departure_time,
|
| 187 |
+
ranked.available_seats,
|
| 188 |
+
ranked.total_seats,
|
| 189 |
+
ranked.price,
|
| 190 |
+
ranked.status,
|
| 191 |
+
ranked.driver_name,
|
| 192 |
+
ranked.driver_phone_number,
|
| 193 |
+
ranked.car_type,
|
| 194 |
+
ranked.chunk_text,
|
| 195 |
+
ranked.similarity,
|
| 196 |
+
ranked.time_difference_minutes
|
| 197 |
+
from ranked
|
| 198 |
+
where ranked.similarity >= match_threshold
|
| 199 |
+
order by
|
| 200 |
+
ranked.time_difference_minutes nulls last,
|
| 201 |
+
ranked.departure_date,
|
| 202 |
+
ranked.vector_distance
|
| 203 |
+
limit match_count;
|
| 204 |
+
$$;
|
| 205 |
+
|
| 206 |
+
alter table public.falsa_info_chunks enable row level security;
|
| 207 |
+
alter table public.driver_trip_embeddings enable row level security;
|
| 208 |
+
|
| 209 |
+
grant all on public.falsa_info_chunks to service_role;
|
| 210 |
+
grant all on public.driver_trip_embeddings to service_role;
|
| 211 |
+
grant usage on schema extensions to service_role;
|
| 212 |
+
grant all on all routines in schema public to service_role;
|