feat: send trip cards as individual messages and resolve trip_id from replies
Browse files- Send each trip as a separate WhatsApp message with trip_id metadata
- Resolve trip_id automatically when customer replies to a trip card
- Store phone_number on customer upsert
- Parse context_message_id and phone_number from inbound messages
- Add get_message_by_whatsapp_id to repository
- Translate driver booking notification to Arabic
- Increase conversation context window to 8 messages
- app/ai/tool_schemas.py +9 -5
- app/database/supabase.py +15 -0
- app/models/domain.py +2 -0
- app/services/conversation_service.py +20 -4
- app/tools/handlers.py +58 -14
- app/whatsapp/parser.py +6 -0
- app/whatsapp/trip_selection.py +38 -0
- prompts/system_passenger.md +3 -2
- supabase/migrations/202607010001_customer_phone_number.sql +4 -0
- tests/conftest.py +11 -4
- tests/test_tools.py +1 -1
app/ai/tool_schemas.py
CHANGED
|
@@ -34,7 +34,9 @@ _SEARCH_TRIPS = {
|
|
| 34 |
"name": "search_trips",
|
| 35 |
"description": (
|
| 36 |
"Search active car or bus trips. "
|
| 37 |
-
"Use when the customer asks for travel options."
|
|
|
|
|
|
|
| 38 |
),
|
| 39 |
"parameters": {
|
| 40 |
"type": "object",
|
|
@@ -104,23 +106,25 @@ _CREATE_BOOKING_LEAD = {
|
|
| 104 |
"name": "create_booking_lead",
|
| 105 |
"description": (
|
| 106 |
"Create a pending booking lead and notify the driver. "
|
| 107 |
-
"This does not reserve seats or confirm payment."
|
|
|
|
|
|
|
| 108 |
),
|
| 109 |
"parameters": {
|
| 110 |
"type": "object",
|
| 111 |
"properties": {
|
| 112 |
-
"trip_id": {"type": "string", "description": "Selected trip ID."},
|
| 113 |
"requested_seats": {
|
| 114 |
"type": "integer",
|
| 115 |
"minimum": 1,
|
| 116 |
-
"description": "Number of seats requested
|
| 117 |
},
|
| 118 |
"notes": {
|
| 119 |
"type": "string",
|
| 120 |
"description": "Optional customer notes or pickup details.",
|
| 121 |
},
|
| 122 |
},
|
| 123 |
-
"required": [
|
| 124 |
"additionalProperties": False,
|
| 125 |
},
|
| 126 |
},
|
|
|
|
| 34 |
"name": "search_trips",
|
| 35 |
"description": (
|
| 36 |
"Search active car or bus trips. "
|
| 37 |
+
"Use when the customer asks for travel options. "
|
| 38 |
+
"Each matching trip is sent as a separate WhatsApp message. "
|
| 39 |
+
"The customer can reply to a trip card to select it."
|
| 40 |
),
|
| 41 |
"parameters": {
|
| 42 |
"type": "object",
|
|
|
|
| 106 |
"name": "create_booking_lead",
|
| 107 |
"description": (
|
| 108 |
"Create a pending booking lead and notify the driver. "
|
| 109 |
+
"This does not reserve seats or confirm payment. "
|
| 110 |
+
"If the customer replied to a trip card, trip_id is resolved automatically — "
|
| 111 |
+
"you can omit it and the system will detect which trip they meant."
|
| 112 |
),
|
| 113 |
"parameters": {
|
| 114 |
"type": "object",
|
| 115 |
"properties": {
|
| 116 |
+
"trip_id": {"type": "string", "description": "Selected trip ID. Optional if the customer replied to a trip card message."},
|
| 117 |
"requested_seats": {
|
| 118 |
"type": "integer",
|
| 119 |
"minimum": 1,
|
| 120 |
+
"description": "Number of seats requested. Defaults to 1 if not specified.",
|
| 121 |
},
|
| 122 |
"notes": {
|
| 123 |
"type": "string",
|
| 124 |
"description": "Optional customer notes or pickup details.",
|
| 125 |
},
|
| 126 |
},
|
| 127 |
+
"required": [],
|
| 128 |
"additionalProperties": False,
|
| 129 |
},
|
| 130 |
},
|
app/database/supabase.py
CHANGED
|
@@ -38,11 +38,13 @@ class SupabaseRepository:
|
|
| 38 |
remote_jid: str,
|
| 39 |
name: str | None = None,
|
| 40 |
preferred_language: str | None = None,
|
|
|
|
| 41 |
) -> dict[str, Any]:
|
| 42 |
payload = {
|
| 43 |
"remoteJid": remote_jid,
|
| 44 |
"name": name,
|
| 45 |
"preferred_language": preferred_language,
|
|
|
|
| 46 |
}
|
| 47 |
payload = {key: value for key, value in payload.items() if value is not None}
|
| 48 |
response = await (
|
|
@@ -200,6 +202,19 @@ class SupabaseRepository:
|
|
| 200 |
prior = _response_data(prior_response) or []
|
| 201 |
return list(reversed(prior)) + [current]
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
async def list_active_trips(self) -> list[dict[str, Any]]:
|
| 204 |
query = (
|
| 205 |
self.client.table("driver_trips")
|
|
|
|
| 38 |
remote_jid: str,
|
| 39 |
name: str | None = None,
|
| 40 |
preferred_language: str | None = None,
|
| 41 |
+
phone_number: str | None = None,
|
| 42 |
) -> dict[str, Any]:
|
| 43 |
payload = {
|
| 44 |
"remoteJid": remote_jid,
|
| 45 |
"name": name,
|
| 46 |
"preferred_language": preferred_language,
|
| 47 |
+
"phone_number": phone_number,
|
| 48 |
}
|
| 49 |
payload = {key: value for key, value in payload.items() if value is not None}
|
| 50 |
response = await (
|
|
|
|
| 202 |
prior = _response_data(prior_response) or []
|
| 203 |
return list(reversed(prior)) + [current]
|
| 204 |
|
| 205 |
+
async def get_message_by_whatsapp_id(
|
| 206 |
+
self,
|
| 207 |
+
whatsapp_message_id: str,
|
| 208 |
+
) -> dict[str, Any] | None:
|
| 209 |
+
response = await (
|
| 210 |
+
self.client.table("messages")
|
| 211 |
+
.select("*")
|
| 212 |
+
.eq("whatsapp_message_id", whatsapp_message_id)
|
| 213 |
+
.maybe_single()
|
| 214 |
+
.execute()
|
| 215 |
+
)
|
| 216 |
+
return _response_data(response)
|
| 217 |
+
|
| 218 |
async def list_active_trips(self) -> list[dict[str, Any]]:
|
| 219 |
query = (
|
| 220 |
self.client.table("driver_trips")
|
app/models/domain.py
CHANGED
|
@@ -15,6 +15,8 @@ class WhatsAppInboundMessage:
|
|
| 15 |
phone_number_id: str | None = None
|
| 16 |
message_type: str = "text"
|
| 17 |
interactive_reply_id: str | None = None
|
|
|
|
|
|
|
| 18 |
raw: dict[str, Any] = field(default_factory=dict)
|
| 19 |
|
| 20 |
|
|
|
|
| 15 |
phone_number_id: str | None = None
|
| 16 |
message_type: str = "text"
|
| 17 |
interactive_reply_id: str | None = None
|
| 18 |
+
context_message_id: str | None = None
|
| 19 |
+
phone_number: str | None = None
|
| 20 |
raw: dict[str, Any] = field(default_factory=dict)
|
| 21 |
|
| 22 |
|
app/services/conversation_service.py
CHANGED
|
@@ -78,24 +78,37 @@ class ConversationService:
|
|
| 78 |
customer = await self.repository.upsert_customer(
|
| 79 |
remote_jid=inbound.remoteJid,
|
| 80 |
name=inbound.profile_name,
|
|
|
|
| 81 |
)
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
current_message = await self.repository.create_message(
|
| 84 |
customer_id=str(customer["id"]),
|
| 85 |
sender_type="customer",
|
| 86 |
message=inbound.text,
|
| 87 |
whatsapp_message_id=inbound.message_id,
|
| 88 |
-
metadata=
|
| 89 |
)
|
| 90 |
|
| 91 |
context = await self.repository.get_recent_context_messages(
|
| 92 |
customer_id=str(customer["id"]),
|
| 93 |
current_message_id=str(current_message["id"]),
|
| 94 |
-
limit=
|
| 95 |
)
|
| 96 |
|
| 97 |
user_mode = _resolve_user_mode(customer)
|
| 98 |
-
registry = self._tool_registry(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
reply = await self.ai.generate_reply(
|
| 100 |
messages=self._ai_messages(context, user_mode=user_mode),
|
| 101 |
tools=get_tool_schemas(user_mode),
|
|
@@ -188,11 +201,12 @@ class ConversationService:
|
|
| 188 |
customer,
|
| 189 |
remoteJid=inbound.remoteJid,
|
| 190 |
user_mode=user_mode,
|
|
|
|
| 191 |
)
|
| 192 |
context = await self.repository.get_recent_context_messages(
|
| 193 |
customer_id=str(customer["id"]),
|
| 194 |
current_message_id=str(current_message["id"]),
|
| 195 |
-
limit=
|
| 196 |
)
|
| 197 |
messages = self._ai_messages(context, user_mode=user_mode)
|
| 198 |
messages.append({"role": "system", "content": system_note})
|
|
@@ -233,6 +247,7 @@ class ConversationService:
|
|
| 233 |
*,
|
| 234 |
remoteJid: str,
|
| 235 |
user_mode: UserMode,
|
|
|
|
| 236 |
) -> ToolRegistry:
|
| 237 |
handlers = FalsaToolHandlers(
|
| 238 |
repository=self.repository,
|
|
@@ -241,6 +256,7 @@ class ConversationService:
|
|
| 241 |
customer=customer,
|
| 242 |
remoteJid=remoteJid,
|
| 243 |
embedding_model=self.settings.jina_embedding_model,
|
|
|
|
| 244 |
)
|
| 245 |
registry = ToolRegistry()
|
| 246 |
for tool_name in _TOOLS_BY_MODE[user_mode]:
|
|
|
|
| 78 |
customer = await self.repository.upsert_customer(
|
| 79 |
remote_jid=inbound.remoteJid,
|
| 80 |
name=inbound.profile_name,
|
| 81 |
+
phone_number=inbound.phone_number,
|
| 82 |
)
|
| 83 |
|
| 84 |
+
metadata: dict[str, Any] = {
|
| 85 |
+
"whatsapp": inbound.raw,
|
| 86 |
+
"timestamp": inbound.timestamp,
|
| 87 |
+
}
|
| 88 |
+
if inbound.context_message_id:
|
| 89 |
+
metadata["context_message_id"] = inbound.context_message_id
|
| 90 |
+
|
| 91 |
current_message = await self.repository.create_message(
|
| 92 |
customer_id=str(customer["id"]),
|
| 93 |
sender_type="customer",
|
| 94 |
message=inbound.text,
|
| 95 |
whatsapp_message_id=inbound.message_id,
|
| 96 |
+
metadata=metadata,
|
| 97 |
)
|
| 98 |
|
| 99 |
context = await self.repository.get_recent_context_messages(
|
| 100 |
customer_id=str(customer["id"]),
|
| 101 |
current_message_id=str(current_message["id"]),
|
| 102 |
+
limit=8,
|
| 103 |
)
|
| 104 |
|
| 105 |
user_mode = _resolve_user_mode(customer)
|
| 106 |
+
registry = self._tool_registry(
|
| 107 |
+
customer,
|
| 108 |
+
remoteJid=inbound.remoteJid,
|
| 109 |
+
user_mode=user_mode,
|
| 110 |
+
current_message=current_message,
|
| 111 |
+
)
|
| 112 |
reply = await self.ai.generate_reply(
|
| 113 |
messages=self._ai_messages(context, user_mode=user_mode),
|
| 114 |
tools=get_tool_schemas(user_mode),
|
|
|
|
| 201 |
customer,
|
| 202 |
remoteJid=inbound.remoteJid,
|
| 203 |
user_mode=user_mode,
|
| 204 |
+
current_message=current_message,
|
| 205 |
)
|
| 206 |
context = await self.repository.get_recent_context_messages(
|
| 207 |
customer_id=str(customer["id"]),
|
| 208 |
current_message_id=str(current_message["id"]),
|
| 209 |
+
limit=8,
|
| 210 |
)
|
| 211 |
messages = self._ai_messages(context, user_mode=user_mode)
|
| 212 |
messages.append({"role": "system", "content": system_note})
|
|
|
|
| 247 |
*,
|
| 248 |
remoteJid: str,
|
| 249 |
user_mode: UserMode,
|
| 250 |
+
current_message: dict[str, Any] | None = None,
|
| 251 |
) -> ToolRegistry:
|
| 252 |
handlers = FalsaToolHandlers(
|
| 253 |
repository=self.repository,
|
|
|
|
| 256 |
customer=customer,
|
| 257 |
remoteJid=remoteJid,
|
| 258 |
embedding_model=self.settings.jina_embedding_model,
|
| 259 |
+
current_message=current_message,
|
| 260 |
)
|
| 261 |
registry = ToolRegistry()
|
| 262 |
for tool_name in _TOOLS_BY_MODE[user_mode]:
|
app/tools/handlers.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
|
|
| 1 |
from decimal import Decimal, InvalidOperation
|
| 2 |
from typing import Any
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from app.database.supabase import SupabaseRepository
|
| 5 |
from app.models.domain import ToolResult
|
| 6 |
from app.services.embedding_service import JinaEmbeddingService
|
|
@@ -15,7 +18,7 @@ from app.utils.departure import (
|
|
| 15 |
trip_satisfies_departure_request,
|
| 16 |
)
|
| 17 |
from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
|
| 18 |
-
from app.whatsapp.trip_selection import
|
| 19 |
|
| 20 |
|
| 21 |
class FalsaToolHandlers:
|
|
@@ -28,6 +31,7 @@ class FalsaToolHandlers:
|
|
| 28 |
customer: dict[str, Any],
|
| 29 |
remoteJid: str,
|
| 30 |
embedding_model: str,
|
|
|
|
| 31 |
) -> None:
|
| 32 |
self.repository = repository
|
| 33 |
self.embeddings = embeddings
|
|
@@ -35,6 +39,20 @@ class FalsaToolHandlers:
|
|
| 35 |
self.customer = customer
|
| 36 |
self.remoteJid = remoteJid
|
| 37 |
self.embedding_model = embedding_model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
async def about_falsa(self, arguments: dict[str, Any]) -> ToolResult:
|
| 40 |
query = str(arguments.get("query") or "").strip()
|
|
@@ -143,16 +161,38 @@ class FalsaToolHandlers:
|
|
| 143 |
)
|
| 144 |
])
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return ToolResult(
|
| 147 |
ok=True,
|
| 148 |
data={
|
| 149 |
-
"
|
| 150 |
-
"count": len(filtered[:5]),
|
| 151 |
"alternate_alert": alternate_alert,
|
|
|
|
| 152 |
"note": (
|
| 153 |
"No active matching trips were found."
|
| 154 |
-
if not
|
| 155 |
-
else
|
|
|
|
| 156 |
),
|
| 157 |
},
|
| 158 |
)
|
|
@@ -163,7 +203,9 @@ class FalsaToolHandlers:
|
|
| 163 |
notes = _optional_string(arguments.get("notes"))
|
| 164 |
|
| 165 |
if not trip_id:
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
if requested_seats < 1:
|
| 168 |
return ToolResult(ok=False, data={}, error="requested_seats must be at least 1")
|
| 169 |
|
|
@@ -186,6 +228,7 @@ class FalsaToolHandlers:
|
|
| 186 |
notes=notes,
|
| 187 |
)
|
| 188 |
|
|
|
|
| 189 |
notification_status = "sent"
|
| 190 |
notification_error = None
|
| 191 |
try:
|
|
@@ -194,6 +237,7 @@ class FalsaToolHandlers:
|
|
| 194 |
driver_remote_jid = driver_customer.get("remoteJid") or driver_record.get("remoteJid")
|
| 195 |
if not driver_remote_jid:
|
| 196 |
raise WhatsAppClientError("Driver remoteJid is missing")
|
|
|
|
| 197 |
await self.whatsapp.send_text(
|
| 198 |
driver_remote_jid,
|
| 199 |
_driver_notification_text(
|
|
@@ -220,6 +264,7 @@ class FalsaToolHandlers:
|
|
| 220 |
"status": "pending",
|
| 221 |
"driver_notification_status": notification_status,
|
| 222 |
"driver_notification_error": notification_error,
|
|
|
|
| 223 |
"message": "Booking lead created. Seats are not reserved until confirmed.",
|
| 224 |
},
|
| 225 |
)
|
|
@@ -1033,12 +1078,11 @@ def _driver_notification_text(
|
|
| 1033 |
notes: str | None,
|
| 1034 |
) -> str:
|
| 1035 |
return (
|
| 1036 |
-
"
|
| 1037 |
-
f"
|
| 1038 |
-
f"
|
| 1039 |
-
f"
|
| 1040 |
-
f"
|
| 1041 |
-
f"
|
| 1042 |
-
|
| 1043 |
-
"Status: pending confirmation"
|
| 1044 |
)
|
|
|
|
| 1 |
+
import logging
|
| 2 |
from decimal import Decimal, InvalidOperation
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
from app.database.supabase import SupabaseRepository
|
| 8 |
from app.models.domain import ToolResult
|
| 9 |
from app.services.embedding_service import JinaEmbeddingService
|
|
|
|
| 18 |
trip_satisfies_departure_request,
|
| 19 |
)
|
| 20 |
from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
|
| 21 |
+
from app.whatsapp.trip_selection import build_trip_selection_text, format_trip_card
|
| 22 |
|
| 23 |
|
| 24 |
class FalsaToolHandlers:
|
|
|
|
| 31 |
customer: dict[str, Any],
|
| 32 |
remoteJid: str,
|
| 33 |
embedding_model: str,
|
| 34 |
+
current_message: dict[str, Any] | None = None,
|
| 35 |
) -> None:
|
| 36 |
self.repository = repository
|
| 37 |
self.embeddings = embeddings
|
|
|
|
| 39 |
self.customer = customer
|
| 40 |
self.remoteJid = remoteJid
|
| 41 |
self.embedding_model = embedding_model
|
| 42 |
+
self.current_message = current_message
|
| 43 |
+
|
| 44 |
+
async def _resolve_trip_id_from_reply(self) -> str | None:
|
| 45 |
+
if not self.current_message:
|
| 46 |
+
return None
|
| 47 |
+
metadata = self.current_message.get("metadata") or {}
|
| 48 |
+
context_message_id = metadata.get("context_message_id")
|
| 49 |
+
if not context_message_id:
|
| 50 |
+
return None
|
| 51 |
+
original = await self.repository.get_message_by_whatsapp_id(context_message_id)
|
| 52 |
+
if not original:
|
| 53 |
+
return None
|
| 54 |
+
original_meta = original.get("metadata") or {}
|
| 55 |
+
return original_meta.get("trip_id")
|
| 56 |
|
| 57 |
async def about_falsa(self, arguments: dict[str, Any]) -> ToolResult:
|
| 58 |
query = str(arguments.get("query") or "").strip()
|
|
|
|
| 161 |
)
|
| 162 |
])
|
| 163 |
|
| 164 |
+
top_trips = filtered[:5]
|
| 165 |
+
|
| 166 |
+
if top_trips:
|
| 167 |
+
for trip_summary in top_trips:
|
| 168 |
+
trip_id = trip_summary["trip_id"]
|
| 169 |
+
trip = next((t for t in trips if (t.get("trip_id") or t.get("id")) == trip_id), {})
|
| 170 |
+
card = format_trip_card(trip)
|
| 171 |
+
try:
|
| 172 |
+
resp = await self.whatsapp.send_text(self.remoteJid, card)
|
| 173 |
+
wam_id = resp.get("messages", [{}])[0].get("id")
|
| 174 |
+
if wam_id:
|
| 175 |
+
await self.repository.create_message(
|
| 176 |
+
customer_id=str(self.customer["id"]),
|
| 177 |
+
sender_type="assistant",
|
| 178 |
+
message=card,
|
| 179 |
+
whatsapp_message_id=wam_id,
|
| 180 |
+
metadata={"trip_id": trip_id, "type": "trip_card"},
|
| 181 |
+
)
|
| 182 |
+
except WhatsAppClientError:
|
| 183 |
+
logger.warning("Failed to send trip card for trip %s", trip_id)
|
| 184 |
+
|
| 185 |
return ToolResult(
|
| 186 |
ok=True,
|
| 187 |
data={
|
| 188 |
+
"count": len(top_trips),
|
|
|
|
| 189 |
"alternate_alert": alternate_alert,
|
| 190 |
+
"sent_as_messages": bool(top_trips),
|
| 191 |
"note": (
|
| 192 |
"No active matching trips were found."
|
| 193 |
+
if not top_trips
|
| 194 |
+
else "Trips were sent as separate WhatsApp messages. "
|
| 195 |
+
"Ask the user to reply to a trip card to select it."
|
| 196 |
),
|
| 197 |
},
|
| 198 |
)
|
|
|
|
| 203 |
notes = _optional_string(arguments.get("notes"))
|
| 204 |
|
| 205 |
if not trip_id:
|
| 206 |
+
trip_id = await self._resolve_trip_id_from_reply()
|
| 207 |
+
if not trip_id:
|
| 208 |
+
return ToolResult(ok=False, data={}, error="trip_id is required. Ask the user to reply to a trip card message or provide the trip ID.")
|
| 209 |
if requested_seats < 1:
|
| 210 |
return ToolResult(ok=False, data={}, error="requested_seats must be at least 1")
|
| 211 |
|
|
|
|
| 228 |
notes=notes,
|
| 229 |
)
|
| 230 |
|
| 231 |
+
driver_phone: str | None = None
|
| 232 |
notification_status = "sent"
|
| 233 |
notification_error = None
|
| 234 |
try:
|
|
|
|
| 237 |
driver_remote_jid = driver_customer.get("remoteJid") or driver_record.get("remoteJid")
|
| 238 |
if not driver_remote_jid:
|
| 239 |
raise WhatsAppClientError("Driver remoteJid is missing")
|
| 240 |
+
driver_phone = driver_remote_jid.split("@")[0]
|
| 241 |
await self.whatsapp.send_text(
|
| 242 |
driver_remote_jid,
|
| 243 |
_driver_notification_text(
|
|
|
|
| 264 |
"status": "pending",
|
| 265 |
"driver_notification_status": notification_status,
|
| 266 |
"driver_notification_error": notification_error,
|
| 267 |
+
"driver_phone": driver_phone,
|
| 268 |
"message": "Booking lead created. Seats are not reserved until confirmed.",
|
| 269 |
},
|
| 270 |
)
|
|
|
|
| 1078 |
notes: str | None,
|
| 1079 |
) -> str:
|
| 1080 |
return (
|
| 1081 |
+
"🔔 حجز جديد في فلسا\n"
|
| 1082 |
+
f"العميل: {customer.get('name') or 'عميل جديد'}\n"
|
| 1083 |
+
f"الرحلة: {trip.get('departure')} ← {trip.get('destination')}\n"
|
| 1084 |
+
f"التاريخ: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n"
|
| 1085 |
+
f"المقاعد المطلوبة: {requested_seats}\n"
|
| 1086 |
+
f"ملاحظات: {notes or 'لا يوجد'}\n"
|
| 1087 |
+
"الحالة: قيد الانتظار"
|
|
|
|
| 1088 |
)
|
app/whatsapp/parser.py
CHANGED
|
@@ -24,6 +24,8 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
|
|
| 24 |
|
| 25 |
remoteJid = message.get("from")
|
| 26 |
message_id = message.get("id")
|
|
|
|
|
|
|
| 27 |
|
| 28 |
text = message.get("text", {}).get("body")
|
| 29 |
if not remoteJid or not message_id or text is None:
|
|
@@ -42,6 +44,8 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
|
|
| 42 |
profile_name=contacts_by_wa_id.get(remoteJid),
|
| 43 |
phone_number_id=phone_number_id,
|
| 44 |
message_type="text",
|
|
|
|
|
|
|
| 45 |
raw=message,
|
| 46 |
)
|
| 47 |
)
|
|
@@ -67,6 +71,8 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
|
|
| 67 |
phone_number_id=phone_number_id,
|
| 68 |
message_type="interactive",
|
| 69 |
interactive_reply_id=reply_id,
|
|
|
|
|
|
|
| 70 |
raw=message,
|
| 71 |
)
|
| 72 |
)
|
|
|
|
| 24 |
|
| 25 |
remoteJid = message.get("from")
|
| 26 |
message_id = message.get("id")
|
| 27 |
+
phone_number = message.get("remote_jid_alt")
|
| 28 |
+
context_message_id = message.get("context", {}).get("id")
|
| 29 |
|
| 30 |
text = message.get("text", {}).get("body")
|
| 31 |
if not remoteJid or not message_id or text is None:
|
|
|
|
| 44 |
profile_name=contacts_by_wa_id.get(remoteJid),
|
| 45 |
phone_number_id=phone_number_id,
|
| 46 |
message_type="text",
|
| 47 |
+
context_message_id=context_message_id,
|
| 48 |
+
phone_number=phone_number,
|
| 49 |
raw=message,
|
| 50 |
)
|
| 51 |
)
|
|
|
|
| 71 |
phone_number_id=phone_number_id,
|
| 72 |
message_type="interactive",
|
| 73 |
interactive_reply_id=reply_id,
|
| 74 |
+
context_message_id=context_message_id,
|
| 75 |
+
phone_number=phone_number,
|
| 76 |
raw=message,
|
| 77 |
)
|
| 78 |
)
|
app/whatsapp/trip_selection.py
CHANGED
|
@@ -76,6 +76,44 @@ def build_trip_selection_text(
|
|
| 76 |
return "\n".join(lines)
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def _trip_row_description(trip: dict[str, Any]) -> str:
|
| 80 |
departure = str(trip.get("departure") or "")
|
| 81 |
destination = str(trip.get("destination") or "")
|
|
|
|
| 76 |
return "\n".join(lines)
|
| 77 |
|
| 78 |
|
| 79 |
+
def format_trip_card(trip: dict[str, Any]) -> str:
|
| 80 |
+
departure = str(trip.get("departure") or "")
|
| 81 |
+
destination = str(trip.get("destination") or "")
|
| 82 |
+
parsed_date = trip_departure_date(trip)
|
| 83 |
+
date_text = parsed_date.isoformat() if parsed_date else ""
|
| 84 |
+
bucket = trip_departure_bucket(trip)
|
| 85 |
+
bucket_text = _BUCKET_LABELS.get(bucket, "") if bucket else ""
|
| 86 |
+
|
| 87 |
+
driver = _first_dict_value(trip.get("drivers")) or {}
|
| 88 |
+
car = _first_dict_value(trip.get("driver_cars")) or {}
|
| 89 |
+
driver_name = driver.get("name") or trip.get("driver_name") or ""
|
| 90 |
+
car_type = car.get("car_type") or trip.get("car_type") or ""
|
| 91 |
+
|
| 92 |
+
available = trip.get("available_seats") or 0
|
| 93 |
+
total = trip.get("total_seats") or 0
|
| 94 |
+
price = trip.get("price") or ""
|
| 95 |
+
|
| 96 |
+
lines = [
|
| 97 |
+
"─" * 14,
|
| 98 |
+
f"من: {departure} ← إلى: {destination}",
|
| 99 |
+
f"التاريخ: {date_text} | الوقت: {bucket_text}",
|
| 100 |
+
f"المقاعد: {available} من {total} متاحة",
|
| 101 |
+
f"السعر: {price}" if price else "",
|
| 102 |
+
f"السيارة: {car_type}" if car_type else "",
|
| 103 |
+
f"السائق: {driver_name}" if driver_name else "",
|
| 104 |
+
"─" * 14,
|
| 105 |
+
]
|
| 106 |
+
return "\n".join(line for line in lines if line)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _first_dict_value(value: Any) -> dict[str, Any] | None:
|
| 110 |
+
if isinstance(value, list):
|
| 111 |
+
return value[0] if value else None
|
| 112 |
+
if isinstance(value, dict):
|
| 113 |
+
return value
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
def _trip_row_description(trip: dict[str, Any]) -> str:
|
| 118 |
departure = str(trip.get("departure") or "")
|
| 119 |
destination = str(trip.get("destination") or "")
|
prompts/system_passenger.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
- search_trips for travel options. Include vector_query_text (natural-language search phrase).
|
| 2 |
-
-
|
| 3 |
-
- create_booking_lead only after trip + seat count selected.
|
| 4 |
- Ask short follow-up if details missing.
|
| 5 |
- Booking leads are pending — seats not reserved.
|
|
|
|
| 6 |
- To drive: switch_to_driver. If no account: name -> create_driver_account -> switch_to_driver. Never before account exists.
|
|
|
|
| 1 |
- search_trips for travel options. Include vector_query_text (natural-language search phrase).
|
| 2 |
+
- When search_trips returns matches, trips are sent as separate WhatsApp messages. The user can reply to a trip card to select it — the system will detect which trip they meant automatically, without needing a trip_id or short_id.
|
| 3 |
+
- create_booking_lead only after trip + seat count selected. If the user replied to a trip card, trip_id is resolved automatically and you can omit it.
|
| 4 |
- Ask short follow-up if details missing.
|
| 5 |
- Booking leads are pending — seats not reserved.
|
| 6 |
+
- After successful create_booking_lead, tell the passenger the driver's phone number from driver_phone so they can contact the driver directly.
|
| 7 |
- To drive: switch_to_driver. If no account: name -> create_driver_account -> switch_to_driver. Never before account exists.
|
supabase/migrations/202607010001_customer_phone_number.sql
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
alter table public.customers add column if not exists phone_number text;
|
| 2 |
+
|
| 3 |
+
create index if not exists idx_customers_phone_number
|
| 4 |
+
on public.customers(phone_number);
|
tests/conftest.py
CHANGED
|
@@ -117,23 +117,30 @@ class FakeRepository:
|
|
| 117 |
async def upsert_customer(
|
| 118 |
self,
|
| 119 |
*,
|
| 120 |
-
remote_jid: str,
|
| 121 |
name: str | None = None,
|
| 122 |
preferred_language: str | None = None,
|
|
|
|
| 123 |
) -> dict[str, Any]:
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
| 125 |
if customer is None:
|
| 126 |
customer = {
|
| 127 |
"id": f"cust-{len(self.customers_by_remote_jid) + 1}",
|
| 128 |
-
"remoteJid":
|
| 129 |
"name": name,
|
| 130 |
"preferred_language": preferred_language,
|
|
|
|
| 131 |
"user_mode": None,
|
| 132 |
"session_data": {},
|
| 133 |
}
|
| 134 |
-
self.customers_by_remote_jid[
|
| 135 |
elif name:
|
| 136 |
customer["name"] = name
|
|
|
|
|
|
|
| 137 |
return customer
|
| 138 |
|
| 139 |
async def update_customer_user_mode(
|
|
|
|
| 117 |
async def upsert_customer(
|
| 118 |
self,
|
| 119 |
*,
|
| 120 |
+
remote_jid: str | None = None,
|
| 121 |
name: str | None = None,
|
| 122 |
preferred_language: str | None = None,
|
| 123 |
+
phone_number: str | None = None,
|
| 124 |
) -> dict[str, Any]:
|
| 125 |
+
jid = remote_jid or phone_number
|
| 126 |
+
if not jid:
|
| 127 |
+
raise ValueError("remote_jid or phone_number is required")
|
| 128 |
+
customer = self.customers_by_remote_jid.get(jid)
|
| 129 |
if customer is None:
|
| 130 |
customer = {
|
| 131 |
"id": f"cust-{len(self.customers_by_remote_jid) + 1}",
|
| 132 |
+
"remoteJid": jid,
|
| 133 |
"name": name,
|
| 134 |
"preferred_language": preferred_language,
|
| 135 |
+
"phone_number": phone_number,
|
| 136 |
"user_mode": None,
|
| 137 |
"session_data": {},
|
| 138 |
}
|
| 139 |
+
self.customers_by_remote_jid[jid] = customer
|
| 140 |
elif name:
|
| 141 |
customer["name"] = name
|
| 142 |
+
if phone_number:
|
| 143 |
+
customer["phone_number"] = phone_number
|
| 144 |
return customer
|
| 145 |
|
| 146 |
async def update_customer_user_mode(
|
tests/test_tools.py
CHANGED
|
@@ -17,7 +17,7 @@ def make_handlers(
|
|
| 17 |
embeddings=embeddings or FakeEmbeddings(),
|
| 18 |
whatsapp=whatsapp or FakeWhatsApp(),
|
| 19 |
customer=customer or {"id": "cust-1", "remoteJid": sender_phone},
|
| 20 |
-
|
| 21 |
embedding_model="jina-embeddings-v5-text-small",
|
| 22 |
)
|
| 23 |
|
|
|
|
| 17 |
embeddings=embeddings or FakeEmbeddings(),
|
| 18 |
whatsapp=whatsapp or FakeWhatsApp(),
|
| 19 |
customer=customer or {"id": "cust-1", "remoteJid": sender_phone},
|
| 20 |
+
remoteJid=sender_phone,
|
| 21 |
embedding_model="jina-embeddings-v5-text-small",
|
| 22 |
)
|
| 23 |
|