diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..54ec55a32f61fd6fc4dbafff7dc400f2aca75b89 --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "supabase": { + "enabled": true + } + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d321492db0af98c7afa859fe04ee1822b59e3599 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +APP_NAME=FALSA +ENVIRONMENT=development +APP_TIMEZONE=Asia/Aden +LOG_LEVEL=INFO + +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +PINECONE_API_KEY=your-pinecone-api-key +PINECONE_CLOUD=aws +PINECONE_REGION=us-east-1 +PINECONE_INFO_INDEX=falsa-info +PINECONE_TRIPS_INDEX=falsa-trips +PINECONE_EMBED_MODEL=multilingual-e5-large +PINECONE_NAMESPACE=default + +GROQ_API_KEY=your-groq-api-key +GROQ_MODEL=your-groq-tool-calling-model +HF_TOKEN=your-hugging-face-token +HF_MODEL=your-hugging-face-tool-calling-model +AI_TEMPERATURE=0.2 +AI_MAX_TOOL_ITERATIONS=3 +REQUEST_TIMEOUT_SECONDS=20 + +WHATSAPP_GRAPH_URL=https://graph.facebook.com +WHATSAPP_API_VERSION=v20.0 +WHATSAPP_VERIFY_TOKEN=choose-a-webhook-verify-token +WHATSAPP_APP_SECRET=your-meta-app-secret +WHATSAPP_ACCESS_TOKEN=your-whatsapp-cloud-api-token +WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id + +ADMIN_API_KEY=choose-a-long-random-admin-key diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..53b3d6414b277a5f11b1df7fe050e2ccca6ad17a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +.venv/ +.deps/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +tests/notes.txt +whatsappDebug/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..448f88b4e439358cb3673e61972bf10b60b86336 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..05abf545c8c5fb08e51a898434132797ad1f0e0b --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# FALSA + +FALSA is an async FastAPI backend for WhatsApp-based AI travel customer service. +It stores conversations in Supabase, retrieves short-term chat context, uses Groq +with Hugging Face fallback, calls local tools for FALSA info/trip search/booking +leads, stores vector embeddings in Supabase with Jina Embeddings, and sends replies +through Meta WhatsApp Cloud API. + +## Quick Start + +```bash +cp .env.example .env +pip install -r requirements.txt +uvicorn main:app --reload +``` + +Run checks: + +```bash +pytest +ruff check . +``` + +## Main Endpoints + +- `GET /healthz` +- `GET /webhooks/whatsapp` +- `POST /webhooks/whatsapp` +- `POST /admin/seed-info` +- `POST /admin/sync-trips` + +Apply the SQL in `supabase/migrations` to create the Supabase schema, including the +pgvector tables and RPC functions, before using the production services. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a05eb9abb93a3c0a4e3f1ef478fe80d1793cee90 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/app/ai/__init__.py b/app/ai/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..153c47fe73f214e81a278a70fade3d927b2fabf4 --- /dev/null +++ b/app/ai/__init__.py @@ -0,0 +1,4 @@ +from app.ai.orchestrator import AIOrchestrator +from app.ai.providers import GroqChatProvider, HuggingFaceChatProvider + +__all__ = ["AIOrchestrator", "GroqChatProvider", "HuggingFaceChatProvider"] diff --git a/app/ai/orchestrator.py b/app/ai/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..1016d55f6c945dc20955b8c1a0fced8654abbebf --- /dev/null +++ b/app/ai/orchestrator.py @@ -0,0 +1,138 @@ +import json +import logging +from typing import Any + +from app.ai.providers import ( + ChatProvider, + InvalidToolCallGenerationError, + ProviderError, + RetryableProviderError, +) +from app.models.domain import AIProviderResponse, ToolCall +from app.tools.registry import ToolRegistry + +logger = logging.getLogger(__name__) + + +class AIOrchestrator: + def __init__( + self, + *, + primary: ChatProvider, + fallback: ChatProvider, + temperature: float, + max_tool_iterations: int, + ) -> None: + self.primary = primary + self.fallback = fallback + self.temperature = temperature + self.max_tool_iterations = max_tool_iterations + + async def generate_reply( + self, + *, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + registry: ToolRegistry, + ) -> str: + try: + return await self._run_provider( + self.primary, + messages=messages, + tools=tools, + registry=registry, + temperature=self.temperature, + ) + except InvalidToolCallGenerationError: + logger.warning("Primary provider generated invalid tool call; retrying once") + try: + return await self._run_provider( + self.primary, + messages=messages, + tools=tools, + registry=registry, + temperature=max(self.temperature - 0.2, 0.1), + ) + except RetryableProviderError: + logger.warning("Primary retry failed; falling back to Hugging Face") + except RetryableProviderError: + logger.warning("Primary provider failed; falling back to Hugging Face") + + return await self._run_provider( + self.fallback, + messages=messages, + tools=tools, + registry=registry, + temperature=self.temperature, + ) + + async def _run_provider( + self, + provider: ChatProvider, + *, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + registry: ToolRegistry, + temperature: float, + ) -> str: + working_messages = [dict(message) for message in messages] + # need to be edited to reponse quackly instead in enter in a for loop + for _ in range(self.max_tool_iterations + 1): + response = await provider.chat( + working_messages, + tools=tools, + tool_choice="auto", + temperature=temperature, + ) + if not response.tool_calls: + content = (response.content or "").strip() + if content: + return content + raise ProviderError(f"{provider.name} returned an empty response") + logger.info("----++-----"+str(response)) + working_messages.append(_assistant_tool_message(response)) + for tool_call in response.tool_calls: + # logger.warning("++++++++"+str(tool_call)+ "&&&"+ str(registry)) + result = await _execute_tool_call(registry, tool_call) + logger.warning("+++++++++++++"+str(result)) + working_messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "name": tool_call.name, + "content": json.dumps(result, ensure_ascii=False), + } + ) + + return ( + "I found that this request needs extra checking. " + "A support team member will follow up with you shortly." + ) + + +def _assistant_tool_message(response: AIProviderResponse) -> dict[str, Any]: + if response.raw_message: + return response.raw_message + return { + "role": "assistant", + "content": response.content, + "tool_calls": [ + { + "id": tool_call.id, + "type": "function", + "function": {"name": tool_call.name, "arguments": tool_call.arguments}, + } + for tool_call in response.tool_calls + ], + } + + +async def _execute_tool_call(registry: ToolRegistry, tool_call: ToolCall) -> dict[str, Any]: + try: + arguments = json.loads(tool_call.arguments or "{}") + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object") + except (json.JSONDecodeError, ValueError) as exc: + return {"ok": False, "error": f"Invalid tool arguments: {exc}", "data": {}} + + return (await registry.execute(tool_call.name, arguments)).to_payload() diff --git a/app/ai/providers.py b/app/ai/providers.py new file mode 100644 index 0000000000000000000000000000000000000000..9b3c30b8b5ebb591c9e8b7d17174ab86b92692d7 --- /dev/null +++ b/app/ai/providers.py @@ -0,0 +1,154 @@ +from typing import Any, Protocol + +from app.config import Settings +from app.models.domain import AIProviderResponse, ToolCall + + +class ProviderError(RuntimeError): + pass + + +class RetryableProviderError(ProviderError): + pass + + +class InvalidToolCallGenerationError(RetryableProviderError): + pass + + +class ChatProvider(Protocol): + name: str + + async def chat( + self, + messages: list[dict[str, Any]], + *, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = "auto", + temperature: float = 0.2, + ) -> AIProviderResponse: + ... + + +class OpenAICompatibleChatProvider: + name = "openai-compatible" + + def __init__( + self, + *, + api_key: str, + base_url: str, + model: str, + timeout: float, + name: str, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.model = model + self.timeout = timeout + self.name = name + self._client: Any | None = None + + @property + def client(self) -> Any: + if self._client is None: + from openai import AsyncOpenAI + + self._client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.base_url, + timeout=self.timeout, + ) + return self._client + + async def chat( + self, + messages: list[dict[str, Any]], + *, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = "auto", + temperature: float = 0.2, + ) -> AIProviderResponse: + try: + kwargs: dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": temperature, + } + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice + + completion = await self.client.chat.completions.create(**kwargs) + message = completion.choices[0].message + return _normalize_openai_message(message) + except Exception as exc: # noqa: BLE001 + raise _provider_error_from_exception(exc, self.name) from exc + + +class GroqChatProvider(OpenAICompatibleChatProvider): + def __init__(self, settings: Settings) -> None: + super().__init__( + api_key=settings.groq_api_key, + base_url="https://api.groq.com/openai/v1", + model=settings.groq_model, + timeout=settings.request_timeout_seconds, + name="groq", + ) + + +class HuggingFaceChatProvider(OpenAICompatibleChatProvider): + def __init__(self, settings: Settings) -> None: + super().__init__( + api_key=settings.hf_token, + base_url="https://router.huggingface.co/v1", + model=settings.hf_model, + timeout=settings.request_timeout_seconds, + name="huggingface", + ) + + +def _normalize_openai_message(message: Any) -> AIProviderResponse: + raw_message: dict[str, Any] + if hasattr(message, "model_dump"): + raw_message = message.model_dump(exclude_none=True) + elif isinstance(message, dict): + raw_message = message + else: + raw_message = {} + + tool_calls = [] + for tool_call in raw_message.get("tool_calls") or []: + function = tool_call.get("function") or {} + tool_calls.append( + ToolCall( + id=tool_call.get("id") or function.get("name", "tool-call"), + name=function.get("name", ""), + arguments=function.get("arguments") or "{}", + ) + ) + + return AIProviderResponse( + content=raw_message.get("content"), + tool_calls=tool_calls, + raw_message=raw_message, + ) + + +def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError: + status_code = getattr(exc, "status_code", None) + body = getattr(exc, "body", None) + message = str(exc) + if body: + message = f"{message} {body}" + + if status_code == 400 and "failed_generation" in message: + return InvalidToolCallGenerationError(f"{provider_name} generated an invalid tool call") + + if status_code in {408, 409, 429} or (isinstance(status_code, int) and status_code >= 500): + return RetryableProviderError(f"{provider_name} retryable failure: {message}") + + if exc.__class__.__name__ in {"APITimeoutError", "APIConnectionError", "RateLimitError"}: + return RetryableProviderError(f"{provider_name} network/rate failure: {message}") + + return ProviderError(f"{provider_name} failure: {message}") diff --git a/app/ai/tool_schemas.py b/app/ai/tool_schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..0565712a346e4ed80e9323cd7f693c292af5724a --- /dev/null +++ b/app/ai/tool_schemas.py @@ -0,0 +1,357 @@ +from typing import Any + +from app.models.domain import UserMode + +_ABOUT_FALSA = { + "type": "function", + "function": { + "name": "about_falsa", + "description": ( + "Retrieve official FALSA company, FAQ, policy, or pricing information." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The customer's question about FALSA.", + }, + "language": { + "type": "string", + "enum": ["ar", "en"], + "description": "Customer language for the result.", + }, + }, + "required": ["query", "language"], + "additionalProperties": False, + }, + }, +} + +_SEARCH_TRIPS = { + "type": "function", + "function": { + "name": "search_trips", + "description": ( + "Search active car or bus trips. " + "Use when the customer asks for travel options." + ), + "parameters": { + "type": "object", + "properties": { + "departure": { + "type": "string", + "description": "Departure city or area in Arabic.", + }, + "destination": { + "type": "string", + "description": "Destination city or area in Arabic.", + }, + "travel_datetime": { + "type": "string", + "description": ( + "Optional requested date/time in ISO format. Times are interpreted " + "in Asia/Aden and normalized to morning, noon, or night." + ), + }, + "travel_date": { + "type": "string", + "description": ( + "Optional requested trip date as YYYY-MM-DD in Asia/Aden. Use English digits." + ), + }, + "travel_time": { + "type": "string", + "enum": ["صباح", "ظهر", "ليل"], + "description": ( + "Optional requested trip time bucket in Arabic. If the customer also provides " + "an exact time, provide the matching Arabic bucket: صباح before 12:00, ظهر from 12:00-17:59, ليل from 18:00." + ), + }, + "travel_time_exact": { + "type": "string", + "description": ( + "Optional exact requested time as HH:MM in Asia/Aden, for example " + "06:00. Use English digits and colon formatting. Also provide the corresponding Arabic travel_time bucket when possible." + ), + }, + "seats": { + "type": "integer", + "minimum": 1, + "description": "Optional number of seats requested.", + }, + "vehicle_type": { + "type": "string", + "description": "Optional car type in Arabic, for example سيارة or باص.", + }, + "vector_query_text": { + "type": "string", + "description": ( + "Natural-language semantic search text containing the route, date, " + "time, seats, and vehicle preferences extracted from the customer." + ), + }, + }, + "required": ["departure", "destination", "vector_query_text"], + "additionalProperties": False, + }, + }, +} + +_CREATE_BOOKING_LEAD = { + "type": "function", + "function": { + "name": "create_booking_lead", + "description": ( + "Create a pending booking lead and notify the driver. " + "This does not reserve seats or confirm payment." + ), + "parameters": { + "type": "object", + "properties": { + "trip_id": {"type": "string", "description": "Selected trip ID."}, + "requested_seats": { + "type": "integer", + "minimum": 1, + "description": "Number of seats requested by the customer.", + }, + "notes": { + "type": "string", + "description": "Optional customer notes or pickup details.", + }, + }, + "required": ["trip_id", "requested_seats"], + "additionalProperties": False, + }, + }, +} + +_CREATE_DRIVER_ACCOUNT = { + "type": "function", + "function": { + "name": "create_driver_account", + "description": ( + "Register the current WhatsApp sender as a FALSA driver. " + "Phone number is taken automatically from the chat session." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Driver full legal name.", + }, + }, + "required": ["name"], + "additionalProperties": False, + }, + }, +} + +_CHECK_DRIVER_INFO = { + "type": "function", + "function": { + "name": "check_driver_info", + "description": ( + "Retrieve the registered driver's account details, registered vehicles, and active trip summary. " + "Use when a driver asks about their own profile or vehicle status." + ), + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + }, + }, +} + +_CHECK_DRIVER_TRIPS = { + "type": "function", + "function": { + "name": "check_driver_trips", + "description": ( + "List all upcoming active trips for the registered driver. " + "A trip is considered upcoming if it has status active and has not yet departed." + ), + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + }, + }, +} + +_ADD_DRIVER_CAR = { + "type": "function", + "function": { + "name": "add_driver_car", + "description": ( + "Register a new vehicle for the current WhatsApp driver. " + "Only the car name is required; plate number and seat count are optional." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Vehicle name or type in Arabic.", + }, + "plate_number": { + "type": "string", + "description": "Optional vehicle plate number.", + }, + "seat_count": { + "type": "integer", + "minimum": 1, + "description": "Optional number of seats in the vehicle.", + }, + }, + "required": ["name"], + "additionalProperties": False, + }, + }, +} + +_ADD_TRIP_BY_DRIVER = { + "type": "function", + "function": { + "name": "add_trip_by_driver", + "description": ( + "Create an active trip for the registered driver on this WhatsApp number. " + "Phone is taken from the chat session. Optional car, seat, and price fields " + "default from the driver's most recent trip or sole registered vehicle." + ), + "parameters": { + "type": "object", + "properties": { + "departure": { + "type": "string", + "description": "Departure city or area in Arabic.", + }, + "destination": { + "type": "string", + "description": "Destination city or area in Arabic.", + }, + "departure_date": { + "type": "string", + "description": "Trip date as YYYY-MM-DD in Asia/Aden.", + }, + "departure_time": { + "type": "string", + "description": ( + "Trip time bucket: morning, noon, night, or Arabic " + "صباح / ظهر / ليل." + ), + }, + "vehicle_type": { + "type": "string", + "description": ( + "Optional vehicle name or type in Arabic, for example " + "سيارة or باص. Matched against the driver's registered cars." + ), + }, + "available_seats": { + "type": "integer", + "minimum": 0, + "description": "Optional seats available for booking.", + }, + "total_seats": { + "type": "integer", + "minimum": 1, + "description": "Optional total vehicle seats for this trip.", + }, + "price": { + "type": "number", + "minimum": 0, + "description": "Optional trip price.", + }, + }, + "required": ["departure", "destination", "departure_date", "departure_time"], + "additionalProperties": False, + }, + }, +} + +_SWITCH_TO_DRIVER = { + "type": "function", + "function": { + "name": "switch_to_driver", + "description": ( + "Switch this sender to driver mode. " + "Requires an existing driver account; use create_driver_account first if needed." + ), + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + }, + }, +} + +_SWITCH_TO_PASSENGER = { + "type": "function", + "function": { + "name": "switch_to_passenger", + "description": ( + "Switch this sender to passenger mode so they can search and book trips." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Optional passenger display name.", + }, + }, + "required": [], + "additionalProperties": False, + }, + }, +} + +_TOOL_SCHEMAS: dict[str, dict[str, Any]] = { + "about_falsa": _ABOUT_FALSA, + "search_trips": _SEARCH_TRIPS, + "create_booking_lead": _CREATE_BOOKING_LEAD, + "create_driver_account": _CREATE_DRIVER_ACCOUNT, + "check_driver_info": _CHECK_DRIVER_INFO, + "check_driver_trips": _CHECK_DRIVER_TRIPS, + "add_driver_car": _ADD_DRIVER_CAR, + "add_trip_by_driver": _ADD_TRIP_BY_DRIVER, + "switch_to_driver": _SWITCH_TO_DRIVER, + "switch_to_passenger": _SWITCH_TO_PASSENGER, +} + +_TOOLS_BY_MODE: dict[UserMode, list[str]] = { + "new_user": [ + "about_falsa", + "create_driver_account", + "switch_to_driver", + "switch_to_passenger", + ], + "driver": [ + "about_falsa", + "check_driver_info", + "check_driver_trips", + "add_driver_car", + "add_trip_by_driver", + "switch_to_passenger", + ], + "passenger": [ + "about_falsa", + "search_trips", + "create_booking_lead", + "create_driver_account", + "switch_to_driver", + ], +} + + +def get_tool_schemas(user_mode: UserMode = "new_user") -> list[dict[str, Any]]: + return [_TOOL_SCHEMAS[name] for name in _TOOLS_BY_MODE[user_mode]] + + +def get_all_tool_schemas() -> list[dict[str, Any]]: + return list(_TOOL_SCHEMAS.values()) diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2bb843d3ee3129607739ee3ead48a1414fdf2327 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,3 @@ +from app.api.routes import router + +__all__ = ["router"] diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..699c63b4e4d3ec962f9a9e79c5893cc0390d0143 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,25 @@ +from fastapi import Header, HTTPException, Request, status + +from app.services.container import ServiceContainer + + +def get_container(request: Request) -> ServiceContainer: + container = getattr(request.app.state, "container", None) + if container is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Service container is not initialized", + ) + return container + + +def verify_admin_api_key( + request: Request, + x_admin_api_key: str | None = Header(default=None, alias="X-Admin-Api-Key"), +) -> None: + expected = get_container(request).settings.admin_api_key + if not x_admin_api_key or x_admin_api_key != expected: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid admin API key", + ) diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..9ddb20022074f80c67a2f104094543bc59a37a38 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,244 @@ +from typing import Annotated, Any + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + HTTPException, + Query, + Request, + Response, + status, +) + +from app.api.deps import get_container, verify_admin_api_key +from app.models.api import ( + HealthResponse, + DriverDebugRequest, + LLMToolCallRequest, + LLMToolCallResponse, + JinaEmbeddingRequest, + JinaEmbeddingResponse, + SeedInfoResponse, + SyncTripsResponse, + WebhookAcceptedResponse, + WebhookDebugResponse, +) +from app.services.container import ServiceContainer +from app.whatsapp.parser import parse_inbound_messages +from app.whatsapp.security import verify_meta_signature, verify_webhook_challenge +from app.ai.tool_schemas import get_all_tool_schemas +import json + +router = APIRouter() + + +@router.get("/healthz", response_model=HealthResponse) +async def healthz(container: Annotated[ServiceContainer, Depends(get_container)]) -> HealthResponse: + return HealthResponse(service=container.settings.app_name) + + +@router.get("/webhooks/whatsapp") +async def verify_whatsapp_webhook( + container: Annotated[ServiceContainer, Depends(get_container)], + hub_mode: Annotated[str | None, Query(alias="hub.mode")] = None, + hub_verify_token: Annotated[str | None, Query(alias="hub.verify_token")] = None, + hub_challenge: Annotated[str | None, Query(alias="hub.challenge")] = None, +) -> Response: + if verify_webhook_challenge( + hub_mode, + hub_verify_token, + container.settings.whatsapp_verify_token, + ): + return Response(content=hub_challenge or "", media_type="text/plain") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid verify token") + + +@router.post("/webhooks/whatsapp", response_model=WebhookAcceptedResponse) +async def receive_whatsapp_webhook( + request: Request, + background_tasks: BackgroundTasks, + container: Annotated[ServiceContainer, Depends(get_container)], +) -> WebhookAcceptedResponse: + body = await request.body() + signature = request.headers.get("x-hub-signature-256") + if not verify_meta_signature(body, signature, container.settings.whatsapp_app_secret): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature") + + payload: dict[str, Any] = await request.json() + messages = parse_inbound_messages(payload) + for inbound in messages: + background_tasks.add_task(container.conversation.handle_inbound_message, inbound) + + return WebhookAcceptedResponse(messages=len(messages)) + + +@router.post( + "/webhooks/whatsapp/debug", + response_model=WebhookDebugResponse, +) +async def receive_whatsapp_webhook_debug( + request: Request, + container: Annotated[ServiceContainer, Depends(get_container)], +) -> WebhookDebugResponse: + body = await request.body() + signature = request.headers.get("x-hub-signature-256") + # if not verify_meta_signature(body, signature, container.settings.whatsapp_app_secret): + # raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature") + + payload: dict[str, Any] = await request.json() + messages = parse_inbound_messages(payload) + replies: list[str] = [] + for inbound in messages: + reply = await container.conversation.handle_inbound_message(inbound) + if reply is not None: + replies.append(reply) + + return WebhookDebugResponse(messages=len(messages), replies=replies) + + +@router.post( + "/admin/jina-embed", + response_model=JinaEmbeddingResponse, + dependencies=[Depends(verify_admin_api_key)], +) +async def jina_embed_query( + request: JinaEmbeddingRequest, + container: Annotated[ServiceContainer, Depends(get_container)], +) -> JinaEmbeddingResponse: + embedding = await container.embeddings.embed_query(request.text) + return JinaEmbeddingResponse( + text=request.text, + embedding=embedding, + dimensions=len(embedding), + ) + + +@router.post( + "/admin/llm-tool-call", + response_model=LLMToolCallResponse, + dependencies=[Depends(verify_admin_api_key)], +) +async def llm_tool_call_debug( + request: LLMToolCallRequest, + container: Annotated[ServiceContainer, Depends(get_container)], +) -> LLMToolCallResponse: + # Call the primary provider directly with the tool schemas and return generated tool calls + provider = container.ai.primary + tools = get_all_tool_schemas() + response = await provider.chat( + messages=[{"role": "user", "content": request.message}], + tools=tools, + tool_choice="auto", + temperature=container.settings.ai_temperature, + ) + + tool_calls: list[dict[str, Any]] = [] + tool_results: list[dict[str, Any]] = [] + registry = container.conversation._tool_registry( + {"id": "admin-debug", "phone_number": "", "name": "admin-debug"}, + sender_phone="admin-debug", + user_mode="passenger", + ) + + for tc in response.tool_calls: + try: + args = json.loads(tc.arguments or "{}") + except Exception: + args = tc.arguments + tool_calls.append({"name": tc.name, "arguments": args}) + + execution_result = await registry.execute(tc.name, args if isinstance(args, dict) else {}) + tool_results.append( + { + "tool_call_id": tc.id, + "name": tc.name, + "arguments": args, + "result": execution_result.to_payload(), + } + ) + + return LLMToolCallResponse( + llm_response=(response.content or "").strip() or None, + tool_calls=tool_calls, + tool_results=tool_results, + ) + + +@router.post( + "/admin/driver-debug", + response_model=LLMToolCallResponse, + dependencies=[Depends(verify_admin_api_key)], +) +async def driver_service_debug( + request: DriverDebugRequest, + container: Annotated[ServiceContainer, Depends(get_container)], +) -> LLMToolCallResponse: + provider = container.ai.primary + tools = get_all_tool_schemas() + response = await provider.chat( + messages=[{"role": "user", "content": request.message}], + tools=tools, + tool_choice="auto", + temperature=container.settings.ai_temperature, + ) + + tool_calls: list[dict[str, Any]] = [] + tool_results: list[dict[str, Any]] = [] + customer = { + "id": "debug", + "phone_number": request.client_number, + "name": "driver-debug", + } + registry = container.conversation._tool_registry( + customer, + sender_phone=request.client_number, + user_mode="driver", + ) + + for tc in response.tool_calls: + try: + args = json.loads(tc.arguments or "{}") + except Exception: + args = tc.arguments + tool_calls.append({"name": tc.name, "arguments": args}) + + execution_result = await registry.execute(tc.name, args if isinstance(args, dict) else {}) + tool_results.append( + { + "tool_call_id": tc.id, + "name": tc.name, + "arguments": args, + "result": execution_result.to_payload(), + } + ) + + return LLMToolCallResponse( + llm_response=(response.content or "").strip() or None, + tool_calls=tool_calls, + tool_results=tool_results, + ) + + +@router.post( + "/admin/seed-info", + response_model=SeedInfoResponse, + dependencies=[Depends(verify_admin_api_key)], +) +async def seed_info( + container: Annotated[ServiceContainer, Depends(get_container)], +) -> SeedInfoResponse: + return SeedInfoResponse(indexed_chunks=await container.admin.seed_info()) + + +@router.post( + "/admin/sync-trips", + response_model=SyncTripsResponse, + dependencies=[Depends(verify_admin_api_key)], +) +async def sync_trips( + container: Annotated[ServiceContainer, Depends(get_container)], +) -> SyncTripsResponse: + return SyncTripsResponse(indexed_trips=await container.admin.sync_trips()) + + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..1dfdd0faca96a58daf8ab830d5e403c05f2640fb --- /dev/null +++ b/app/config.py @@ -0,0 +1,53 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, HttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + + +BASE_DIR = Path(__file__).resolve().parent.parent + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=BASE_DIR / ".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "FALSA" + environment: str = "development" + app_timezone: str = "Asia/Aden" + log_level: str = "INFO" + + supabase_url: HttpUrl + supabase_service_role_key: str = Field(min_length=1) + + jina_api_key: str = Field(min_length=1) + jina_embedding_model: str = "jina-embeddings-v5-text-small" + jina_embedding_dimensions: int = 1024 + jina_embedding_endpoint: str = "https://api.jina.ai/v1/embeddings" + jina_query_task: str = "retrieval.query" + jina_passage_task: str = "retrieval.passage" + + groq_api_key: str = Field(min_length=1) + groq_model: str = Field(min_length=1) + hf_token: str = Field(min_length=1) + hf_model: str = Field(min_length=1) + ai_temperature: float = 0.2 + ai_max_tool_iterations: int = 3 + request_timeout_seconds: float = 20.0 + + whatsapp_graph_url: str = "https://graph.facebook.com" + whatsapp_api_version: str = "v20.0" + whatsapp_verify_token: str = Field(min_length=1) + whatsapp_app_secret: str = Field(min_length=1) + whatsapp_access_token: str = Field(min_length=1) + whatsapp_phone_number_id: str = Field(min_length=1) + + admin_api_key: str = Field(min_length=1) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b9fa6d77945ddff42700e9416be3001e22c2c7df --- /dev/null +++ b/app/database/__init__.py @@ -0,0 +1,3 @@ +from app.database.supabase import SupabaseRepository, create_supabase_client + +__all__ = ["SupabaseRepository", "create_supabase_client"] diff --git a/app/database/supabase.py b/app/database/supabase.py new file mode 100644 index 0000000000000000000000000000000000000000..5a38c2748a06e701d93e93befcfb0d878b35a2d6 --- /dev/null +++ b/app/database/supabase.py @@ -0,0 +1,460 @@ +import logging +from datetime import date, time +from typing import Any + +from app.config import Settings +from app.utils.departure import ( + DepartureRequest, + not_departed_bucket_filter, +) + +logger = logging.getLogger(__name__) + + +async def create_supabase_client(settings: Settings) -> Any: + from supabase import acreate_client + + return await acreate_client( + str(settings.supabase_url), + settings.supabase_service_role_key, + ) + + +def _response_data(response: Any) -> Any: + if hasattr(response, "data"): + return response.data + if isinstance(response, dict): + return response.get("data", response) + return response + + +class SupabaseRepository: + def __init__(self, client: Any) -> None: + self.client = client + + async def upsert_customer( + self, + *, + phone_number: str, + name: str | None = None, + preferred_language: str | None = None, + ) -> dict[str, Any]: + payload = { + "phone_number": phone_number, + "name": name, + "preferred_language": preferred_language, + } + payload = {key: value for key, value in payload.items() if value is not None} + response = await ( + self.client.table("customers") + .upsert(payload, on_conflict="phone_number") + .execute() + ) + data = _response_data(response) + return data[0] if isinstance(data, list) else data + + async def update_customer_user_mode( + self, + *, + customer_id: str, + user_mode: str, + ) -> dict[str, Any]: + response = await ( + self.client.table("customers") + .update({"user_mode": user_mode}) + .eq("id", customer_id) + .execute() + ) + data = _response_data(response) + return data[0] if isinstance(data, list) else data + + async def update_customer_name( + self, + *, + customer_id: str, + name: str, + ) -> dict[str, Any]: + response = await ( + self.client.table("customers") + .update({"name": name}) + .eq("id", customer_id) + .execute() + ) + data = _response_data(response) + return data[0] if isinstance(data, list) else data + + async def message_exists(self, whatsapp_message_id: str) -> bool: + response = await ( + self.client.table("messages") + .select("id") + .eq("whatsapp_message_id", whatsapp_message_id) + .limit(1) + .execute() + ) + data = _response_data(response) + return bool(data) + + async def create_message( + self, + *, + customer_id: str, + sender_type: str, + message: str, + whatsapp_message_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "customer_id": customer_id, + "sender_type": sender_type, + "message": message, + "whatsapp_message_id": whatsapp_message_id, + "metadata": metadata or {}, + } + response = await self.client.table("messages").insert(payload).execute() + data = _response_data(response) + return data[0] if isinstance(data, list) else data + + async def get_recent_context_messages( + self, + *, + customer_id: str, + current_message_id: str, + limit: int = 4, + ) -> list[dict[str, Any]]: + current_response = await ( + self.client.table("messages") + .select("*") + .eq("id", current_message_id) + .single() + .execute() + ) + current = _response_data(current_response) + current_created_at = current.get("created_at") + + prior_query = ( + self.client.table("messages") + .select("*") + .eq("customer_id", customer_id) + .neq("id", current_message_id) + .order("created_at", desc=True) + .limit(limit) + ) + if current_created_at: + prior_query = prior_query.lt("created_at", current_created_at) + + prior_response = await prior_query.execute() + prior = _response_data(prior_response) or [] + return list(reversed(prior)) + [current] + + async def list_active_trips(self) -> list[dict[str, Any]]: + query = ( + self.client.table("driver_trips") + .select("*, drivers(*), driver_cars(*)") + .eq("status", "active") + .gt("available_seats", 0) + ) + query = ( + self._apply_not_departed_filter(query) + .order("departure_date") + .order("departure_time") + ) + response = await query.execute() + return _response_data(response) or [] + + async def get_trips_by_ids(self, trip_ids: list[str]) -> list[dict[str, Any]]: + if not trip_ids: + return [] + response = await ( + self.client.table("driver_trips") + .select("*, drivers(*), driver_cars(*)") + .in_("id", trip_ids) + .execute() + ) + return _response_data(response) or [] + + async def search_active_trips( + self, + *, + departure: str | None = None, + destination: str | None = None, + seats: int | None = None, + vehicle_type: str | None = None, + departure_request: DepartureRequest | None = None, + ) -> list[dict[str, Any]]: + query = ( + self.client.table("driver_trips") + .select("*, drivers(*), driver_cars(*)") + .eq("status", "active") + .gt("available_seats", 0) + ) + query = self._apply_departure_request_filter(query, departure_request) + query = query.order("departure_date").order("departure_time") + if departure: + query = query.ilike("departure", f"%{departure}%") + if destination: + query = query.ilike("destination", f"%{destination}%") + if seats: + query = query.gte("available_seats", seats) + if vehicle_type: + query = query.ilike("driver_cars.car_type", f"%{vehicle_type}%") + response = await query.limit(10).execute() + return _response_data(response) or [] + + async def search_info_chunks_by_vector( + self, + *, + query_embedding: list[float], + match_count: int = 5, + ) -> list[dict[str, Any]]: + response = await self.client.rpc( + "match_falsa_info", + { + "query_embedding": query_embedding, + "match_count": match_count, + "match_threshold": 0.0, + }, + ).execute() + return _response_data(response) or [] + + async def search_trips_by_vector( + self, + *, + query_embedding: list[float], + departure: str, + destination: str, + departure_date: date | None = None, + departure_time: str | None = None, + requested_time: time | None = None, + seats: int = 1, + vehicle_type: str | None = None, + match_count: int = 10, + ) -> list[dict[str, Any]]: + try: + response = await self.client.rpc( + "match_active_trips", + { + "query_embedding": query_embedding, + "match_count": match_count, + "match_threshold": 0.0, + "filter_departure": departure, + "filter_destination": destination, + "filter_departure_date": ( + departure_date.isoformat() if departure_date else None + ), + "filter_departure_time": departure_time, + "filter_requested_time": ( + requested_time.isoformat(timespec="minutes") if requested_time else None + ), + "filter_seats": seats, + "filter_vehicle_type": vehicle_type, + }, + ).execute() + return _response_data(response) or [] + except Exception as exc: # noqa: BLE001 + logger.warning( + "Supabase match_active_trips RPC failed; falling back to regular active trips search: %s", + exc, + ) + return [] + + async def upsert_info_chunks(self, chunks: list[dict[str, Any]]) -> int: + if not chunks: + return 0 + response = await self.client.table("falsa_info_chunks").upsert(chunks).execute() + data = _response_data(response) + return len(data) if isinstance(data, list) else len(chunks) + + async def upsert_trip_embeddings(self, trip_embeddings: list[dict[str, Any]]) -> int: + if not trip_embeddings: + return 0 + response = await ( + self.client.table("driver_trip_embeddings") + .upsert(trip_embeddings, on_conflict="trip_id") + .execute() + ) + data = _response_data(response) + return len(data) if isinstance(data, list) else len(trip_embeddings) + + def _apply_departure_request_filter( + self, + query: Any, + departure_request: DepartureRequest | None, + ) -> Any: + if not departure_request: + return self._apply_not_departed_filter(query) + + today, remaining_buckets = not_departed_bucket_filter() + if departure_request.departure_date: + query = query.eq("departure_date", departure_request.departure_date.isoformat()) + if departure_request.departure_time: + return query.eq("departure_time", departure_request.departure_time) + if departure_request.departure_date == today: + return query.in_("departure_time", list(remaining_buckets)) + return query + + query = self._apply_not_departed_filter(query) + if departure_request.departure_time: + return query.eq("departure_time", departure_request.departure_time) + return query + + def _apply_not_departed_filter(self, query: Any) -> Any: + today, remaining_buckets = not_departed_bucket_filter() + bucket_list = ",".join(remaining_buckets) + return query.or_( + f"departure_date.gt.{today.isoformat()}," + f"and(departure_date.eq.{today.isoformat()},departure_time.in.({bucket_list}))" + ) + + async def get_trip_by_id(self, trip_id: str) -> dict[str, Any] | None: + response = await ( + self.client.table("driver_trips") + .select("*, drivers(*), driver_cars(*)") + .eq("id", trip_id) + .maybe_single() + .execute() + ) + return _response_data(response) + + async def get_driver_by_phone(self, phone_number: str) -> dict[str, Any] | None: + response = await ( + self.client.table("drivers") + .select("*") + .eq("phone_number", phone_number) + .maybe_single() + .execute() + ) + return _response_data(response) + + async def create_driver(self, *, name: str, phone_number: str) -> dict[str, Any]: + response = await ( + self.client.table("drivers") + .insert({"name": name, "phone_number": phone_number, "status": "active"}) + .execute() + ) + data = _response_data(response) + driver = data[0] if isinstance(data, list) else data + await ( + self.client.table("driver_wallet") + .insert({"driver_id": driver["id"], "balance": 0}) + .execute() + ) + return driver + + async def get_driver_latest_trip(self, driver_id: str) -> dict[str, Any] | None: + response = await ( + self.client.table("driver_trips") + .select("*, driver_cars(*)") + .eq("driver_id", driver_id) + .order("created_at", desc=True) + .limit(1) + .maybe_single() + .execute() + ) + return _response_data(response) + + async def list_driver_cars(self, driver_id: str) -> list[dict[str, Any]]: + response = await ( + self.client.table("driver_cars") + .select("*") + .eq("driver_id", driver_id) + .execute() + ) + return _response_data(response) or [] + + async def list_driver_trips(self, driver_id: str) -> list[dict[str, Any]]: + query = ( + self.client.table("driver_trips") + .select("*, driver_cars(*)") + .eq("driver_id", driver_id) + .eq("status", "active") + ) + query = self._apply_not_departed_filter(query).order("departure_date").order("departure_time") + response = await query.execute() + return _response_data(response) or [] + + async def create_driver_car( + self, + *, + driver_id: str, + car_type: str, + plate_number: str | None = None, + seat_count: int | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "driver_id": driver_id, + "car_type": car_type, + } + if plate_number is not None: + payload["plate_number"] = plate_number + if seat_count is not None: + payload["seat_count"] = seat_count + + response = await self.client.table("driver_cars").insert(payload).execute() + data = _response_data(response) + return data[0] if isinstance(data, list) else data + + async def create_driver_trip( + self, + *, + driver_id: str, + car_id: str | None, + departure: str, + destination: str, + departure_date: date, + departure_time: str, + available_seats: int, + total_seats: int, + price: float, + ) -> dict[str, Any]: + payload = { + "driver_id": driver_id, + "car_id": car_id, + "departure": departure, + "destination": destination, + "departure_date": departure_date.isoformat(), + "departure_time": departure_time, + "available_seats": available_seats, + "total_seats": total_seats, + "price": price, + "status": "active", + } + response = await self.client.table("driver_trips").insert(payload).execute() + data = _response_data(response) + trip = data[0] if isinstance(data, list) else data + return await self.get_trip_by_id(str(trip["id"])) or trip + + async def create_booking_lead( + self, + *, + customer_id: str, + trip_id: str, + requested_seats: int, + notes: str | None, + ) -> dict[str, Any]: + payload = { + "customer_id": customer_id, + "trip_id": trip_id, + "requested_seats": requested_seats, + "status": "pending", + "notes": notes, + "driver_notification_status": "not_sent", + } + response = await self.client.table("booking_leads").insert(payload).execute() + data = _response_data(response) + return data[0] if isinstance(data, list) else data + + async def update_booking_lead_notification( + self, + *, + lead_id: str, + status: str, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"driver_notification_status": status} + if metadata is not None: + payload["metadata"] = metadata + response = ( + await self.client.table("booking_leads").update(payload).eq("id", lead_id).execute() + ) + data = _response_data(response) + return data[0] if isinstance(data, list) else data diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..d2f3fbc9a83eb8b230e53c301b3ebf6ee2b5ecdb --- /dev/null +++ b/app/main.py @@ -0,0 +1,26 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.api.routes import router +from app.config import Settings, get_settings +from app.services.container import ServiceContainer +from app.utils.logging import configure_logging + + +def create_app( + *, + settings: Settings | None = None, + container: ServiceContainer | None = None, +) -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + active_settings = settings or (container.settings if container else get_settings()) + configure_logging(active_settings.log_level) + app.state.container = container or await ServiceContainer.from_settings(active_settings) + yield + + app = FastAPI(title="FALSA API", version="0.1.0", lifespan=lifespan) + app.include_router(router) + return app diff --git a/app/models/api.py b/app/models/api.py new file mode 100644 index 0000000000000000000000000000000000000000..1fa2411f3387d4b2741baa1a45d6c6a0b7740b55 --- /dev/null +++ b/app/models/api.py @@ -0,0 +1,53 @@ +from pydantic import BaseModel, Field +from typing import Any + + +class HealthResponse(BaseModel): + status: str = "ok" + service: str = "FALSA" + + +class SeedInfoResponse(BaseModel): + indexed_chunks: int + + +class SyncTripsResponse(BaseModel): + indexed_trips: int + + +class WebhookAcceptedResponse(BaseModel): + status: str = "accepted" + messages: int = Field(ge=0) + + +class WebhookDebugResponse(BaseModel): + status: str = "accepted" + messages: int = Field(ge=0) + replies: list[str] = Field(default_factory=list) + + +class JinaEmbeddingRequest(BaseModel): + text: str = Field(min_length=1) + + +class JinaEmbeddingResponse(BaseModel): + status: str = "ok" + text: str + embedding: list[float] + dimensions: int + + +class LLMToolCallRequest(BaseModel): + message: str = Field(min_length=1) + + +class DriverDebugRequest(BaseModel): + message: str = Field(min_length=1) + client_number: str = Field(min_length=1) + + +class LLMToolCallResponse(BaseModel): + status: str = "ok" + llm_response: str | None = None + tool_calls: list[dict[str, Any]] + tool_results: list[dict[str, Any]] = Field(default_factory=list) diff --git a/app/models/domain.py b/app/models/domain.py new file mode 100644 index 0000000000000000000000000000000000000000..0096f980138d8db56187cd6454a45415c332459a --- /dev/null +++ b/app/models/domain.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field +from typing import Any, Literal + +SenderType = Literal["customer", "assistant", "driver", "system"] +UserMode = Literal["new_user", "driver", "passenger"] + + +@dataclass(slots=True) +class WhatsAppInboundMessage: + message_id: str + from_phone: str + text: str + timestamp: str | None = None + profile_name: str | None = None + phone_number_id: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class ToolCall: + id: str + name: str + arguments: str + + +@dataclass(slots=True) +class AIProviderResponse: + content: str | None = None + tool_calls: list[ToolCall] = field(default_factory=list) + raw_message: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class ToolResult: + ok: bool + data: dict[str, Any] + error: str | None = None + + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = {"ok": self.ok, "data": self.data} + if self.error: + payload["error"] = self.error + return payload diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40c8678a8a972cf674b1e55bc77d685a326e5589 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +from app.services.container import ServiceContainer + +__all__ = ["ServiceContainer"] diff --git a/app/services/admin_service.py b/app/services/admin_service.py new file mode 100644 index 0000000000000000000000000000000000000000..f8ec0df5bc01b0c6ebe189896e16a3c54bae0c16 --- /dev/null +++ b/app/services/admin_service.py @@ -0,0 +1,66 @@ +import hashlib +from pathlib import Path + +from app.config import Settings +from app.database.supabase import SupabaseRepository +from app.services.embedding_service import JinaEmbeddingService +from app.services.trip_indexing import build_trip_embedding_record + + +class AdminService: + def __init__( + self, + *, + repository: SupabaseRepository, + embeddings: JinaEmbeddingService, + settings: Settings, + info_path: Path | None = None, + ) -> None: + self.repository = repository + self.embeddings = embeddings + self.settings = settings + self.info_path = info_path or Path("prompts/falsa_info.md") + + async def seed_info(self) -> int: + text = self.info_path.read_text(encoding="utf-8") + chunk_texts = _chunk_markdown(text) + embeddings = await self.embeddings.embed_passages(chunk_texts) + chunks = [] + for chunk, embedding in zip(chunk_texts, embeddings, strict=True): + digest = hashlib.sha256(chunk.encode("utf-8")).hexdigest()[:24] + chunks.append( + { + "id": f"info-{digest}", + "chunk_text": chunk, + "source": str(self.info_path), + "embedding": embedding, + "embedding_model": self.settings.jina_embedding_model, + } + ) + return await self.repository.upsert_info_chunks(chunks) + + async def sync_trips(self) -> int: + trips = await self.repository.list_active_trips() + trip_records = [ + build_trip_embedding_record(trip, self.settings.jina_embedding_model) + for trip in trips + ] + embeddings = await self.embeddings.embed_passages( + [record["chunk_text"] for record in trip_records] + ) + for record, embedding in zip(trip_records, embeddings, strict=True): + record["embedding"] = embedding + return await self.repository.upsert_trip_embeddings(trip_records) + + +def _chunk_markdown(text: str, *, max_chars: int = 1200) -> list[str]: + sections = [section.strip() for section in text.split("\n## ") if section.strip()] + chunks: list[str] = [] + for index, section in enumerate(sections): + content = section if index == 0 else f"## {section}" + if len(content) <= max_chars: + chunks.append(content) + continue + for start in range(0, len(content), max_chars): + chunks.append(content[start : start + max_chars].strip()) + return chunks diff --git a/app/services/container.py b/app/services/container.py new file mode 100644 index 0000000000000000000000000000000000000000..d4e75dd63751b98d5fd8a2118136b5b8d3ab894c --- /dev/null +++ b/app/services/container.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass + +from app.ai.orchestrator import AIOrchestrator +from app.ai.providers import GroqChatProvider, HuggingFaceChatProvider +from app.config import Settings +from app.database.supabase import SupabaseRepository, create_supabase_client +from app.services.admin_service import AdminService +from app.services.conversation_service import ConversationService +from app.services.embedding_service import JinaEmbeddingService +from app.whatsapp.client import WhatsAppClient + + +@dataclass(slots=True) +class ServiceContainer: + settings: Settings + repository: SupabaseRepository + embeddings: JinaEmbeddingService + whatsapp: WhatsAppClient + ai: AIOrchestrator + conversation: ConversationService + admin: AdminService + + @classmethod + async def from_settings(cls, settings: Settings) -> "ServiceContainer": + supabase_client = await create_supabase_client(settings) + repository = SupabaseRepository(supabase_client) + embeddings = JinaEmbeddingService(settings) + whatsapp = WhatsAppClient(settings) + ai = AIOrchestrator( + primary=GroqChatProvider(settings), + fallback=HuggingFaceChatProvider(settings), + temperature=settings.ai_temperature, + max_tool_iterations=settings.ai_max_tool_iterations, + ) + conversation = ConversationService( + repository=repository, + embeddings=embeddings, + whatsapp=whatsapp, + ai=ai, + settings=settings, + ) + admin = AdminService(repository=repository, embeddings=embeddings, settings=settings) + return cls( + settings=settings, + repository=repository, + embeddings=embeddings, + whatsapp=whatsapp, + ai=ai, + conversation=conversation, + admin=admin, + ) diff --git a/app/services/conversation_service.py b/app/services/conversation_service.py new file mode 100644 index 0000000000000000000000000000000000000000..5a8d67af5e3dd7723a3583c53c07c0cc5f5792cc --- /dev/null +++ b/app/services/conversation_service.py @@ -0,0 +1,170 @@ +import logging +from pathlib import Path +from typing import Any + +from app.ai.orchestrator import AIOrchestrator +from app.ai.tool_schemas import get_tool_schemas +from app.config import Settings +from app.database.supabase import SupabaseRepository +from app.models.domain import UserMode, WhatsAppInboundMessage +from app.services.embedding_service import JinaEmbeddingService +from app.tools.handlers import FalsaToolHandlers +from app.tools.registry import ToolRegistry +from app.utils.time import now_in_timezone +from app.whatsapp.client import WhatsAppClient + +logger = logging.getLogger(__name__) + +_PROMPT_PATHS: dict[UserMode, Path] = { + "new_user": Path("prompts/system_new_user.md"), + "driver": Path("prompts/system_driver.md"), + "passenger": Path("prompts/system_passenger.md"), +} + +_TOOLS_BY_MODE: dict[UserMode, list[str]] = { + "new_user": [ + "about_falsa", + "create_driver_account", + "switch_to_driver", + "switch_to_passenger", + ], + "driver": [ + "about_falsa", + "check_driver_info", + "check_driver_trips", + "add_driver_car", + "add_trip_by_driver", + "switch_to_passenger", + ], + "passenger": [ + "about_falsa", + "search_trips", + "create_booking_lead", + "create_driver_account", + "switch_to_driver", + ], +} + + +class ConversationService: + def __init__( + self, + *, + repository: SupabaseRepository, + embeddings: JinaEmbeddingService, + whatsapp: WhatsAppClient, + ai: AIOrchestrator, + settings: Settings, + system_prompt_path: Path | None = None, + ) -> None: + self.repository = repository + self.embeddings = embeddings + self.whatsapp = whatsapp + self.ai = ai + self.settings = settings + self.system_prompt_path = system_prompt_path + + async def handle_inbound_message(self, inbound: WhatsAppInboundMessage) -> str | None: + if await self.repository.message_exists(inbound.message_id): + logger.info("Skipping duplicate WhatsApp message %s", inbound.message_id) + return None + + customer = await self.repository.upsert_customer( + phone_number=inbound.from_phone, + name=inbound.profile_name, + ) + current_message = await self.repository.create_message( + customer_id=str(customer["id"]), + sender_type="customer", + message=inbound.text, + whatsapp_message_id=inbound.message_id, + metadata={"whatsapp": inbound.raw, "timestamp": inbound.timestamp}, + ) + + context = await self.repository.get_recent_context_messages( + customer_id=str(customer["id"]), + current_message_id=str(current_message["id"]), + limit=4, + ) + + user_mode = _resolve_user_mode(customer) + registry = self._tool_registry(customer, sender_phone=inbound.from_phone, user_mode=user_mode) + reply = await self.ai.generate_reply( + messages=self._ai_messages(context, user_mode=user_mode), + tools=get_tool_schemas(user_mode), + registry=registry, + ) + + await self.repository.create_message( + customer_id=str(customer["id"]), + sender_type="assistant", + message=reply, + metadata={"provider_flow": "groq_primary_hf_fallback", "user_mode": user_mode}, + ) + # await self.whatsapp.send_text(inbound.from_phone, reply) + return reply + + def _tool_registry( + self, + customer: dict[str, Any], + *, + sender_phone: str, + user_mode: UserMode, + ) -> ToolRegistry: + handlers = FalsaToolHandlers( + repository=self.repository, + embeddings=self.embeddings, + whatsapp=self.whatsapp, + customer=customer, + sender_phone=sender_phone, + embedding_model=self.settings.jina_embedding_model, + ) + registry = ToolRegistry() + for tool_name in _TOOLS_BY_MODE[user_mode]: + registry.register(tool_name, getattr(handlers, tool_name)) + return registry + + def _ai_messages( + self, + context: list[dict[str, Any]], + *, + user_mode: UserMode, + ) -> list[dict[str, Any]]: + messages = [ + { + "role": "system", + "content": self._system_prompt(user_mode), + } + ] + for row in context: + role = _sender_to_ai_role(row.get("sender_type")) + messages.append({"role": role, "content": row.get("message") or ""}) + return messages + + def _system_prompt(self, user_mode: UserMode) -> str: + if self.system_prompt_path is not None: + template = self.system_prompt_path.read_text(encoding="utf-8") + else: + template = _PROMPT_PATHS[user_mode].read_text(encoding="utf-8") + current_datetime = now_in_timezone(self.settings.app_timezone).isoformat() + return template.format( + current_datetime=current_datetime, + timezone=self.settings.app_timezone, + ) + + +def _resolve_user_mode(customer: dict[str, Any]) -> UserMode: + mode = customer.get("user_mode") + if mode == "driver": + return "driver" + if mode == "passenger": + return "passenger" + return "new_user" + + +def _sender_to_ai_role(sender_type: str | None) -> str: + if sender_type == "assistant": + return "assistant" + if sender_type == "customer": + return "user" + return "system" diff --git a/app/services/embedding_service.py b/app/services/embedding_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0364616aea1e8a962146f01c657955e3630accd1 --- /dev/null +++ b/app/services/embedding_service.py @@ -0,0 +1,90 @@ +from typing import Any + +import httpx + +from app.config import Settings + + +class EmbeddingServiceError(RuntimeError): + pass + + +class JinaEmbeddingService: + def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None: + self.settings = settings + self._client = client + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) + return self._client + + async def embed_query(self, text: str) -> list[float]: + embeddings = await self.embed_texts([text], task=self.settings.jina_query_task) + return embeddings[0] + + async def embed_passages(self, texts: list[str]) -> list[list[float]]: + return await self.embed_texts(texts, task=self.settings.jina_passage_task) + + async def embed_texts(self, texts: list[str], *, task: str) -> list[list[float]]: + cleaned = [text.strip() for text in texts if text and text.strip()] + if not cleaned: + return [] + + payload: dict[str, Any] = { + "model": self.settings.jina_embedding_model, + "input": [ + _with_retrieval_prefix(text, task, self.settings.jina_embedding_model) + for text in cleaned + ], + "dimensions": self.settings.jina_embedding_dimensions, + "task": task, + "truncate": True, + } + headers = { + "Authorization": f"Bearer {self.settings.jina_api_key}", + "Content-Type": "application/json", + } + + try: + response = await self.client.post( + self.settings.jina_embedding_endpoint, + headers=headers, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + raise EmbeddingServiceError(f"Jina embedding request failed: {exc}") from exc + + body = response.json() + data = body.get("data") + if not isinstance(data, list): + raise EmbeddingServiceError("Jina embedding response did not include a data array") + + embeddings = [_embedding_from_item(item) for item in sorted(data, key=_embedding_index)] + if len(embeddings) != len(cleaned): + raise EmbeddingServiceError("Jina embedding response count did not match input count") + return embeddings + + +def _with_retrieval_prefix(text: str, task: str, model: str) -> str: + if "v5" not in model: + return text + if task == "retrieval.query" and not text.startswith("Query:"): + return f"Query: {text}" + if task == "retrieval.passage" and not text.startswith("Document:"): + return f"Document: {text}" + return text + + +def _embedding_index(item: Any) -> int: + if isinstance(item, dict): + return int(item.get("index") or 0) + return 0 + + +def _embedding_from_item(item: Any) -> list[float]: + if not isinstance(item, dict) or not isinstance(item.get("embedding"), list): + raise EmbeddingServiceError("Jina embedding item did not include an embedding array") + return [float(value) for value in item["embedding"]] diff --git a/app/services/trip_indexing.py b/app/services/trip_indexing.py new file mode 100644 index 0000000000000000000000000000000000000000..2ed6ad427d8e568a49e0c845da86012328bc4c6e --- /dev/null +++ b/app/services/trip_indexing.py @@ -0,0 +1,45 @@ +from typing import Any + +from app.database.supabase import SupabaseRepository +from app.services.embedding_service import JinaEmbeddingService +from app.utils.departure import trip_departure_bucket, trip_departure_date + + +def build_trip_embedding_record(trip: dict[str, Any], embedding_model: str) -> dict[str, Any]: + driver = _first_or_dict(trip.get("drivers")) or {} + car = _first_or_dict(trip.get("driver_cars")) or {} + trip_id = str(trip.get("id") or trip.get("trip_id")) + departure_date = trip_departure_date(trip) + departure_time = trip_departure_bucket(trip) + chunk_text = ( + f"Trip {trip_id}: {trip.get('departure')} to {trip.get('destination')} " + f"on {departure_date} during {departure_time}. " + f"Available seats: {trip.get('available_seats')} of {trip.get('total_seats')}. " + f"Vehicle: {car.get('car_type')}. Driver: {driver.get('name')}. " + f"Price: {trip.get('price')}. Status: {trip.get('status')}." + ) + return { + "trip_id": trip_id, + "chunk_text": chunk_text, + "embedding_model": embedding_model, + } + + +async def index_trip( + *, + repository: SupabaseRepository, + embeddings: JinaEmbeddingService, + embedding_model: str, + trip: dict[str, Any], +) -> None: + record = build_trip_embedding_record(trip, embedding_model) + record["embedding"] = (await embeddings.embed_passages([record["chunk_text"]]))[0] + await repository.upsert_trip_embeddings([record]) + + +def _first_or_dict(value: Any) -> dict[str, Any] | None: + if isinstance(value, list): + return value[0] if value else None + if isinstance(value, dict): + return value + return None diff --git a/app/tools/__init__.py b/app/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..486411f1887de76b8c191c6246bcc265334881c7 --- /dev/null +++ b/app/tools/__init__.py @@ -0,0 +1,3 @@ +from app.tools.registry import ToolRegistry + +__all__ = ["ToolRegistry"] diff --git a/app/tools/handlers.py b/app/tools/handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..5da37455a7e14289d9dae2f00316fe0645980ecd --- /dev/null +++ b/app/tools/handlers.py @@ -0,0 +1,742 @@ +from decimal import Decimal, InvalidOperation +from typing import Any + +from app.database.supabase import SupabaseRepository +from app.models.domain import ToolResult +from app.services.embedding_service import JinaEmbeddingService +from app.services.trip_indexing import index_trip +from app.utils.departure import ( + _parse_date_value, + normalize_departure_bucket, + parse_departure_request, + parse_requested_clock_time, + trip_departure_bucket, + trip_departure_date, + trip_satisfies_departure_request, +) +from app.whatsapp.client import WhatsAppClient, WhatsAppClientError + + +class FalsaToolHandlers: + def __init__( + self, + *, + repository: SupabaseRepository, + embeddings: JinaEmbeddingService, + whatsapp: WhatsAppClient, + customer: dict[str, Any], + sender_phone: str, + embedding_model: str, + ) -> None: + self.repository = repository + self.embeddings = embeddings + self.whatsapp = whatsapp + self.customer = customer + self.sender_phone = sender_phone + self.embedding_model = embedding_model + + async def about_falsa(self, arguments: dict[str, Any]) -> ToolResult: + query = str(arguments.get("query") or "").strip() + if not query: + return ToolResult(ok=False, data={}, error="query is required") + + query_embedding = await self.embeddings.embed_query(query) + matches = await self.repository.search_info_chunks_by_vector( + query_embedding=query_embedding, + match_count=5, + ) + if not matches: + return ToolResult( + ok=True, + data={ + "answer": "No matching FALSA policy or FAQ content was found.", + "sources": [], + }, + ) + + return ToolResult( + ok=True, + data={ + "answer_context": [ + { + "text": match.get("chunk_text") + or match.get("metadata", {}).get("chunk_text"), + "source": match.get("source") or match.get("metadata", {}).get("source"), + "score": match.get("score") or match.get("similarity"), + } + for match in matches + ], + }, + ) + + async def search_trips(self, arguments: dict[str, Any]) -> ToolResult: + departure = _optional_string(arguments.get("departure")) + destination = _optional_string(arguments.get("destination")) + travel_date = _optional_string(arguments.get("travel_date")) + travel_time = _optional_string(arguments.get("travel_time")) + travel_time_exact = _optional_string(arguments.get("travel_time_exact")) + travel_datetime = _optional_string(arguments.get("travel_datetime")) + seats = _optional_int(arguments.get("seats")) or 1 + vehicle_type = _optional_string(arguments.get("vehicle_type")) + vector_query_text = _optional_string(arguments.get("vector_query_text")) + departure_request = parse_departure_request( + travel_date=travel_date, + travel_time=travel_time, + travel_datetime=travel_datetime, + ) + requested_time = parse_requested_clock_time( + travel_time=travel_time, + travel_datetime=travel_datetime, + exact_time=travel_time_exact, + ) + + if not departure or not destination: + return ToolResult( + ok=False, + data={}, + error="departure and destination are required before searching trips", + ) + + query = vector_query_text or _trip_vector_query_text( + departure=departure, + destination=destination, + travel_date=travel_date, + travel_time=travel_time, + travel_time_exact=travel_time_exact, + travel_datetime=travel_datetime, + seats=seats, + vehicle_type=vehicle_type, + ) + query_embedding = await self.embeddings.embed_query(query) + trips = await self.repository.search_trips_by_vector( + query_embedding=query_embedding, + departure=departure, + destination=destination, + departure_date=departure_request.departure_date, + departure_time=departure_request.departure_time, + requested_time=requested_time, + seats=seats, + vehicle_type=vehicle_type, + match_count=10, + ) + if not trips: + trips = await self.repository.search_active_trips( + departure=departure, + destination=destination, + seats=seats, + vehicle_type=vehicle_type, + departure_request=departure_request, + ) + + alternate_alert = _alternate_time_alert(trips) + filtered = _sort_trip_summaries([ + _trip_summary(trip) + for trip in trips + if _is_trip_match( + trip, + departure=departure, + destination=destination, + seats=seats, + vehicle_type=vehicle_type, + departure_request=departure_request, + ) + ]) + + return ToolResult( + ok=True, + data={ + "matches": filtered[:5], + "count": len(filtered[:5]), + "alternate_alert": alternate_alert, + "note": ( + "No active matching trips were found." + if not filtered + else _trip_search_note(alternate_alert) + ), + }, + ) + + async def create_booking_lead(self, arguments: dict[str, Any]) -> ToolResult: + trip_id = _optional_string(arguments.get("trip_id")) + requested_seats = _optional_int(arguments.get("requested_seats")) or 1 + notes = _optional_string(arguments.get("notes")) + + if not trip_id: + return ToolResult(ok=False, data={}, error="trip_id is required") + if requested_seats < 1: + return ToolResult(ok=False, data={}, error="requested_seats must be at least 1") + + trip = await self.repository.get_trip_by_id(trip_id) + if not trip: + return ToolResult(ok=False, data={}, error="Trip was not found") + if trip.get("status") != "active": + return ToolResult(ok=False, data={}, error="Trip is not active") + if int(trip.get("available_seats") or 0) < requested_seats: + return ToolResult( + ok=False, + data={"available_seats": trip.get("available_seats")}, + error="Not enough available seats", + ) + + lead = await self.repository.create_booking_lead( + customer_id=str(self.customer["id"]), + trip_id=trip_id, + requested_seats=requested_seats, + notes=notes, + ) + + notification_status = "sent" + notification_error = None + try: + driver_phone = (_first_or_dict(trip.get("drivers")) or {}).get("phone_number") + if not driver_phone: + raise WhatsAppClientError("Driver phone number is missing") + await self.whatsapp.send_text( + driver_phone, + _driver_notification_text( + customer=self.customer, + trip=trip, + requested_seats=requested_seats, + notes=notes, + ), + ) + except Exception as exc: # noqa: BLE001 + notification_status = "failed" + notification_error = str(exc) + + await self.repository.update_booking_lead_notification( + lead_id=str(lead["id"]), + status=notification_status, + metadata={"error": notification_error} if notification_error else None, + ) + + return ToolResult( + ok=True, + data={ + "lead_id": lead["id"], + "status": "pending", + "driver_notification_status": notification_status, + "driver_notification_error": notification_error, + "message": "Booking lead created. Seats are not reserved until confirmed.", + }, + ) + + async def create_driver_account(self, arguments: dict[str, Any]) -> ToolResult: + name = _optional_string(arguments.get("name")) + if not name: + return ToolResult(ok=False, data={}, error="name is required") + + phone = self.sender_phone + existing = await self.repository.get_driver_by_phone(phone) + if existing: + return ToolResult( + ok=False, + data={"driver_id": existing["id"]}, + error="Driver account already exists for this WhatsApp number", + ) + + driver = await self.repository.create_driver(name=name, phone_number=phone) + return ToolResult( + ok=True, + data={ + "driver_id": driver["id"], + "name": driver.get("name"), + "phone_number": driver.get("phone_number"), + "message": "Driver account created successfully.", + }, + ) + + async def check_driver_info(self, arguments: dict[str, Any]) -> ToolResult: + driver = await self.repository.get_driver_by_phone(self.sender_phone) + if not driver: + return ToolResult( + ok=False, + data={"action": "create_driver_account"}, + error=( + "No driver account for this WhatsApp number. " + "Ask the sender to register with create_driver_account first." + ), + ) + + cars = await self.repository.list_driver_cars(str(driver["id"])) + upcoming_trips = await self.repository.list_driver_trips(str(driver["id"])) + + return ToolResult( + ok=True, + data={ + "driver_id": driver["id"], + "name": driver.get("name"), + "phone_number": driver.get("phone_number"), + "status": driver.get("status"), + "vehicle_count": len(cars), + "active_trip_count": len(upcoming_trips), + "vehicles": [ + { + "car_id": str(car.get("id")), + "name": car.get("car_type"), + "plate_number": car.get("plate_number"), + "seat_count": car.get("seat_count"), + } + for car in cars + ], + "active_trips": [ + _trip_summary(trip) for trip in upcoming_trips + ], + }, + ) + + async def check_driver_trips(self, arguments: dict[str, Any]) -> ToolResult: + driver = await self.repository.get_driver_by_phone(self.sender_phone) + if not driver: + return ToolResult( + ok=False, + data={"action": "create_driver_account"}, + error=( + "No driver account for this WhatsApp number. " + "Ask the sender to register with create_driver_account first." + ), + ) + + trips = await self.repository.list_driver_trips(str(driver["id"])) + return ToolResult( + ok=True, + data={ + "driver_id": driver["id"], + "upcoming_trips": [_trip_summary(trip) for trip in trips], + "count": len(trips), + "message": ( + "No upcoming active trips found." + if not trips + else "Upcoming active trips retrieved successfully." + ), + }, + ) + + async def add_driver_car(self, arguments: dict[str, Any]) -> ToolResult: + driver = await self.repository.get_driver_by_phone(self.sender_phone) + if not driver: + return ToolResult( + ok=False, + data={"action": "create_driver_account"}, + error=( + "No driver account for this WhatsApp number. " + "Ask the sender to register with create_driver_account first." + ), + ) + + car_type = _optional_string(arguments.get("name")) + if not car_type: + return ToolResult(ok=False, data={}, error="name is required") + + plate_number = _optional_string(arguments.get("plate_number")) + seat_count = _optional_int(arguments.get("seat_count")) + if seat_count is not None and seat_count < 1: + return ToolResult(ok=False, data={}, error="seat_count must be at least 1") + + car = await self.repository.create_driver_car( + driver_id=str(driver["id"]), + car_type=car_type, + plate_number=plate_number, + seat_count=seat_count, + ) + + return ToolResult( + ok=True, + data={ + "car_id": car.get("id"), + "name": car.get("car_type"), + "plate_number": car.get("plate_number"), + "seat_count": car.get("seat_count"), + "message": "Driver vehicle registered successfully.", + }, + ) + + async def add_trip_by_driver(self, arguments: dict[str, Any]) -> ToolResult: + driver = await self.repository.get_driver_by_phone(self.sender_phone) + if not driver: + return ToolResult( + ok=False, + data={"action": "create_driver_account"}, + error=( + "No driver account for this WhatsApp number. " + "Ask the sender to register with create_driver_account first." + ), + ) + + departure = _optional_string(arguments.get("departure")) + destination = _optional_string(arguments.get("destination")) + if not departure or not destination: + return ToolResult( + ok=False, + data={}, + error="departure and destination are required", + ) + + parsed_date = _parse_date_value(arguments.get("departure_date")) + if not parsed_date: + return ToolResult( + ok=False, + data={}, + error="departure_date is required as YYYY-MM-DD", + ) + + departure_time = normalize_departure_bucket(arguments.get("departure_time")) + if not departure_time: + return ToolResult( + ok=False, + data={}, + error="departure_time must be morning, noon, night, or Arabic صباح / ظهر / ليل", + ) + + latest_trip = await self.repository.get_driver_latest_trip(str(driver["id"])) + cars = await self.repository.list_driver_cars(str(driver["id"])) + + vehicle_type = _optional_string(arguments.get("vehicle_type")) + matched_car = _resolve_driver_car(cars, vehicle_type=vehicle_type) + if matched_car is None and latest_trip: + matched_car = _resolve_driver_car( + cars, + vehicle_type=None, + car_id=_optional_string(latest_trip.get("car_id")), + ) + if matched_car is None and len(cars) == 1: + matched_car = cars[0] + + car_id = str(matched_car["id"]) if matched_car else None + + available_seats = _optional_int(arguments.get("available_seats")) + if available_seats is None and latest_trip is not None: + available_seats = _optional_int(latest_trip.get("available_seats")) + + total_seats = _optional_int(arguments.get("total_seats")) + if total_seats is None and latest_trip is not None: + total_seats = _optional_int(latest_trip.get("total_seats")) + + if total_seats is None and matched_car is not None: + total_seats = _optional_int(matched_car.get("seat_count")) + + price = _optional_price(arguments.get("price")) + if price is None and latest_trip is not None: + price = _optional_price(latest_trip.get("price")) + + missing = [ + field + for field, value in [ + ("available_seats", available_seats), + ("total_seats", total_seats), + ("price", price), + ] + if value is None + ] + if matched_car is None and not vehicle_type: + missing.insert(0, "vehicle_type") + if missing: + return ToolResult( + ok=False, + data={"missing_fields": missing}, + error=f"Missing required trip fields: {', '.join(missing)}", + ) + + if not matched_car: + if vehicle_type: + return ToolResult( + ok=False, + data={}, + error=( + f"No registered vehicle matches '{vehicle_type}'. " + "Ask the driver to use the exact car type or plate from their account." + ), + ) + return ToolResult( + ok=False, + data={}, + error="No registered vehicle found for this driver", + ) + + if total_seats is None or total_seats < 1: + return ToolResult(ok=False, data={}, error="total_seats must be at least 1") + if available_seats is None or available_seats < 0: + return ToolResult(ok=False, data={}, error="available_seats must be at least 0") + if available_seats > total_seats: + return ToolResult( + ok=False, + data={}, + error="available_seats cannot exceed total_seats", + ) + if price is None or price < 0: + return ToolResult(ok=False, data={}, error="price must be zero or greater") + + trip = await self.repository.create_driver_trip( + driver_id=str(driver["id"]), + car_id=car_id, + departure=departure, + destination=destination, + departure_date=parsed_date, + departure_time=departure_time, + available_seats=available_seats, + total_seats=total_seats, + price=price, + ) + + await index_trip( + repository=self.repository, + embeddings=self.embeddings, + embedding_model=self.embedding_model, + trip=trip, + ) + + return ToolResult( + ok=True, + data={ + "trip_id": trip.get("id"), + "departure": trip.get("departure"), + "destination": trip.get("destination"), + "departure_date": parsed_date.isoformat(), + "departure_time": departure_time, + "available_seats": available_seats, + "total_seats": total_seats, + "price": price, + "car_id": car_id, + "indexed": True, + "message": "Trip created and indexed for search.", + }, + ) + + async def switch_to_driver(self, arguments: dict[str, Any]) -> ToolResult: + driver = await self.repository.get_driver_by_phone(self.sender_phone) + if not driver: + return ToolResult( + ok=False, + data={"action": "create_driver_account"}, + error=( + "No driver account for this WhatsApp number. " + "Use create_driver_account first, then switch_to_driver." + ), + ) + + await self.repository.update_customer_user_mode( + customer_id=str(self.customer["id"]), + user_mode="driver", + ) + self.customer["user_mode"] = "driver" + return ToolResult( + ok=True, + data={ + "user_mode": "driver", + "driver_id": driver["id"], + "message": "Switched to driver mode.", + }, + ) + + async def switch_to_passenger(self, arguments: dict[str, Any]) -> ToolResult: + name = _optional_string(arguments.get("name")) + if name: + await self.repository.update_customer_name( + customer_id=str(self.customer["id"]), + name=name, + ) + self.customer["name"] = name + + await self.repository.update_customer_user_mode( + customer_id=str(self.customer["id"]), + user_mode="passenger", + ) + self.customer["user_mode"] = "passenger" + return ToolResult( + ok=True, + data={ + "user_mode": "passenger", + "name": self.customer.get("name"), + "message": "Switched to passenger mode.", + }, + ) + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _optional_int(value: Any) -> int | None: + if value is None or value == "": + return None + return int(value) + + +def _optional_price(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(Decimal(str(value))) + except (InvalidOperation, ValueError): + return None + + +def _resolve_driver_car( + cars: list[dict[str, Any]], + *, + vehicle_type: str | None = None, + car_id: str | None = None, +) -> dict[str, Any] | None: + if car_id: + for car in cars: + if str(car.get("id")) == car_id: + return car + return None + + if not vehicle_type: + return None + + query = vehicle_type.lower() + matches = [ + car + for car in cars + if query in str(car.get("car_type") or "").lower() + or query in str(car.get("plate_number") or "").lower() + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + return None + return None + + +def _trip_vector_query_text( + *, + departure: str, + destination: str, + travel_date: str | None, + travel_time: str | None, + travel_time_exact: str | None, + travel_datetime: str | None, + seats: int, + vehicle_type: str | None, +) -> str: + return " ".join( + part + for part in [ + departure, + destination, + travel_date, + travel_time_exact, + travel_time, + travel_datetime, + f"{seats} seats", + vehicle_type, + ] + if part + ) + + +def _is_trip_match( + trip: dict[str, Any], + *, + departure: str, + destination: str, + seats: int, + vehicle_type: str | None, + departure_request: Any, +) -> bool: + if trip.get("status") != "active": + return False + if int(trip.get("available_seats") or 0) < seats: + return False + if departure.lower() not in str(trip.get("departure") or "").lower(): + return False + if destination.lower() not in str(trip.get("destination") or "").lower(): + return False + if vehicle_type: + car = _first_or_dict(trip.get("driver_cars")) or {} + car_type = car.get("car_type") or trip.get("car_type") + if vehicle_type.lower() not in str(car_type or "").lower(): + return False + if not trip_satisfies_departure_request(trip, departure_request): + return False + return True + + +def _trip_summary(trip: dict[str, Any]) -> dict[str, Any]: + driver = _first_or_dict(trip.get("drivers")) or {} + car = _first_or_dict(trip.get("driver_cars")) or {} + return { + "trip_id": trip.get("trip_id") or trip.get("id"), + "departure": trip.get("departure"), + "destination": trip.get("destination"), + "departure_date": ( + parsed_date.isoformat() if (parsed_date := trip_departure_date(trip)) else None + ), + "departure_time": trip.get("departure_time"), + "departure_time_type": trip_departure_bucket(trip), + "available_seats": trip.get("available_seats"), + "total_seats": trip.get("total_seats"), + "price": trip.get("price"), + "driver_name": driver.get("name") or trip.get("driver_name"), + "car_type": car.get("car_type") or trip.get("car_type"), + "status": trip.get("status"), + "similarity": trip.get("similarity"), + "time_difference_minutes": trip.get("time_difference_minutes"), + } + + +def _sort_trip_summaries(trips: list[dict[str, Any]]) -> list[dict[str, Any]]: + bucket_order = {"morning": 0, "noon": 1, "night": 2} + return sorted( + trips, + key=lambda trip: ( + str(trip.get("departure_date") or ""), + bucket_order.get(str(trip.get("departure_time_type") or ""), 99), + ), + ) + + +def _first_or_dict(value: Any) -> dict[str, Any] | None: + if isinstance(value, list): + return value[0] if value else None + if isinstance(value, dict): + return value + return None + + +def _alternate_time_alert(trips: list[dict[str, Any]]) -> str | None: + if not trips: + return None + raw_difference = trips[0].get("time_difference_minutes") + if raw_difference is None: + return None + try: + difference = int(raw_difference) + except (TypeError, ValueError): + return None + if difference <= 60: + return None + return ( + "The closest available trip is more than 60 minutes away from the requested time. " + "Mention that it is an alternate time before listing the options." + ) + + +def _trip_search_note(alternate_alert: str | None) -> str: + if alternate_alert: + return alternate_alert + return "Trips are available for handoff only; seats are not reserved yet." + + +def _driver_notification_text( + *, + customer: dict[str, Any], + trip: dict[str, Any], + requested_seats: int, + notes: str | None, +) -> str: + return ( + "New FALSA booking lead\n" + f"Customer: {customer.get('name') or customer.get('phone_number')}\n" + f"Phone: {customer.get('phone_number')}\n" + f"Trip: {trip.get('departure')} -> {trip.get('destination')}\n" + f"Departure: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n" + f"Seats requested: {requested_seats}\n" + f"Notes: {notes or '-'}\n" + "Status: pending confirmation" + ) diff --git a/app/tools/registry.py b/app/tools/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..a53f6b28a29b67e949c60684ae6e5b25d90325e6 --- /dev/null +++ b/app/tools/registry.py @@ -0,0 +1,23 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from app.models.domain import ToolResult + +ToolHandler = Callable[[dict[str, Any]], Awaitable[ToolResult]] + + +class ToolRegistry: + def __init__(self) -> None: + self._handlers: dict[str, ToolHandler] = {} + + def register(self, name: str, handler: ToolHandler) -> None: + self._handlers[name] = handler + + async def execute(self, name: str, arguments: dict[str, Any]) -> ToolResult: + handler = self._handlers.get(name) + if handler is None: + return ToolResult(ok=False, data={}, error=f"Unknown tool: {name}") + try: + return await handler(arguments) + except Exception as exc: # noqa: BLE001 + return ToolResult(ok=False, data={}, error=f"Tool {name} failed: {exc}") diff --git a/app/utils/departure.py b/app/utils/departure.py new file mode 100644 index 0000000000000000000000000000000000000000..efd613ab0f2eb834adb48e27fca2e4fbdaffd89e --- /dev/null +++ b/app/utils/departure.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date, datetime, time +from typing import Any, Literal +from zoneinfo import ZoneInfo + +APP_TIMEZONE = "Asia/Aden" +DEPARTURE_BUCKETS = ("morning", "noon", "night") +DepartureBucket = Literal["morning", "noon", "night"] + + +@dataclass(frozen=True) +class DepartureRequest: + departure_date: date | None = None + departure_time: DepartureBucket | None = None + + +def now_in_aden() -> datetime: + return datetime.now(tz=ZoneInfo(APP_TIMEZONE)) + + +def normalize_departure_bucket(value: Any) -> DepartureBucket | None: + if value is None: + return None + text = str(value).strip().lower() + if text in DEPARTURE_BUCKETS: + return text # type: ignore[return-value] + + parsed = _parse_datetime(text) + if parsed: + return bucket_for_time(parsed.astimezone(ZoneInfo(APP_TIMEZONE)).time()) + + parsed_time = _parse_time(text) + if parsed_time: + return bucket_for_time(parsed_time) + + if "morning" in text or "صباح" in text: + return "morning" + if "noon" in text or "afternoon" in text or "ظهر" in text: + return "noon" + if "night" in text or "evening" in text or "ليل" in text or "مساء" in text: + return "night" + return None + + +def bucket_for_time(value: time) -> DepartureBucket: + if value.hour < 12: + return "morning" + if value.hour < 18: + return "noon" + return "night" + + +def parse_departure_request( + *, + travel_date: Any = None, + travel_time: Any = None, + travel_datetime: Any = None, +) -> DepartureRequest: + requested_date = _parse_date_value(travel_date) + requested_time = normalize_departure_bucket(travel_time) + + if travel_datetime: + parsed_datetime = _parse_datetime(str(travel_datetime).strip()) + if parsed_datetime: + aden_datetime = parsed_datetime.astimezone(ZoneInfo(APP_TIMEZONE)) + requested_date = requested_date or aden_datetime.date() + requested_time = requested_time or bucket_for_time(aden_datetime.time()) + else: + requested_date = requested_date or _parse_date_value(travel_datetime) + requested_time = requested_time or normalize_departure_bucket(travel_datetime) + + return DepartureRequest(departure_date=requested_date, departure_time=requested_time) + + +def parse_requested_clock_time( + *, + travel_time: Any = None, + travel_datetime: Any = None, + exact_time: Any = None, +) -> time | None: + for value in (exact_time, travel_datetime, travel_time): + if not value: + continue + text = str(value).strip() + parsed_datetime = _parse_datetime(text) + if parsed_datetime: + return parsed_datetime.astimezone(ZoneInfo(APP_TIMEZONE)).time().replace( + second=0, + microsecond=0, + ) + parsed_time = _parse_time(text) + if parsed_time: + return parsed_time + return None + + +def not_departed_bucket_filter(now: datetime | None = None) -> tuple[date, tuple[str, ...]]: + aden_now = (now or now_in_aden()).astimezone(ZoneInfo(APP_TIMEZONE)) + if aden_now.time() < time(12, 0): + return aden_now.date(), DEPARTURE_BUCKETS + if aden_now.time() < time(18, 0): + return aden_now.date(), ("noon", "night") + return aden_now.date(), ("night",) + + +def trip_departure_date(trip: dict[str, Any]) -> date | None: + parsed = _parse_date_value(trip.get("departure_date")) + if parsed: + return parsed + parsed_datetime = _parse_datetime(str(trip.get("departure_time") or "").strip()) + if parsed_datetime: + return parsed_datetime.astimezone(ZoneInfo(APP_TIMEZONE)).date() + return None + + +def trip_departure_bucket(trip: dict[str, Any]) -> DepartureBucket | None: + return normalize_departure_bucket(trip.get("departure_time")) + + +def trip_satisfies_departure_request( + trip: dict[str, Any], + request: DepartureRequest, + *, + now: datetime | None = None, +) -> bool: + trip_date = trip_departure_date(trip) + trip_bucket = trip_departure_bucket(trip) + if trip_date is None or trip_bucket is None: + return False + + today, remaining_buckets = not_departed_bucket_filter(now) + if request.departure_date: + if trip_date != request.departure_date: + return False + if request.departure_time: + return trip_bucket == request.departure_time + if trip_date == today: + return trip_bucket in remaining_buckets + return trip_date > today + + if request.departure_time and trip_bucket != request.departure_time: + return False + return trip_date > today or (trip_date == today and trip_bucket in remaining_buckets) + + +def _parse_date_value(value: Any) -> date | None: + if value is None: + return None + if isinstance(value, date) and not isinstance(value, datetime): + return value + if isinstance(value, datetime): + return value.astimezone(ZoneInfo(APP_TIMEZONE)).date() + text = str(value).strip() + if not text: + return None + match = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", text) + if not match: + return None + try: + return date.fromisoformat(match.group(1)) + except ValueError: + return None + + +def _parse_datetime(value: str) -> datetime | None: + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=ZoneInfo(APP_TIMEZONE)) + return parsed + + +def _parse_time(value: str) -> time | None: + match = re.search(r"\b([01]?\d|2[0-3])(?::([0-5]\d))?\s*(am|pm)?\b", value, re.I) + if not match: + return None + hour = int(match.group(1)) + minute = int(match.group(2) or 0) + meridiem = (match.group(3) or "").lower() + if meridiem == "pm" and hour < 12: + hour += 12 + if meridiem == "am" and hour == 12: + hour = 0 + return time(hour, minute) diff --git a/app/utils/logging.py b/app/utils/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..0193d59bd201084873dcf5c50536ef81cec44178 --- /dev/null +++ b/app/utils/logging.py @@ -0,0 +1,8 @@ +import logging + + +def configure_logging(level: str) -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) diff --git a/app/utils/time.py b/app/utils/time.py new file mode 100644 index 0000000000000000000000000000000000000000..d6914d81e62b822651df999c93591584dbfcd6c2 --- /dev/null +++ b/app/utils/time.py @@ -0,0 +1,6 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + + +def now_in_timezone(timezone_name: str) -> datetime: + return datetime.now(tz=ZoneInfo(timezone_name)) diff --git a/app/whatsapp/client.py b/app/whatsapp/client.py new file mode 100644 index 0000000000000000000000000000000000000000..279376329402ccbf9fa5b1273396b7e1f869d20d --- /dev/null +++ b/app/whatsapp/client.py @@ -0,0 +1,46 @@ +import httpx + +from app.config import Settings + + +class WhatsAppClientError(RuntimeError): + pass + + +class WhatsAppClient: + def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None: + self._settings = settings + self._client = client + + @property + def _messages_url(self) -> str: + base = self._settings.whatsapp_graph_url.rstrip("/") + version = self._settings.whatsapp_api_version.strip("/") + phone_number_id = self._settings.whatsapp_phone_number_id + return f"{base}/{version}/{phone_number_id}/messages" + + async def send_text(self, to_phone: str, text: str) -> dict: + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to_phone, + "type": "text", + "text": {"preview_url": False, "body": text}, + } + headers = { + "Authorization": f"Bearer {self._settings.whatsapp_access_token}", + "Content-Type": "application/json", + } + + if self._client is not None: + response = await self._client.post(self._messages_url, json=payload, headers=headers) + else: + async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client: + response = await client.post(self._messages_url, json=payload, headers=headers) + + if response.status_code >= 400: + raise WhatsAppClientError( + f"WhatsApp API failed with {response.status_code}: {response.text}" + ) + + return response.json() diff --git a/app/whatsapp/parser.py b/app/whatsapp/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..c6030f57c7e089b868b0148908d28d83ab06482b --- /dev/null +++ b/app/whatsapp/parser.py @@ -0,0 +1,43 @@ +from typing import Any + +from app.models.domain import WhatsAppInboundMessage + +SUPPORTED_MESSAGE_TYPES = {"text"} + + +def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessage]: + messages: list[WhatsAppInboundMessage] = [] + + for entry in payload.get("entry", []): + for change in entry.get("changes", []): + value = change.get("value", {}) + contacts_by_wa_id = { + contact.get("wa_id"): contact.get("profile", {}).get("name") + for contact in value.get("contacts", []) + } + phone_number_id = value.get("metadata", {}).get("phone_number_id") + + for message in value.get("messages", []): + message_type = message.get("type") + if message_type not in SUPPORTED_MESSAGE_TYPES: + continue + + from_phone = message.get("from") + message_id = message.get("id") + text = message.get("text", {}).get("body") + if not from_phone or not message_id or text is None: + continue + + messages.append( + WhatsAppInboundMessage( + message_id=message_id, + from_phone=from_phone, + text=text, + timestamp=message.get("timestamp"), + profile_name=contacts_by_wa_id.get(from_phone), + phone_number_id=phone_number_id, + raw=message, + ) + ) + + return messages diff --git a/app/whatsapp/security.py b/app/whatsapp/security.py new file mode 100644 index 0000000000000000000000000000000000000000..f397dfa49dee7ed60071a117665dde462f55a4ad --- /dev/null +++ b/app/whatsapp/security.py @@ -0,0 +1,22 @@ +import hashlib +import hmac + + +def verify_meta_signature(body: bytes, signature_header: str | None, app_secret: str) -> bool: + if not signature_header or not signature_header.startswith("sha256="): + return False + + expected = "sha256=" + hmac.new( + app_secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature_header) + + +def verify_webhook_challenge( + mode: str | None, + verify_token: str | None, + expected_verify_token: str, +) -> bool: + return mode == "subscribe" and verify_token == expected_verify_token diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..dcacdc80d2f62ac046539f76641650230b431fd4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + falsa-api: + build: . + env_file: + - .env + ports: + - "8000:8000" + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - .:/app diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..37b70adbdbe960806f15f814fb3a26a91613b906 --- /dev/null +++ b/main.py @@ -0,0 +1,3 @@ +from app.main import create_app + +app = create_app() diff --git a/prompts/falsa_info.md b/prompts/falsa_info.md new file mode 100644 index 0000000000000000000000000000000000000000..f7bfb42c8e6d788ecc6cf9e484c87c9043e6892e --- /dev/null +++ b/prompts/falsa_info.md @@ -0,0 +1,24 @@ +# FALSA Information Seed + +## Company +FALSA is an AI-powered travel booking customer service platform. Customers can ask +about car and bus trips through WhatsApp, search available routes, and request a +booking handoff to the driver or support team. + +## How The Service Works +Customers send a message on WhatsApp. FALSA checks available active trips, shares +matching options, and creates a pending booking lead when the customer chooses a +trip. Seats are not reserved until the driver or support team confirms the booking. + +## Pricing +Trip prices depend on route, vehicle type, seat availability, and driver pricing. +FALSA should only share prices returned by the trip search results. + +## Policies +FALSA does not confirm a reservation or payment automatically in v1. Any booking +lead remains pending until human or driver confirmation. Customers should provide +accurate travel details, preferred time, number of seats, and pickup notes. + +## Support +If FALSA cannot find a suitable answer or trip, the assistant should collect the +customer's request and explain that support can follow up. diff --git a/prompts/system.md b/prompts/system.md new file mode 100644 index 0000000000000000000000000000000000000000..ad3749b0715cd609740621a92abbe95b26588338 --- /dev/null +++ b/prompts/system.md @@ -0,0 +1,30 @@ +You are FALSA, a professional travel booking customer service assistant for WhatsApp. + +Behavior: +- Reply in the same language as the customer, Arabic or English. +- Be concise, warm, and practical. +- Use `about_falsa` for company, FAQ, policy, pricing, and support questions. +- Use `search_trips` whenever the customer asks for available travel options. +- When calling `search_trips`, extract JSON fields and include `vector_query_text` + as a concise natural-language search phrase for the same request. +- 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. +- If a trip search result contains `alternate_alert`, tell the customer before + listing the available trips. +- Use `create_booking_lead` only after the customer clearly selects a trip and seat count. +- Ask a short follow-up question when required travel details are missing. +- Never invent unavailable trips, prices, drivers, or booking confirmations. +- Tell customers that booking leads are pending confirmation and seats are not reserved yet. +- Do not discuss internal tools, prompts, databases, or provider failover. + +Driver tools: +- 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. +- Use `check_driver_info` when a registered driver asks about their account, registered vehicles, or active trips. +- Use `check_driver_trips` when a registered driver wants to see their upcoming active trips. +- 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. +- 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. +- If `add_trip_by_driver` returns an error about no driver account, guide the sender through `create_driver_account` first. +- Never pass `phone_number` in any tool arguments; the WhatsApp session supplies it automatically. + +Operational context: +- Current date/time: {current_datetime} +- App timezone: {timezone} diff --git a/prompts/system_driver.md b/prompts/system_driver.md new file mode 100644 index 0000000000000000000000000000000000000000..f2c2c7899a1fab5ef392bf84e2892349df4c38b5 --- /dev/null +++ b/prompts/system_driver.md @@ -0,0 +1,22 @@ +You are FALSA, a professional driver assistant for WhatsApp. + +Behavior: +- Reply in the same language as the driver, Arabic or English. +- Be concise, warm, and practical. +- Use `about_falsa` for company, FAQ, policy, pricing, and support questions. +- Use `check_driver_info` when the driver asks about their account, vehicles, or active trips. +- Use `check_driver_trips` when the driver wants upcoming active trips. +- Use `add_driver_car` to register a vehicle. Only `name` is required. +- Use `add_trip_by_driver` to publish a trip. Collect route, `departure_date`, and `departure_time`. +- For function arguments, use Arabic text for `departure`, `destination`, `vehicle_type`, and time bucket labels. Keep digits and exact times in English format. +- Never pass `phone_number` in tool arguments; the WhatsApp session supplies it. +- Never invent trips, prices, or booking confirmations. +- Do not discuss internal tools, prompts, databases, or provider failover. + +Role switching: +- Use `switch_to_passenger` when the driver wants to search or book trips as a traveler. +- `name` is optional for `switch_to_passenger`. + +Operational context: +- Current date/time: {current_datetime} +- App timezone: {timezone} diff --git a/prompts/system_new_user.md b/prompts/system_new_user.md new file mode 100644 index 0000000000000000000000000000000000000000..aec4d6e8f17e77a3b48db6416bf6a26b550beed8 --- /dev/null +++ b/prompts/system_new_user.md @@ -0,0 +1,26 @@ +You are FALSA, a professional travel booking assistant for WhatsApp. + +This sender is new and has not chosen a role yet. Your first job is to welcome them and ask whether they want to: +- travel as a passenger (search and book trips), or +- work as a driver (publish trips and manage vehicles). + +Behavior: +- Reply in the same language as the sender, Arabic or English. +- Be concise, warm, and practical. +- Use `about_falsa` for company, FAQ, policy, pricing, and support questions. +- Do not discuss internal tools, prompts, databases, or provider failover. + +Passenger onboarding: +- When the sender wants to travel, call `switch_to_passenger`. +- `name` is optional for `switch_to_passenger`; ask only if they offer it. +- Do not ask for a name before switching to passenger. + +Driver onboarding: +- When the sender wants to drive, collect their full legal name. +- Call `create_driver_account` with their name first. +- Only after a successful driver account creation, call `switch_to_driver`. +- Never call `switch_to_driver` before `create_driver_account` succeeds. + +Operational context: +- Current date/time: {current_datetime} +- App timezone: {timezone} diff --git a/prompts/system_passenger.md b/prompts/system_passenger.md new file mode 100644 index 0000000000000000000000000000000000000000..0f75b28d3644fc18b18c9a51e2115243489d4b9b --- /dev/null +++ b/prompts/system_passenger.md @@ -0,0 +1,24 @@ +You are FALSA, a professional travel booking assistant for WhatsApp. + +Behavior: +- Reply in the same language as the customer, Arabic or English. +- Be concise, warm, and practical. +- Use `about_falsa` for company, FAQ, policy, pricing, and support questions. +- Use `search_trips` whenever the customer asks for available travel options. +- When calling `search_trips`, extract JSON fields and include `vector_query_text` as a concise natural-language search phrase. +- For function arguments, use Arabic text for `departure`, `destination`, `vehicle_type`, and travel time bucket labels. Keep digits and exact times in English format. +- If a trip search result contains `alternate_alert`, tell the customer before listing trips. +- Use `create_booking_lead` only after the customer clearly selects a trip and seat count. +- Ask a short follow-up when required travel details are missing. +- Never invent unavailable trips, prices, drivers, or booking confirmations. +- Tell customers that booking leads are pending confirmation and seats are not reserved yet. +- Do not discuss internal tools, prompts, databases, or provider failover. + +Role switching: +- Use `switch_to_driver` when the customer wants to offer rides as a driver. +- If they do not have a driver account yet, collect their full name, call `create_driver_account`, then `switch_to_driver`. +- Never call `switch_to_driver` before `create_driver_account` succeeds when no account exists. + +Operational context: +- Current date/time: {current_datetime} +- App timezone: {timezone} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..3a47456a5e7e9877775bf702654eaffa60a2c287 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "falsa" +version = "0.1.0" +description = "AI-powered WhatsApp travel booking customer service backend" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "pydantic-settings>=2.4.0", + "supabase>=2.6.0", + "openai>=1.40.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.6.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +exclude = [".deps", ".venv", "__pycache__"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC"] +ignore = ["B008"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..74e768fbff949074add4a3a2f8bcae5bcf75b0e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +pydantic-settings>=2.4.0 +supabase>=2.6.0 +openai>=1.40.0 +httpx>=0.27.0 +python-dotenv>=1.0.1 + +# Development and verification +pytest>=8.3.0 +pytest-asyncio>=0.24.0 +ruff>=0.6.0 diff --git a/scripts/seed_info.py b/scripts/seed_info.py new file mode 100644 index 0000000000000000000000000000000000000000..793ee3d86fc1d2822bd26bc3173e26adbe893e05 --- /dev/null +++ b/scripts/seed_info.py @@ -0,0 +1,28 @@ +import asyncio +import os +import sys + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) + +from app.config import get_settings +from app.database.supabase import SupabaseRepository, create_supabase_client +from app.services.admin_service import AdminService +from app.services.embedding_service import JinaEmbeddingService + + +async def main() -> None: + settings = get_settings() + repository = SupabaseRepository(await create_supabase_client(settings)) + service = AdminService( + repository=repository, + embeddings=JinaEmbeddingService(settings), + settings=settings, + ) + indexed = await service.seed_info() + print(f"Indexed {indexed} FALSA info chunks") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/setup_and_seed.sh b/scripts/setup_and_seed.sh new file mode 100644 index 0000000000000000000000000000000000000000..9c655d81489547fcd34513f774df57d504bb7360 --- /dev/null +++ b/scripts/setup_and_seed.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./scripts/setup_and_seed.sh +# Seeds FALSA info and syncs active trips into Supabase vector tables. +# Ensure you have activated your virtualenv and filled .env before running. + +python scripts/seed_info.py +python scripts/sync_trips.py + +echo "Done: Supabase vector info seeded and trips synced." diff --git a/scripts/sync_trips.py b/scripts/sync_trips.py new file mode 100644 index 0000000000000000000000000000000000000000..80bd8ea7bb09bce40997fd2c782a40994c03fb28 --- /dev/null +++ b/scripts/sync_trips.py @@ -0,0 +1,29 @@ +# يرسل بيانات الرحلات الحية إلى جداول المتجهات في Supabase +import asyncio +import os +import sys + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) + +from app.config import get_settings +from app.database.supabase import SupabaseRepository, create_supabase_client +from app.services.admin_service import AdminService +from app.services.embedding_service import JinaEmbeddingService + + +async def main() -> None: + settings = get_settings() + repository = SupabaseRepository(await create_supabase_client(settings)) + service = AdminService( + repository=repository, + embeddings=JinaEmbeddingService(settings), + settings=settings, + ) + indexed = await service.sync_trips() + print(f"Indexed {indexed} active trips") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_scripts.py b/scripts/test_scripts.py new file mode 100644 index 0000000000000000000000000000000000000000..5180ed4b3ff61c4b84e179c5dd47373affcff010 --- /dev/null +++ b/scripts/test_scripts.py @@ -0,0 +1,39 @@ +import asyncio +import os + +import httpx + +BASE_URL = os.getenv("FALSA_BASE_URL", "http://127.0.0.1:8000") +ADMIN_API_KEY = os.getenv("ADMIN_API_KEY") +TEXT = os.getenv("JINA_TEXT", "رحلات الى مسقط اليوم") +ACTION = os.getenv("ACTION", "embed") + +async def main() -> None: + if not ADMIN_API_KEY: + print("Set ADMIN_API_KEY environment variable before running this script.") + return + + async with httpx.AsyncClient(timeout=20.0) as client: + if ACTION == "embed": + url = f"{BASE_URL}/admin/jina-embed" + response = await client.post( + url, + json={"text": TEXT}, + headers={"X-Admin-Api-Key": ADMIN_API_KEY}, + ) + else: + url = f"{BASE_URL}/admin/llm-tool-call" + response = await client.post( + url, + json={"message": TEXT}, + headers={"X-Admin-Api-Key": ADMIN_API_KEY}, + ) + + print("STATUS:", response.status_code) + try: + print(response.json()) + except Exception: + print(response.text) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/supabase/migrations/202605210001_initial_schema.sql b/supabase/migrations/202605210001_initial_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..44cfafc0bd980f276e8b20d4e13cd2f7d70d4c0f --- /dev/null +++ b/supabase/migrations/202605210001_initial_schema.sql @@ -0,0 +1,135 @@ +create extension if not exists pgcrypto; + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create table if not exists public.customers ( + id uuid primary key default gen_random_uuid(), + name text, + phone_number text not null unique, + preferred_language text check (preferred_language in ('ar', 'en')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.drivers ( + id uuid primary key default gen_random_uuid(), + name text not null, + phone_number text not null unique, + status text not null default 'active' check (status in ('active', 'inactive', 'suspended')), + rating numeric(3,2), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.driver_wallet ( + id uuid primary key default gen_random_uuid(), + driver_id uuid not null unique references public.drivers(id) on delete cascade, + balance numeric(12,2) not null default 0, + last_updated timestamptz not null default now() +); + +create table if not exists public.driver_cars ( + id uuid primary key default gen_random_uuid(), + driver_id uuid not null references public.drivers(id) on delete cascade, + car_type text not null, + plate_number text unique, + seat_count integer check (seat_count > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.driver_trips ( + id uuid primary key default gen_random_uuid(), + driver_id uuid not null references public.drivers(id) on delete cascade, + car_id uuid references public.driver_cars(id) on delete set null, + departure text not null, + destination text not null, + departure_date date not null, + departure_time text not null check (departure_time in ('morning', 'noon', 'night')), + available_seats integer not null check (available_seats >= 0), + total_seats integer not null check (total_seats > 0), + price numeric(12,2) not null check (price >= 0), + status text not null default 'active' check (status in ('active', 'cancelled', 'completed')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint driver_trips_available_not_over_total check (available_seats <= total_seats) +); + +create table if not exists public.messages ( + id uuid primary key default gen_random_uuid(), + customer_id uuid not null references public.customers(id) on delete cascade, + sender_type text not null check (sender_type in ('customer', 'assistant', 'driver', 'system')), + message text not null, + whatsapp_message_id text unique, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists public.booking_leads ( + id uuid primary key default gen_random_uuid(), + customer_id uuid not null references public.customers(id) on delete cascade, + trip_id uuid not null references public.driver_trips(id) on delete restrict, + requested_seats integer not null check (requested_seats > 0), + status text not null default 'pending' check (status in ('pending', 'confirmed', 'cancelled')), + notes text, + driver_notification_status text not null default 'not_sent' + check (driver_notification_status in ('not_sent', 'sent', 'failed')), + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_messages_customer_created_at + on public.messages(customer_id, created_at desc); +create index if not exists idx_driver_trips_active_route + on public.driver_trips(status, departure, destination, departure_date, departure_time); +create index if not exists idx_booking_leads_customer + on public.booking_leads(customer_id, created_at desc); +create index if not exists idx_booking_leads_trip + on public.booking_leads(trip_id, created_at desc); + +drop trigger if exists set_customers_updated_at on public.customers; +create trigger set_customers_updated_at +before update on public.customers +for each row execute function public.set_updated_at(); + +drop trigger if exists set_drivers_updated_at on public.drivers; +create trigger set_drivers_updated_at +before update on public.drivers +for each row execute function public.set_updated_at(); + +drop trigger if exists set_driver_cars_updated_at on public.driver_cars; +create trigger set_driver_cars_updated_at +before update on public.driver_cars +for each row execute function public.set_updated_at(); + +drop trigger if exists set_driver_trips_updated_at on public.driver_trips; +create trigger set_driver_trips_updated_at +before update on public.driver_trips +for each row execute function public.set_updated_at(); + +drop trigger if exists set_booking_leads_updated_at on public.booking_leads; +create trigger set_booking_leads_updated_at +before update on public.booking_leads +for each row execute function public.set_updated_at(); + +alter table public.customers enable row level security; +alter table public.messages enable row level security; +alter table public.drivers enable row level security; +alter table public.driver_wallet enable row level security; +alter table public.driver_cars enable row level security; +alter table public.driver_trips enable row level security; +alter table public.booking_leads enable row level security; + +grant usage on schema public to service_role; +grant all on all tables in schema public to service_role; +grant all on all routines in schema public to service_role; +grant all on all sequences in schema public to service_role; diff --git a/supabase/migrations/202605310001_driver_trip_departure_date_time.sql b/supabase/migrations/202605310001_driver_trip_departure_date_time.sql new file mode 100644 index 0000000000000000000000000000000000000000..473a6e2feb98cc18bcbc60296fa68985ade178d1 --- /dev/null +++ b/supabase/migrations/202605310001_driver_trip_departure_date_time.sql @@ -0,0 +1,55 @@ +do $$ +declare + departure_time_type text; +begin + select data_type + into departure_time_type + from information_schema.columns + where table_schema = 'public' + and table_name = 'driver_trips' + and column_name = 'departure_time'; + + if departure_time_type = 'timestamp with time zone' then + alter table public.driver_trips + add column if not exists departure_date date; + + alter table public.driver_trips + add column if not exists departure_time_bucket text; + + update public.driver_trips + set + departure_date = (departure_time at time zone 'Asia/Aden')::date, + departure_time_bucket = case + when extract(hour from departure_time at time zone 'Asia/Aden') < 12 then 'morning' + when extract(hour from departure_time at time zone 'Asia/Aden') < 18 then 'noon' + else 'night' + end + where departure_date is null + or departure_time_bucket is null; + + alter table public.driver_trips + alter column departure_date set not null; + + alter table public.driver_trips + drop column departure_time; + + alter table public.driver_trips + rename column departure_time_bucket to departure_time; + end if; +end; +$$; + +alter table public.driver_trips + alter column departure_time set not null; + +alter table public.driver_trips + drop constraint if exists driver_trips_departure_time_check; + +alter table public.driver_trips + add constraint driver_trips_departure_time_check + check (departure_time in ('morning', 'noon', 'night')); + +drop index if exists public.idx_driver_trips_active_route; + +create index if not exists idx_driver_trips_active_route + on public.driver_trips(status, departure, destination, departure_date, departure_time); diff --git a/supabase/migrations/202606020001_supabase_jina_vectors.sql b/supabase/migrations/202606020001_supabase_jina_vectors.sql new file mode 100644 index 0000000000000000000000000000000000000000..959b4a2a0d82602b4eff41952261d7b701cba591 --- /dev/null +++ b/supabase/migrations/202606020001_supabase_jina_vectors.sql @@ -0,0 +1,212 @@ +create schema if not exists extensions; +create extension if not exists vector with schema extensions; + +create table if not exists public.falsa_info_chunks ( + id text primary key, + chunk_text text not null, + source text, + embedding extensions.vector(1024) not null, + embedding_model text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.driver_trip_embeddings ( + trip_id uuid primary key references public.driver_trips(id) on delete cascade, + chunk_text text not null, + embedding extensions.vector(1024) not null, + embedding_model text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_falsa_info_chunks_embedding + on public.falsa_info_chunks using hnsw (embedding vector_cosine_ops); + +create index if not exists idx_driver_trip_embeddings_embedding + on public.driver_trip_embeddings using hnsw (embedding vector_cosine_ops); + +drop trigger if exists set_falsa_info_chunks_updated_at on public.falsa_info_chunks; +create trigger set_falsa_info_chunks_updated_at +before update on public.falsa_info_chunks +for each row execute function public.set_updated_at(); + +drop trigger if exists set_driver_trip_embeddings_updated_at on public.driver_trip_embeddings; +create trigger set_driver_trip_embeddings_updated_at +before update on public.driver_trip_embeddings +for each row execute function public.set_updated_at(); + +create or replace function public.departure_bucket_clock_time(bucket text) +returns time +language sql +immutable +as $$ + select case bucket + when 'morning' then time '06:00' + when 'noon' then time '12:00' + when 'night' then time '18:00' + else null + end; +$$; + +create or replace function public.match_falsa_info( + query_embedding extensions.vector(1024), + match_threshold float default 0.0, + match_count int default 5 +) +returns table ( + id text, + chunk_text text, + source text, + similarity float +) +language sql +stable +as $$ + select + falsa_info_chunks.id, + falsa_info_chunks.chunk_text, + falsa_info_chunks.source, + 1 - (falsa_info_chunks.embedding <=> query_embedding) as similarity + from public.falsa_info_chunks + where 1 - (falsa_info_chunks.embedding <=> query_embedding) >= match_threshold + order by falsa_info_chunks.embedding <=> query_embedding + limit match_count; +$$; + +create or replace function public.match_active_trips( + query_embedding extensions.vector(1024), + match_threshold float default 0.0, + match_count int default 10, + filter_departure text default null, + filter_destination text default null, + filter_departure_date date default null, + filter_departure_time text default null, + filter_requested_time time default null, + filter_seats int default 1, + filter_vehicle_type text default null +) +returns table ( + trip_id uuid, + departure text, + destination text, + departure_date date, + departure_time text, + available_seats integer, + total_seats integer, + price numeric, + status text, + driver_name text, + driver_phone_number text, + car_type text, + chunk_text text, + similarity float, + time_difference_minutes integer +) +language sql +stable +as $$ + with ranked as ( + select + driver_trips.id as trip_id, + driver_trips.departure, + driver_trips.destination, + driver_trips.departure_date, + driver_trips.departure_time, + driver_trips.available_seats, + driver_trips.total_seats, + driver_trips.price, + driver_trips.status, + drivers.name as driver_name, + drivers.phone_number as driver_phone_number, + driver_cars.car_type, + driver_trip_embeddings.chunk_text, + 1 - (driver_trip_embeddings.embedding <=> query_embedding) as similarity, + driver_trip_embeddings.embedding <=> query_embedding as vector_distance, + case + when filter_requested_time is null then null + else abs( + extract( + epoch from ( + public.departure_bucket_clock_time(driver_trips.departure_time) + - filter_requested_time + ) + ) / 60 + )::integer + end as time_difference_minutes + from public.driver_trip_embeddings + join public.driver_trips on driver_trips.id = driver_trip_embeddings.trip_id + left join public.drivers on drivers.id = driver_trips.driver_id + left join public.driver_cars on driver_cars.id = driver_trips.car_id + where driver_trips.status = 'active' + and driver_trips.available_seats >= coalesce(filter_seats, 1) + and ( + filter_departure is null + or driver_trips.departure ilike '%' || filter_departure || '%' + ) + and ( + filter_destination is null + or driver_trips.destination ilike '%' || filter_destination || '%' + ) + and ( + filter_departure_date is null + or driver_trips.departure_date = filter_departure_date + ) + and ( + filter_departure_time is null + or driver_trips.departure_time = filter_departure_time + ) + and ( + filter_vehicle_type is null + or driver_cars.car_type ilike '%' || filter_vehicle_type || '%' + ) + and ( + driver_trips.departure_date > (now() at time zone 'Asia/Aden')::date + or ( + driver_trips.departure_date = (now() at time zone 'Asia/Aden')::date + and ( + (now() at time zone 'Asia/Aden')::time < time '12:00' + or ( + (now() at time zone 'Asia/Aden')::time < time '18:00' + and driver_trips.departure_time in ('noon', 'night') + ) + or ( + (now() at time zone 'Asia/Aden')::time >= time '18:00' + and driver_trips.departure_time = 'night' + ) + ) + ) + ) + ) + select + ranked.trip_id, + ranked.departure, + ranked.destination, + ranked.departure_date, + ranked.departure_time, + ranked.available_seats, + ranked.total_seats, + ranked.price, + ranked.status, + ranked.driver_name, + ranked.driver_phone_number, + ranked.car_type, + ranked.chunk_text, + ranked.similarity, + ranked.time_difference_minutes + from ranked + where ranked.similarity >= match_threshold + order by + ranked.time_difference_minutes nulls last, + ranked.departure_date, + ranked.vector_distance + limit match_count; +$$; + +alter table public.falsa_info_chunks enable row level security; +alter table public.driver_trip_embeddings enable row level security; + +grant all on public.falsa_info_chunks to service_role; +grant all on public.driver_trip_embeddings to service_role; +grant usage on schema extensions to service_role; +grant all on all routines in schema public to service_role; diff --git a/supabase/migrations/202606080001_customer_user_mode.sql b/supabase/migrations/202606080001_customer_user_mode.sql new file mode 100644 index 0000000000000000000000000000000000000000..0ed309ab3db35617220aa261edb1b3ead525f42b --- /dev/null +++ b/supabase/migrations/202606080001_customer_user_mode.sql @@ -0,0 +1,3 @@ +alter table public.customers + add column if not exists user_mode text + check (user_mode in ('driver', 'passenger')); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..a2b984c9cda89d440d9d5b24727f99813d9ca279 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,386 @@ +import hashlib +import hmac +import json +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any + +import pytest + +from app.config import Settings +from app.main import create_app +from app.models.domain import ToolResult + + +@pytest.fixture +def settings() -> Settings: + return Settings( + supabase_url="https://example.supabase.co", + supabase_service_role_key="supabase-secret", + jina_api_key="jina-secret", + groq_api_key="groq-secret", + groq_model="groq-tool-model", + hf_token="hf-secret", + hf_model="hf-tool-model", + whatsapp_verify_token="verify-token", + whatsapp_app_secret="app-secret", + whatsapp_access_token="wa-token", + whatsapp_phone_number_id="123", + admin_api_key="admin-secret", + ) + + +def signed_body(payload: dict[str, Any], secret: str) -> tuple[bytes, str]: + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + signature = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return body, signature + + +def whatsapp_payload(message_id: str = "wamid.1", text: str = "Hello") -> dict[str, Any]: + return { + "entry": [ + { + "changes": [ + { + "value": { + "metadata": {"phone_number_id": "123"}, + "contacts": [ + { + "wa_id": "967700000001", + "profile": {"name": "Test Customer"}, + } + ], + "messages": [ + { + "from": "967700000001", + "id": message_id, + "timestamp": "1790000000", + "type": "text", + "text": {"body": text}, + } + ], + } + } + ] + } + ] + } + + +class DummyConversation: + def __init__(self) -> None: + self.calls = [] + + async def handle_inbound_message(self, inbound: Any) -> str: + self.calls.append(inbound) + return "ok" + + +class DummyAdmin: + async def seed_info(self) -> int: + return 2 + + async def sync_trips(self) -> int: + return 3 + + +@pytest.fixture +def test_app(settings: Settings) -> Any: + container = SimpleNamespace( + settings=settings, + conversation=DummyConversation(), + admin=DummyAdmin(), + ) + app = create_app(settings=settings, container=container) + app.state.test_container = container + return app + + +class FakeRepository: + def __init__(self) -> None: + self.customers_by_phone: dict[str, dict[str, Any]] = {} + self.drivers_by_phone: dict[str, dict[str, Any]] = {} + self.driver_cars_by_driver: dict[str, list[dict[str, Any]]] = {} + self.latest_trips_by_driver: dict[str, dict[str, Any]] = {} + self.created_drivers: list[dict[str, Any]] = [] + self.created_trips: list[dict[str, Any]] = [] + self.trip_embeddings: list[dict[str, Any]] = [] + self.messages: list[dict[str, Any]] = [] + self.booking_leads: list[dict[str, Any]] = [] + self.notification_updates: list[dict[str, Any]] = [] + self.trips_by_id: dict[str, dict[str, Any]] = {} + self.active_search_results: list[dict[str, Any]] = [] + self.info_search_results: list[dict[str, Any]] = [] + self.trip_vector_search_results: list[dict[str, Any]] = [] + self.vector_trip_search_calls: list[dict[str, Any]] = [] + + async def upsert_customer( + self, + *, + phone_number: str, + name: str | None = None, + preferred_language: str | None = None, + ) -> dict[str, Any]: + customer = self.customers_by_phone.get(phone_number) + if customer is None: + customer = { + "id": f"cust-{len(self.customers_by_phone) + 1}", + "phone_number": phone_number, + "name": name, + "preferred_language": preferred_language, + "user_mode": None, + } + self.customers_by_phone[phone_number] = customer + elif name: + customer["name"] = name + return customer + + async def update_customer_user_mode( + self, + *, + customer_id: str, + user_mode: str, + ) -> dict[str, Any]: + for customer in self.customers_by_phone.values(): + if customer["id"] == customer_id: + customer["user_mode"] = user_mode + return customer + raise KeyError(customer_id) + + async def update_customer_name( + self, + *, + customer_id: str, + name: str, + ) -> dict[str, Any]: + for customer in self.customers_by_phone.values(): + if customer["id"] == customer_id: + customer["name"] = name + return customer + raise KeyError(customer_id) + + async def message_exists(self, whatsapp_message_id: str) -> bool: + return any( + message.get("whatsapp_message_id") == whatsapp_message_id + for message in self.messages + ) + + async def create_message( + self, + *, + customer_id: str, + sender_type: str, + message: str, + whatsapp_message_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + row = { + "id": f"msg-{len(self.messages) + 1}", + "customer_id": customer_id, + "sender_type": sender_type, + "message": message, + "whatsapp_message_id": whatsapp_message_id, + "metadata": metadata or {}, + "created_at": ( + datetime(2026, 5, 21, tzinfo=UTC) + timedelta(seconds=len(self.messages)) + ).isoformat(), + } + self.messages.append(row) + return row + + async def get_recent_context_messages( + self, + *, + customer_id: str, + current_message_id: str, + limit: int = 4, + ) -> list[dict[str, Any]]: + current_index = next( + index for index, row in enumerate(self.messages) if row["id"] == current_message_id + ) + prior = [ + row + for row in self.messages[:current_index] + if row["customer_id"] == customer_id + ][-limit:] + return prior + [self.messages[current_index]] + + async def get_trips_by_ids(self, trip_ids: list[str]) -> list[dict[str, Any]]: + return [self.trips_by_id[trip_id] for trip_id in trip_ids if trip_id in self.trips_by_id] + + async def search_active_trips(self, **_: Any) -> list[dict[str, Any]]: + return self.active_search_results + + async def search_info_chunks_by_vector( + self, + *, + query_embedding: list[float], + match_count: int = 5, + ) -> list[dict[str, Any]]: + return self.info_search_results[:match_count] + + async def search_trips_by_vector(self, **kwargs: Any) -> list[dict[str, Any]]: + self.vector_trip_search_calls.append(kwargs) + match_count = int(kwargs.get("match_count") or 10) + return self.trip_vector_search_results[:match_count] + + async def create_booking_lead( + self, + *, + customer_id: str, + trip_id: str, + requested_seats: int, + notes: str | None, + ) -> dict[str, Any]: + lead = { + "id": f"lead-{len(self.booking_leads) + 1}", + "customer_id": customer_id, + "trip_id": trip_id, + "requested_seats": requested_seats, + "notes": notes, + } + self.booking_leads.append(lead) + return lead + + async def update_booking_lead_notification( + self, + *, + lead_id: str, + status: str, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + update = {"lead_id": lead_id, "status": status, "metadata": metadata} + self.notification_updates.append(update) + return update + + async def get_driver_by_phone(self, phone_number: str) -> dict[str, Any] | None: + return self.drivers_by_phone.get(phone_number) + + async def create_driver(self, *, name: str, phone_number: str) -> dict[str, Any]: + driver = { + "id": f"driver-{len(self.drivers_by_phone) + 1}", + "name": name, + "phone_number": phone_number, + "status": "active", + } + self.drivers_by_phone[phone_number] = driver + self.created_drivers.append(driver) + return driver + + async def get_driver_latest_trip(self, driver_id: str) -> dict[str, Any] | None: + return self.latest_trips_by_driver.get(driver_id) + + async def list_driver_cars(self, driver_id: str) -> list[dict[str, Any]]: + return self.driver_cars_by_driver.get(driver_id, []) + + async def list_driver_trips(self, driver_id: str) -> list[dict[str, Any]]: + trips = [ + trip + for trip in self.trips_by_id.values() + if str(trip.get("driver_id")) == driver_id and trip.get("status") == "active" + ] + return sorted( + trips, + key=lambda trip: ( + str(trip.get("departure_date") or ""), + {"morning": 0, "noon": 1, "night": 2}.get(str(trip.get("departure_time") or ""), 99), + ), + ) + + async def create_driver_car( + self, + *, + driver_id: str, + car_type: str, + plate_number: str | None = None, + seat_count: int | None = None, + ) -> dict[str, Any]: + car_id = f"car-{sum(len(cars) for cars in self.driver_cars_by_driver.values()) + 1}" + car = { + "id": car_id, + "driver_id": driver_id, + "car_type": car_type, + "plate_number": plate_number, + "seat_count": seat_count, + } + self.driver_cars_by_driver.setdefault(driver_id, []).append(car) + return car + + async def create_driver_trip(self, **kwargs: Any) -> dict[str, Any]: + trip_id = f"trip-{len(self.created_trips) + 1}" + driver_id = str(kwargs["driver_id"]) + car_id = kwargs.get("car_id") + cars = self.driver_cars_by_driver.get(driver_id, []) + driver = next( + (row for row in self.drivers_by_phone.values() if row["id"] == driver_id), + {"name": "Driver"}, + ) + matched_car = next( + (car for car in cars if str(car["id"]) == str(car_id)), + {"car_type": "SUV"}, + ) + trip = { + "id": trip_id, + "status": "active", + "drivers": driver, + "driver_cars": matched_car, + **kwargs, + } + self.created_trips.append(trip) + self.trips_by_id[trip_id] = trip + self.latest_trips_by_driver[driver_id] = trip + return trip + + async def get_trip_by_id(self, trip_id: str) -> dict[str, Any] | None: + return self.trips_by_id.get(trip_id) + + async def upsert_trip_embeddings(self, trip_embeddings: list[dict[str, Any]]) -> int: + self.trip_embeddings.extend(trip_embeddings) + return len(trip_embeddings) + + +class FakeEmbeddings: + def __init__(self) -> None: + self.query_texts: list[str] = [] + self.passage_texts: list[list[str]] = [] + self.query_embedding = [0.1, 0.2, 0.3] + + async def embed_query(self, text: str) -> list[float]: + self.query_texts.append(text) + return self.query_embedding + + async def embed_passages(self, texts: list[str]) -> list[list[float]]: + self.passage_texts.append(texts) + return [self.query_embedding for _ in texts] + + +class FakeWhatsApp: + def __init__(self, *, fail: bool = False) -> None: + self.fail = fail + self.sent: list[tuple[str, str]] = [] + + async def send_text(self, to_phone: str, text: str) -> dict[str, Any]: + if self.fail: + raise RuntimeError("send failed") + self.sent.append((to_phone, text)) + return {"messages": [{"id": "sent"}]} + + +class FakeAI: + def __init__(self, reply: str = "Here is your reply") -> None: + self.reply = reply + self.calls: list[dict[str, Any]] = [] + + async def generate_reply( + self, + *, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + registry: Any, + ) -> str: + self.calls.append({"messages": messages, "tools": tools, "registry": registry}) + return self.reply + + +async def ok_tool(_: dict[str, Any]) -> ToolResult: + return ToolResult(ok=True, data={"value": 42}) diff --git a/tests/test_ai_orchestrator.py b/tests/test_ai_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..1bfdf371f5fe1637591bcc7e7b84c7b47329dffe --- /dev/null +++ b/tests/test_ai_orchestrator.py @@ -0,0 +1,139 @@ +import pytest + +from app.ai.orchestrator import AIOrchestrator +from app.ai.providers import InvalidToolCallGenerationError, RetryableProviderError +from app.models.domain import AIProviderResponse, ToolCall +from app.tools.registry import ToolRegistry +from tests.conftest import ok_tool + + +class ScriptedProvider: + def __init__(self, name, script): + self.name = name + self.script = list(script) + self.calls = [] + + async def chat(self, messages, *, tools=None, tool_choice="auto", temperature=0.2): + self.calls.append( + { + "messages": messages, + "tools": tools, + "tool_choice": tool_choice, + "temperature": temperature, + } + ) + next_item = self.script.pop(0) + if isinstance(next_item, Exception): + raise next_item + return next_item + + +@pytest.mark.asyncio +async def test_ai_falls_back_when_primary_rate_limited(): + primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")]) + fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback reply")]) + registry = ToolRegistry() + orchestrator = AIOrchestrator( + primary=primary, + fallback=fallback, + temperature=0.2, + max_tool_iterations=3, + ) + + reply = await orchestrator.generate_reply(messages=[], tools=[], registry=registry) + + assert reply == "fallback reply" + assert len(primary.calls) == 1 + assert len(fallback.calls) == 1 + + +@pytest.mark.asyncio +async def test_ai_retries_invalid_groq_tool_generation_before_fallback(): + primary = ScriptedProvider( + "groq", + [ + InvalidToolCallGenerationError("bad tool"), + AIProviderResponse(content="primary retry reply"), + ], + ) + fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback")]) + orchestrator = AIOrchestrator( + primary=primary, + fallback=fallback, + temperature=0.4, + max_tool_iterations=3, + ) + + reply = await orchestrator.generate_reply(messages=[], tools=[], registry=ToolRegistry()) + + assert reply == "primary retry reply" + assert [call["temperature"] for call in primary.calls] == [0.4, 0.2] + assert fallback.calls == [] + + +@pytest.mark.asyncio +async def test_ai_executes_tool_call_and_returns_final_response(): + primary = ScriptedProvider( + "groq", + [ + AIProviderResponse( + tool_calls=[ + ToolCall( + id="call-1", + name="about_falsa", + arguments='{"query":"FALSA","language":"en"}', + ) + ] + ), + AIProviderResponse(content="FALSA helps with travel booking."), + ], + ) + registry = ToolRegistry() + registry.register("about_falsa", ok_tool) + orchestrator = AIOrchestrator( + primary=primary, + fallback=ScriptedProvider("hf", []), + temperature=0.2, + max_tool_iterations=3, + ) + + reply = await orchestrator.generate_reply( + messages=[], + tools=[{"type": "function"}], + registry=registry, + ) + + assert reply == "FALSA helps with travel booking." + second_call_messages = primary.calls[1]["messages"] + assert second_call_messages[-1]["role"] == "tool" + assert '"ok": true' in second_call_messages[-1]["content"] + + +@pytest.mark.asyncio +async def test_ai_reports_invalid_tool_arguments_to_model(): + primary = ScriptedProvider( + "groq", + [ + AIProviderResponse( + tool_calls=[ToolCall(id="call-1", name="about_falsa", arguments="{bad json")] + ), + AIProviderResponse(content="Please share the question again."), + ], + ) + registry = ToolRegistry() + registry.register("about_falsa", ok_tool) + orchestrator = AIOrchestrator( + primary=primary, + fallback=ScriptedProvider("hf", []), + temperature=0.2, + max_tool_iterations=3, + ) + + reply = await orchestrator.generate_reply( + messages=[], + tools=[{"type": "function"}], + registry=registry, + ) + + assert reply == "Please share the question again." + assert "Invalid tool arguments" in primary.calls[1]["messages"][-1]["content"] diff --git a/tests/test_conversation_service.py b/tests/test_conversation_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9875de808d0e223d6d645d8097050c6427c59311 --- /dev/null +++ b/tests/test_conversation_service.py @@ -0,0 +1,124 @@ +import pytest + +from app.models.domain import WhatsAppInboundMessage +from app.services.conversation_service import ConversationService +from tests.conftest import FakeAI, FakeEmbeddings, FakeRepository, FakeWhatsApp + + +@pytest.mark.asyncio +async def test_conversation_stores_messages_uses_last_four_context_and_sends_reply(settings): + repository = FakeRepository() + customer = await repository.upsert_customer(phone_number="967700000001", name="Customer") + for index in range(6): + await repository.create_message( + customer_id=customer["id"], + sender_type="customer" if index % 2 == 0 else "assistant", + message=f"prior-{index}", + ) + ai = FakeAI(reply="Found trips for you") + whatsapp = FakeWhatsApp() + service = ConversationService( + repository=repository, + embeddings=FakeEmbeddings(), + whatsapp=whatsapp, + ai=ai, + settings=settings, + ) + + reply = await service.handle_inbound_message( + WhatsAppInboundMessage( + message_id="wamid.new", + from_phone="967700000001", + text="Aden to Mukalla tomorrow", + profile_name="Customer", + ) + ) + + assert reply == "Found trips for you" + assert repository.messages[-2]["message"] == "Aden to Mukalla tomorrow" + assert repository.messages[-1]["sender_type"] == "assistant" + + ai_messages = ai.calls[0]["messages"] + assert ai_messages[0]["role"] == "system" + assert "not chosen a role yet" in ai_messages[0]["content"] + assert [message["content"] for message in ai_messages[1:]] == [ + "prior-2", + "prior-3", + "prior-4", + "prior-5", + "Aden to Mukalla tomorrow", + ] + tool_names = {tool["function"]["name"] for tool in ai.calls[0]["tools"]} + assert tool_names == { + "about_falsa", + "create_driver_account", + "switch_to_driver", + "switch_to_passenger", + } + + +@pytest.mark.asyncio +async def test_conversation_skips_duplicate_whatsapp_message(settings): + repository = FakeRepository() + customer = await repository.upsert_customer(phone_number="967700000001") + await repository.create_message( + customer_id=customer["id"], + sender_type="customer", + message="already handled", + whatsapp_message_id="wamid.duplicate", + ) + ai = FakeAI() + whatsapp = FakeWhatsApp() + service = ConversationService( + repository=repository, + embeddings=FakeEmbeddings(), + whatsapp=whatsapp, + ai=ai, + settings=settings, + ) + + result = await service.handle_inbound_message( + WhatsAppInboundMessage( + message_id="wamid.duplicate", + from_phone="967700000001", + text="same message", + ) + ) + + assert result is None + assert ai.calls == [] + assert whatsapp.sent == [] + + +@pytest.mark.asyncio +async def test_conversation_uses_passenger_tools_when_user_mode_is_passenger(settings): + repository = FakeRepository() + customer = await repository.upsert_customer(phone_number="967700000001", name="Customer") + customer["user_mode"] = "passenger" + ai = FakeAI(reply="Passenger reply") + service = ConversationService( + repository=repository, + embeddings=FakeEmbeddings(), + whatsapp=FakeWhatsApp(), + ai=ai, + settings=settings, + ) + + await service.handle_inbound_message( + WhatsAppInboundMessage( + message_id="wamid.passenger", + from_phone="967700000001", + text="Aden to Mukalla tomorrow", + profile_name="Customer", + ) + ) + + tool_names = {tool["function"]["name"] for tool in ai.calls[0]["tools"]} + assert tool_names == { + "about_falsa", + "search_trips", + "create_booking_lead", + "create_driver_account", + "switch_to_driver", + } + assert "travel booking assistant" in ai.calls[0]["messages"][0]["content"] diff --git a/tests/test_tool_schemas.py b/tests/test_tool_schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..0bddf7e864f43f67ca5fe09e3af2162c1349cd1f --- /dev/null +++ b/tests/test_tool_schemas.py @@ -0,0 +1,31 @@ +from app.ai.tool_schemas import get_all_tool_schemas, get_tool_schemas + + +def test_new_user_tools_are_onboarding_only(): + names = {schema["function"]["name"] for schema in get_tool_schemas("new_user")} + assert names == { + "about_falsa", + "create_driver_account", + "switch_to_driver", + "switch_to_passenger", + } + + +def test_driver_tools_exclude_passenger_booking_tools(): + names = {schema["function"]["name"] for schema in get_tool_schemas("driver")} + assert "search_trips" not in names + assert "create_booking_lead" not in names + assert "switch_to_passenger" in names + + +def test_passenger_tools_exclude_driver_management_tools(): + names = {schema["function"]["name"] for schema in get_tool_schemas("passenger")} + assert "add_trip_by_driver" not in names + assert "check_driver_info" not in names + assert "switch_to_driver" in names + assert "create_driver_account" in names + + +def test_all_tool_schemas_include_every_tool(): + names = {schema["function"]["name"] for schema in get_all_tool_schemas()} + assert len(names) == 10 diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..dff3e0e9f1ba227e7b90f99fa32be6dee0e9a37c --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,525 @@ +import pytest + +from app.tools.handlers import FalsaToolHandlers +from tests.conftest import FakeEmbeddings, FakeRepository, FakeWhatsApp + + +def make_handlers( + *, + repository: FakeRepository | None = None, + embeddings: FakeEmbeddings | None = None, + whatsapp: FakeWhatsApp | None = None, + customer: dict | None = None, + sender_phone: str = "967700000001", +) -> FalsaToolHandlers: + return FalsaToolHandlers( + repository=repository or FakeRepository(), + embeddings=embeddings or FakeEmbeddings(), + whatsapp=whatsapp or FakeWhatsApp(), + customer=customer or {"id": "cust-1", "phone_number": sender_phone}, + sender_phone=sender_phone, + embedding_model="jina-embeddings-v5-text-small", + ) + + +def trip( + *, + trip_id="trip-1", + departure="Aden", + destination="Mukalla", + departure_date="2026-12-01", + departure_time="morning", + available_seats=3, + status="active", + car_type="SUV", +): + return { + "id": trip_id, + "departure": departure, + "destination": destination, + "departure_date": departure_date, + "departure_time": departure_time, + "available_seats": available_seats, + "total_seats": 4, + "price": "50.00", + "status": status, + "drivers": {"name": "Ali", "phone_number": "967700000009"}, + "driver_cars": {"car_type": car_type}, + } + + +@pytest.mark.asyncio +async def test_about_falsa_returns_retrieved_context(): + repository = FakeRepository() + repository.info_search_results = [ + { + "similarity": 0.91, + "chunk_text": "FALSA creates pending booking leads.", + "source": "prompts/falsa_info.md", + } + ] + embeddings = FakeEmbeddings() + handlers = make_handlers(repository=repository, embeddings=embeddings) + + result = await handlers.about_falsa({"query": "What is FALSA?", "language": "en"}) + + assert result.ok is True + assert result.data["answer_context"][0]["text"] == "FALSA creates pending booking leads." + assert embeddings.query_texts == ["What is FALSA?"] + + +@pytest.mark.asyncio +async def test_search_trips_uses_supabase_vector_matches_and_validates_business_rules(): + repository = FakeRepository() + repository.trip_vector_search_results = [ + trip(trip_id="trip-1", available_seats=2), + trip(trip_id="trip-2", status="cancelled"), + trip(trip_id="trip-3", available_seats=0), + ] + embeddings = FakeEmbeddings() + handlers = make_handlers(repository=repository, embeddings=embeddings) + + result = await handlers.search_trips( + {"departure": "Aden", "destination": "Mukalla", "seats": 2, "vehicle_type": "SUV"} + ) + + assert result.ok is True + assert result.data["count"] == 1 + assert result.data["matches"][0]["trip_id"] == "trip-1" + assert result.data["matches"][0]["departure_time_type"] == "morning" + assert embeddings.query_texts == ["Aden Mukalla 2 seats SUV"] + assert repository.vector_trip_search_calls[0]["departure"] == "Aden" + assert repository.vector_trip_search_calls[0]["destination"] == "Mukalla" + + +@pytest.mark.asyncio +async def test_search_trips_filters_requested_datetime_to_departure_bucket(): + repository = FakeRepository() + repository.trip_vector_search_results = [ + trip(trip_id="trip-1", departure_date="2026-12-02", departure_time="morning"), + trip(trip_id="trip-2", departure_date="2026-12-02", departure_time="night"), + ] + handlers = make_handlers(repository=repository) + + result = await handlers.search_trips( + { + "departure": "Aden", + "destination": "Mukalla", + "travel_datetime": "2026-12-02T19:30:00+03:00", + } + ) + + assert result.ok is True + assert result.data["count"] == 1 + assert result.data["matches"][0]["trip_id"] == "trip-2" + assert repository.vector_trip_search_calls[0]["departure_time"] == "night" + assert repository.vector_trip_search_calls[0]["requested_time"].hour == 19 + + +@pytest.mark.asyncio +async def test_search_trips_reports_no_matches(): + handlers = make_handlers() + + result = await handlers.search_trips({"departure": "Aden", "destination": "Sana'a"}) + + assert result.ok is True + assert result.data["matches"] == [] + assert "No active" in result.data["note"] + + +@pytest.mark.asyncio +async def test_search_trips_includes_alternate_alert_when_first_result_is_over_one_hour(): + repository = FakeRepository() + repository.trip_vector_search_results = [ + { + **trip(trip_id="trip-1", departure_time="morning"), + "time_difference_minutes": 90, + } + ] + handlers = make_handlers(repository=repository) + + result = await handlers.search_trips( + { + "departure": "Aden", + "destination": "Mukalla", + "travel_time": "morning", + "travel_time_exact": "04:30", + "vector_query_text": "Aden to Mukalla tomorrow morning at 04:30", + } + ) + + assert result.ok is True + assert result.data["count"] == 1 + assert "more than 60 minutes" in result.data["alternate_alert"] + assert result.data["matches"][0]["time_difference_minutes"] == 90 + + +@pytest.mark.asyncio +async def test_create_booking_lead_notifies_driver(): + repository = FakeRepository() + repository.trips_by_id["trip-1"] = trip() + whatsapp = FakeWhatsApp() + handlers = make_handlers( + repository=repository, + whatsapp=whatsapp, + customer={"id": "cust-1", "phone_number": "967700000001", "name": "Mona"}, + ) + + result = await handlers.create_booking_lead( + {"trip_id": "trip-1", "requested_seats": 2, "notes": "Window seat"} + ) + + assert result.ok is True + assert result.data["driver_notification_status"] == "sent" + assert repository.booking_leads[0]["requested_seats"] == 2 + assert whatsapp.sent[0][0] == "967700000009" + + +@pytest.mark.asyncio +async def test_create_booking_lead_keeps_pending_when_driver_notification_fails(): + repository = FakeRepository() + repository.trips_by_id["trip-1"] = trip() + handlers = make_handlers( + repository=repository, + whatsapp=FakeWhatsApp(fail=True), + ) + + result = await handlers.create_booking_lead({"trip_id": "trip-1", "requested_seats": 1}) + + assert result.ok is True + assert result.data["status"] == "pending" + assert result.data["driver_notification_status"] == "failed" + assert repository.notification_updates[0]["status"] == "failed" + + +@pytest.mark.asyncio +async def test_create_booking_lead_rejects_insufficient_seats(): + repository = FakeRepository() + repository.trips_by_id["trip-1"] = trip(available_seats=1) + handlers = make_handlers(repository=repository) + + result = await handlers.create_booking_lead({"trip_id": "trip-1", "requested_seats": 2}) + + assert result.ok is False + assert result.error == "Not enough available seats" + assert repository.booking_leads == [] + + +@pytest.mark.asyncio +async def test_create_driver_account_uses_sender_phone(): + repository = FakeRepository() + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.create_driver_account( + {"name": "Ali Driver", "phone_number": "967700000099"} + ) + + assert result.ok is True + assert repository.created_drivers[0]["phone_number"] == "967700000010" + assert repository.drivers_by_phone["967700000010"]["name"] == "Ali Driver" + + +@pytest.mark.asyncio +async def test_create_driver_account_rejects_duplicate(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + } + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.create_driver_account({"name": "Ali Driver"}) + + assert result.ok is False + assert "already exists" in (result.error or "") + assert repository.created_drivers == [] + + +@pytest.mark.asyncio +async def test_add_trip_by_driver_requires_account(): + handlers = make_handlers(sender_phone="967700000010") + + result = await handlers.add_trip_by_driver( + { + "departure": "عدن", + "destination": "المكلا", + "departure_date": "2026-06-10", + "departure_time": "morning", + } + ) + + assert result.ok is False + assert result.data.get("action") == "create_driver_account" + assert "create_driver_account" in (result.error or "") + + +@pytest.mark.asyncio +async def test_add_driver_car_accepts_name_only(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + } + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.add_driver_car({"name": "سيارة"}) + + assert result.ok is True + assert result.data["name"] == "سيارة" + assert result.data["plate_number"] is None + assert result.data["seat_count"] is None + assert repository.driver_cars_by_driver["driver-1"][0]["car_type"] == "سيارة" + + +@pytest.mark.asyncio +async def test_check_driver_info_returns_account_summary(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + "status": "active", + } + repository.driver_cars_by_driver["driver-1"] = [ + {"id": "car-1", "car_type": "SUV", "plate_number": "1234", "seat_count": 4} + ] + repository.trips_by_id["trip-1"] = { + "id": "trip-1", + "driver_id": "driver-1", + "departure": "عدن", + "destination": "المكلا", + "departure_date": "2026-12-01", + "departure_time": "morning", + "available_seats": 2, + "total_seats": 4, + "price": "80.00", + "status": "active", + "driver_cars": {"car_type": "SUV"}, + "drivers": {"name": "Ali"}, + } + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.check_driver_info({}) + + assert result.ok is True + assert result.data["driver_id"] == "driver-1" + assert result.data["vehicle_count"] == 1 + assert result.data["active_trip_count"] == 1 + assert result.data["vehicles"][0]["name"] == "SUV" + assert result.data["active_trips"][0]["trip_id"] == "trip-1" + + +@pytest.mark.asyncio +async def test_check_driver_trips_returns_upcoming_trips(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + } + repository.trips_by_id["trip-1"] = { + "id": "trip-1", + "driver_id": "driver-1", + "departure": "عدن", + "destination": "المكلا", + "departure_date": "2026-12-01", + "departure_time": "morning", + "available_seats": 2, + "total_seats": 4, + "price": "80.00", + "status": "active", + "driver_cars": {"car_type": "SUV"}, + "drivers": {"name": "Ali"}, + } + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.check_driver_trips({}) + + assert result.ok is True + assert result.data["count"] == 1 + assert result.data["upcoming_trips"][0]["trip_id"] == "trip-1" + + +@pytest.mark.asyncio +async def test_check_driver_trips_requests_driver_registration_if_unregistered(): + handlers = make_handlers(sender_phone="967700000010") + + result = await handlers.check_driver_trips({}) + + assert result.ok is False + assert result.data["action"] == "create_driver_account" + assert "create_driver_account" in (result.error or "") + + +@pytest.mark.asyncio +async def test_check_driver_info_requests_driver_registration_if_unregistered(): + handlers = make_handlers(sender_phone="967700000010") + + result = await handlers.check_driver_info({}) + + assert result.ok is False + assert result.data["action"] == "create_driver_account" + assert "create_driver_account" in (result.error or "") + + +@pytest.mark.asyncio +async def test_add_trip_by_driver_uses_latest_trip_defaults(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + } + repository.driver_cars_by_driver["driver-1"] = [ + {"id": "car-1", "car_type": "SUV", "seat_count": 4}, + ] + repository.latest_trips_by_driver["driver-1"] = { + "car_id": "car-1", + "available_seats": 3, + "total_seats": 4, + "price": "75.00", + } + embeddings = FakeEmbeddings() + handlers = make_handlers( + repository=repository, + embeddings=embeddings, + sender_phone="967700000010", + ) + + result = await handlers.add_trip_by_driver( + { + "departure": "عدن", + "destination": "صنعاء", + "departure_date": "2026-06-10", + "departure_time": "noon", + } + ) + + assert result.ok is True + assert result.data["trip_id"] == "trip-1" + assert repository.created_trips[0]["car_id"] == "car-1" + assert repository.created_trips[0]["available_seats"] == 3 + assert repository.created_trips[0]["price"] == 75.0 + assert repository.trip_embeddings[0]["trip_id"] == "trip-1" + assert embeddings.passage_texts == [["Trip trip-1: عدن to صنعاء on 2026-06-10 during noon. Available seats: 3 of 4. Vehicle: SUV. Driver: Ali. Price: 75.0. Status: active."]] + + +@pytest.mark.asyncio +async def test_add_trip_by_driver_resolves_vehicle_type_by_name(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + } + repository.driver_cars_by_driver["driver-1"] = [ + {"id": "car-1", "car_type": "باص", "plate_number": "1234", "seat_count": 14}, + {"id": "car-2", "car_type": "سيارة", "plate_number": "5678", "seat_count": 4}, + ] + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.add_trip_by_driver( + { + "departure": "عدن", + "destination": "المكلا", + "departure_date": "2026-06-10", + "departure_time": "morning", + "vehicle_type": "باص", + "available_seats": 10, + "total_seats": 14, + "price": 120, + } + ) + + assert result.ok is True + assert repository.created_trips[0]["car_id"] == "car-1" + + +@pytest.mark.asyncio +async def test_add_trip_by_driver_indexes_new_trip(): + repository = FakeRepository() + repository.drivers_by_phone["967700000010"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000010", + } + repository.driver_cars_by_driver["driver-1"] = [ + {"id": "car-1", "car_type": "SUV", "seat_count": 4}, + ] + handlers = make_handlers(repository=repository, sender_phone="967700000010") + + result = await handlers.add_trip_by_driver( + { + "departure": "عدن", + "destination": "المكلا", + "departure_date": "2026-06-10", + "departure_time": "morning", + "vehicle_type": "SUV", + "available_seats": 2, + "total_seats": 4, + "price": 50, + } + ) + + assert result.ok is True + assert result.data["indexed"] is True + assert len(repository.trip_embeddings) == 1 + + +@pytest.mark.asyncio +async def test_switch_to_driver_requires_existing_driver_account(): + repository = FakeRepository() + customer = {"id": "cust-1", "phone_number": "967700000001"} + handlers = make_handlers(repository=repository, customer=customer) + + result = await handlers.switch_to_driver({}) + + assert result.ok is False + assert result.data["action"] == "create_driver_account" + + +@pytest.mark.asyncio +async def test_switch_to_driver_updates_customer_mode(): + repository = FakeRepository() + customer = await repository.upsert_customer(phone_number="967700000001") + repository.drivers_by_phone["967700000001"] = { + "id": "driver-1", + "name": "Ali", + "phone_number": "967700000001", + } + handlers = make_handlers(repository=repository, customer=customer) + + result = await handlers.switch_to_driver({}) + + assert result.ok is True + assert result.data["user_mode"] == "driver" + assert customer["user_mode"] == "driver" + + +@pytest.mark.asyncio +async def test_switch_to_passenger_updates_mode_and_optional_name(): + repository = FakeRepository() + customer = await repository.upsert_customer(phone_number="967700000001") + handlers = make_handlers(repository=repository, customer=customer) + + result = await handlers.switch_to_passenger({"name": "Sara"}) + + assert result.ok is True + assert result.data["user_mode"] == "passenger" + assert customer["user_mode"] == "passenger" + assert customer["name"] == "Sara" + + +@pytest.mark.asyncio +async def test_switch_to_passenger_without_name(): + repository = FakeRepository() + customer = await repository.upsert_customer(phone_number="967700000001", name="Existing") + handlers = make_handlers(repository=repository, customer=customer) + + result = await handlers.switch_to_passenger({}) + + assert result.ok is True + assert customer["user_mode"] == "passenger" + assert customer["name"] == "Existing" diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000000000000000000000000000000000000..69024d638afe448ac25d436e198bdd6c1895dc5a --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,151 @@ +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from app.main import create_app +from app.models.domain import ToolResult +from tests.conftest import signed_body, whatsapp_payload + + +def test_whatsapp_verification_success(test_app): + with TestClient(test_app) as client: + response = client.get( + "/webhooks/whatsapp", + params={ + "hub.mode": "subscribe", + "hub.verify_token": "verify-token", + "hub.challenge": "challenge-code", + }, + ) + + assert response.status_code == 200 + assert response.text == "challenge-code" + + +def test_whatsapp_verification_rejects_wrong_token(test_app): + with TestClient(test_app) as client: + response = client.get( + "/webhooks/whatsapp", + params={ + "hub.mode": "subscribe", + "hub.verify_token": "wrong", + "hub.challenge": "challenge-code", + }, + ) + + assert response.status_code == 401 + + +def test_post_webhook_validates_signature_and_accepts_text(test_app): + payload = whatsapp_payload(message_id="wamid.100", text="I need Aden to Mukalla") + body, signature = signed_body(payload, "app-secret") + + with TestClient(test_app) as client: + response = client.post( + "/webhooks/whatsapp", + content=body, + headers={ + "content-type": "application/json", + "x-hub-signature-256": signature, + }, + ) + + conversation = test_app.state.test_container.conversation + assert response.status_code == 200 + assert response.json() == {"status": "accepted", "messages": 1} + assert len(conversation.calls) == 1 + assert conversation.calls[0].text == "I need Aden to Mukalla" + + +def test_post_webhook_debug_returns_reply_and_status(test_app): + payload = whatsapp_payload(message_id="wamid.200", text="Debug this message") + body, signature = signed_body(payload, "app-secret") + + with TestClient(test_app) as client: + response = client.post( + "/webhooks/whatsapp/debug", + content=body, + headers={ + "content-type": "application/json", + "x-hub-signature-256": signature, + }, + ) + + conversation = test_app.state.test_container.conversation + assert response.status_code == 200 + assert response.json() == { + "status": "accepted", + "messages": 1, + "replies": ["ok"], + } + assert len(conversation.calls) == 1 + + +def test_post_webhook_rejects_bad_signature(test_app): + payload = whatsapp_payload() + body, _ = signed_body(payload, "app-secret") + + with TestClient(test_app) as client: + response = client.post( + "/webhooks/whatsapp", + content=body, + headers={ + "content-type": "application/json", + "x-hub-signature-256": "sha256=bad", + }, + ) + + assert response.status_code == 401 + + +def test_admin_routes_require_key(test_app): + with TestClient(test_app) as client: + rejected = client.post("/admin/seed-info") + accepted = client.post("/admin/sync-trips", headers={"X-Admin-Api-Key": "admin-secret"}) + + assert rejected.status_code == 401 + assert accepted.status_code == 200 + assert accepted.json() == {"indexed_trips": 3} + + +def test_admin_driver_debug_returns_llm_and_tool_results(settings): + class DummyPrimaryProvider: + async def chat(self, messages, tools, tool_choice, temperature): + return SimpleNamespace( + content="Driver debug reply", + tool_calls=[ + SimpleNamespace( + id="call-1", + name="add_trip_by_driver", + arguments='{"departure":"A","destination":"B","departure_date":"2026-06-20","departure_time":"morning","available_seats":2,"total_seats":4,"price":30}', + ) + ], + ) + + class DummyConversation: + def _tool_registry(self, customer, sender_phone, user_mode="driver"): + class DummyRegistry: + async def execute(self, name, arguments): + return ToolResult(ok=True, data={"name": name, "arguments": arguments}) + + return DummyRegistry() + + container = SimpleNamespace( + settings=settings, + conversation=DummyConversation(), + ai=SimpleNamespace(primary=DummyPrimaryProvider()), + ) + app = create_app(settings=settings, container=container) + + with TestClient(app) as client: + response = client.post( + "/admin/driver-debug", + json={"message": "debug driver", "client_number": "967700000002"}, + headers={"X-Admin-Api-Key": "admin-secret"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["llm_response"] == "Driver debug reply" + assert payload["tool_calls"][0]["name"] == "add_trip_by_driver" + assert payload["tool_results"][0]["result"]["ok"] is True