codeBOKER commited on
Commit
102dd4f
·
0 Parent(s):

first commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .cursor/settings.json +7 -0
  2. .env.example +32 -0
  3. .gitignore +12 -0
  4. Dockerfile +14 -0
  5. README.md +33 -0
  6. app/__init__.py +3 -0
  7. app/ai/__init__.py +4 -0
  8. app/ai/orchestrator.py +138 -0
  9. app/ai/providers.py +154 -0
  10. app/ai/tool_schemas.py +357 -0
  11. app/api/__init__.py +3 -0
  12. app/api/deps.py +25 -0
  13. app/api/routes.py +244 -0
  14. app/config.py +53 -0
  15. app/database/__init__.py +3 -0
  16. app/database/supabase.py +460 -0
  17. app/main.py +26 -0
  18. app/models/api.py +53 -0
  19. app/models/domain.py +43 -0
  20. app/services/__init__.py +3 -0
  21. app/services/admin_service.py +66 -0
  22. app/services/container.py +51 -0
  23. app/services/conversation_service.py +170 -0
  24. app/services/embedding_service.py +90 -0
  25. app/services/trip_indexing.py +45 -0
  26. app/tools/__init__.py +3 -0
  27. app/tools/handlers.py +742 -0
  28. app/tools/registry.py +23 -0
  29. app/utils/departure.py +192 -0
  30. app/utils/logging.py +8 -0
  31. app/utils/time.py +6 -0
  32. app/whatsapp/client.py +46 -0
  33. app/whatsapp/parser.py +43 -0
  34. app/whatsapp/security.py +22 -0
  35. docker-compose.yml +10 -0
  36. main.py +3 -0
  37. prompts/falsa_info.md +24 -0
  38. prompts/system.md +30 -0
  39. prompts/system_driver.md +22 -0
  40. prompts/system_new_user.md +26 -0
  41. prompts/system_passenger.md +24 -0
  42. pyproject.toml +34 -0
  43. requirements.txt +12 -0
  44. scripts/seed_info.py +28 -0
  45. scripts/setup_and_seed.sh +11 -0
  46. scripts/sync_trips.py +29 -0
  47. scripts/test_scripts.py +39 -0
  48. supabase/migrations/202605210001_initial_schema.sql +135 -0
  49. supabase/migrations/202605310001_driver_trip_departure_date_time.sql +55 -0
  50. 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;