codeBOKER commited on
Commit
dc8cb49
·
1 Parent(s): d4f6fe7

feature: modify and delete driver trips

Browse files
.github/prompts/plan-mergeDriverChecksAddTripTools.prompt.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Plan: Merge driver checks & add trip modify/delete tools
2
+
3
+ TL;DR: Merge `check_driver_info` and `check_driver_trips` into a single `check_driver_dashboard` tool that returns driver profile, registered vehicles, and upcoming trips. Add `update_driver_trip` and `delete_driver_trip` tools (prefer soft-cancel) with schemas, handlers, DB methods, indexing calls, and tests.
4
+
5
+ **Steps**
6
+ 1. Update schemas (`app/ai/tool_schemas.py`):
7
+ - Add `_CHECK_DRIVER_DASHBOARD` for `check_driver_dashboard` (no parameters).
8
+ - Add `_UPDATE_DRIVER_TRIP` and `_DELETE_DRIVER_TRIP` for `update_driver_trip` and `delete_driver_trip`.
9
+ - Replace `check_driver_info` and `check_driver_trips` entries in `_TOOLS_BY_MODE["driver"]` with `check_driver_dashboard`, and add the two new tool names to the driver mode list.
10
+
11
+ 2. Implement merged handler (`app/tools/handlers.py`):
12
+ - Add `check_driver_dashboard(self, arguments)` that:
13
+ - Resolves driver by phone (existing helper), fetches registered vehicles and upcoming trips (reuse `list_driver_trips`), and returns a structured JSON payload combining profile, vehicles, and trips summaries.
14
+ - Place the method next to existing `check_driver_info`/`check_driver_trips` logic and remove or keep old helpers as internal functions if useful.
15
+
16
+ 3. Implement update/delete handlers (`app/tools/handlers.py`):
17
+ - `update_driver_trip(self, arguments)`:
18
+ - Validate `trip_id` and driver ownership (`get_driver_by_phone` + `get_trip_by_id`).
19
+ - Accept only allowed fields: `departure`, `destination`, `departure_date`, `departure_time`, `vehicle_type`, `available_seats`, `total_seats`, `price`.
20
+ - Call repository `update_driver_trip(trip_id, updates)` and reindex the trip.
21
+ - Return the updated trip record.
22
+ - `delete_driver_trip(self, arguments)`:
23
+ - Validate ownership.
24
+ - Prefer soft-cancel: call repository `cancel_driver_trip(trip_id)` that sets `status = "cancelled"`.
25
+ - Remove/unindex from vector index and return cancelled trip.
26
+
27
+ 4. Add DB methods (`app/database/supabase.py`):
28
+ - `async def update_driver_trip(self, trip_id: str, updates: dict) -> dict` — update allowed columns and return the updated row.
29
+ - `async def cancel_driver_trip(self, trip_id: str) -> dict` — set `status` to `cancelled` (soft delete) and return updated row.
30
+ - Follow existing style/patterns used by `create_driver_trip` and `get_trip_by_id`.
31
+
32
+ 5. Indexing updates (`app/services/trip_indexing.py`):
33
+ - Add `async def unindex_trip(trip_id: str)` or `delete_from_index(trip_id)` to remove vectors.
34
+ - Ensure `update_driver_trip` calls `index_trip` after DB update and `cancel_driver_trip` calls `unindex_trip`.
35
+
36
+ 6. Registration / wiring:
37
+ - No registry code changes required: `conversation_service._tool_registry()` registers tools from `_TOOLS_BY_MODE` automatically once names are updated.
38
+
39
+ 7. Tests:
40
+ - Update `tests/conftest.py` FakeRepository to implement `update_driver_trip` and `cancel_driver_trip` behaviors.
41
+ - Replace or update tests that call `check_driver_info` / `check_driver_trips` to assert the merged `check_driver_dashboard` behavior in `tests/test_tools.py`.
42
+ - Add tests for `update_driver_trip` and `delete_driver_trip` mirroring existing handler test patterns.
43
+
44
+ 8. Docs & prompts:
45
+ - Update `prompts/system_driver.md` to document the merged tool and the new modify/delete tools so the model calls them appropriately.
46
+
47
+ **Verification**
48
+ 1. Run unit tests: `pytest -q` and ensure all tests pass (focus on `tests/test_tools.py`).
49
+ 2. Run targeted tests: `pytest -q tests/test_tools.py::test_check_driver_dashboard` and new tests for update/delete.
50
+ 3. Manual local sanity: call the tool handlers via the existing orchestrator test harness in `tests/test_ai_orchestrator.py`.
51
+ 4. Verify indexing: after update, trip appears in index with new values; after cancel, it's unindexed.
52
+
53
+ **Decisions & assumptions**
54
+ - Use soft-cancel (`status = "cancelled"`) instead of hard delete to preserve audit/history and avoid breaking references.
55
+ - Require driver ownership verification before allowing update/delete.
56
+ - Limit updatable fields to the set listed above; price/seats allowed, but payments/refunds are out of scope.
57
+
58
+ **Further considerations**
59
+ 1. Notifications: consider notifying booked passengers when a trip is modified/cancelled (out of scope for this change).
60
+ 2. Concurrency: consider optimistic locking/versioning if concurrent edits are possible.
61
+ 3. Permissions & audit logs: ensure handlers record who made the change (phone/driver id) for traceability.
app/ai/orchestrator.py CHANGED
@@ -89,10 +89,9 @@ class AIOrchestrator:
89
  if content:
90
  return content
91
  raise ProviderError(f"{provider.name} returned an empty response")
92
- logger.info("----++-----"+str(response))
93
  working_messages.append(_assistant_tool_message(response))
94
  for tool_call in response.tool_calls:
95
- # logger.warning("++++++++"+str(tool_call)+ "&&&"+ str(registry))
96
  result = await _execute_tool_call(registry, tool_call)
97
  logger.warning("+++++++++++++"+str(result))
98
  working_messages.append(
 
89
  if content:
90
  return content
91
  raise ProviderError(f"{provider.name} returned an empty response")
 
92
  working_messages.append(_assistant_tool_message(response))
93
  for tool_call in response.tool_calls:
94
+ logger.warning("++++++++"+str(tool_call)+ "&&&"+ str(registry))
95
  result = await _execute_tool_call(registry, tool_call)
96
  logger.warning("+++++++++++++"+str(result))
97
  working_messages.append(
app/ai/tool_schemas.py CHANGED
@@ -273,6 +273,151 @@ _ADD_TRIP_BY_DRIVER = {
273
  },
274
  }
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  _SWITCH_TO_DRIVER = {
277
  "type": "function",
278
  "function": {
@@ -320,6 +465,10 @@ _TOOL_SCHEMAS: dict[str, dict[str, Any]] = {
320
  "check_driver_trips": _CHECK_DRIVER_TRIPS,
321
  "add_driver_car": _ADD_DRIVER_CAR,
322
  "add_trip_by_driver": _ADD_TRIP_BY_DRIVER,
 
 
 
 
323
  "switch_to_driver": _SWITCH_TO_DRIVER,
324
  "switch_to_passenger": _SWITCH_TO_PASSENGER,
325
  }
@@ -337,6 +486,10 @@ _TOOLS_BY_MODE: dict[UserMode, list[str]] = {
337
  "check_driver_trips",
338
  "add_driver_car",
339
  "add_trip_by_driver",
 
 
 
 
340
  "switch_to_passenger",
341
  ],
342
  "passenger": [
 
273
  },
274
  }
275
 
276
+ _INITIATE_TRIP_ACTION = {
277
+ "type": "function",
278
+ "function": {
279
+ "name": "initiate_trip_action",
280
+ "description": (
281
+ "Start delete or modify flow for one of the driver's upcoming trips. "
282
+ "The system sends a numbered list of active trips to the driver in plain text."
283
+ ),
284
+ "parameters": {
285
+ "type": "object",
286
+ "properties": {
287
+ "action_type": {
288
+ "type": "string",
289
+ "enum": ["DELETE", "MODIFY"],
290
+ "description": "Whether the driver wants to cancel or edit a trip.",
291
+ },
292
+ "travel_date": {
293
+ "type": "string",
294
+ "description": (
295
+ "Optional trip date filter as YYYY-MM-DD in Asia/Aden when the "
296
+ "driver mentioned a specific day such as today or tomorrow."
297
+ ),
298
+ },
299
+ "travel_time": {
300
+ "type": "string",
301
+ "enum": ["صباح", "ظهر", "ليل"],
302
+ "description": "Optional departure time bucket filter in Arabic.",
303
+ },
304
+ },
305
+ "required": ["action_type"],
306
+ "additionalProperties": False,
307
+ },
308
+ },
309
+ }
310
+
311
+ _UPDATE_TRIP_FIELD = {
312
+ "type": "function",
313
+ "function": {
314
+ "name": "update_trip_field",
315
+ "description": (
316
+ "Update one field on the trip the driver selected for editing. "
317
+ "trip_id is resolved automatically from the active edit session."
318
+ ),
319
+ "parameters": {
320
+ "type": "object",
321
+ "properties": {
322
+ "field": {
323
+ "type": "string",
324
+ "enum": [
325
+ "departure",
326
+ "destination",
327
+ "departure_date",
328
+ "departure_time",
329
+ "pickup_time",
330
+ "vehicle_type",
331
+ "available_seats",
332
+ "total_seats",
333
+ "price",
334
+ ],
335
+ "description": "Trip field to update.",
336
+ },
337
+ "value": {
338
+ "type": "string",
339
+ "description": (
340
+ "New value for the field. Use YYYY-MM-DD for dates, HH:MM for "
341
+ "pickup_time, Arabic city names for route fields, and English "
342
+ "digits for seats and price."
343
+ ),
344
+ },
345
+ },
346
+ "required": ["field", "value"],
347
+ "additionalProperties": False,
348
+ },
349
+ },
350
+ }
351
+
352
+ _DELETE_TRIP_BY_NUMBER = {
353
+ "type": "function",
354
+ "function": {
355
+ "name": "delete_trip_by_number",
356
+ "description": (
357
+ "Cancel an upcoming active trip using the driver-visible trip number. "
358
+ "Trip numbers are assigned in ascending order from oldest to newest."
359
+ ),
360
+ "parameters": {
361
+ "type": "object",
362
+ "properties": {
363
+ "trip_number": {
364
+ "type": "integer",
365
+ "minimum": 1,
366
+ "description": "The one-based number of the trip from the driver trip list.",
367
+ },
368
+ },
369
+ "required": ["trip_number"],
370
+ "additionalProperties": False,
371
+ },
372
+ },
373
+ }
374
+
375
+ _MODIFY_TRIP_BY_NUMBER = {
376
+ "type": "function",
377
+ "function": {
378
+ "name": "modify_trip_by_number",
379
+ "description": (
380
+ "Modify one field of an active driver trip using the driver-visible trip number. "
381
+ "Trip numbers are assigned in ascending order from oldest to newest."
382
+ ),
383
+ "parameters": {
384
+ "type": "object",
385
+ "properties": {
386
+ "trip_number": {
387
+ "type": "integer",
388
+ "minimum": 1,
389
+ "description": "The one-based number of the trip from the driver trip list.",
390
+ },
391
+ "field": {
392
+ "type": "string",
393
+ "enum": [
394
+ "departure",
395
+ "destination",
396
+ "departure_date",
397
+ "departure_time",
398
+ "pickup_time",
399
+ "vehicle_type",
400
+ "available_seats",
401
+ "total_seats",
402
+ "price",
403
+ ],
404
+ "description": "The trip field to update.",
405
+ },
406
+ "value": {
407
+ "type": "string",
408
+ "description": (
409
+ "New value for the field. Use YYYY-MM-DD for dates, HH:MM for "
410
+ "pickup_time, Arabic city names for route fields, and English "
411
+ "digits for seats and price."
412
+ ),
413
+ },
414
+ },
415
+ "required": ["trip_number", "field", "value"],
416
+ "additionalProperties": False,
417
+ },
418
+ },
419
+ }
420
+
421
  _SWITCH_TO_DRIVER = {
422
  "type": "function",
423
  "function": {
 
465
  "check_driver_trips": _CHECK_DRIVER_TRIPS,
466
  "add_driver_car": _ADD_DRIVER_CAR,
467
  "add_trip_by_driver": _ADD_TRIP_BY_DRIVER,
468
+ "initiate_trip_action": _INITIATE_TRIP_ACTION,
469
+ "update_trip_field": _UPDATE_TRIP_FIELD,
470
+ "delete_trip_by_number": _DELETE_TRIP_BY_NUMBER,
471
+ "modify_trip_by_number": _MODIFY_TRIP_BY_NUMBER,
472
  "switch_to_driver": _SWITCH_TO_DRIVER,
473
  "switch_to_passenger": _SWITCH_TO_PASSENGER,
474
  }
 
486
  "check_driver_trips",
487
  "add_driver_car",
488
  "add_trip_by_driver",
489
+ "delete_trip_by_number",
490
+ "modify_trip_by_number",
491
+ "initiate_trip_action",
492
+ "update_trip_field",
493
  "switch_to_passenger",
494
  ],
495
  "passenger": [
app/api/routes.py CHANGED
@@ -137,7 +137,7 @@ async def llm_tool_call_debug(
137
  tool_results: list[dict[str, Any]] = []
138
  registry = container.conversation._tool_registry(
139
  {"id": "admin-debug", "remoteJid": "", "name": "admin-debug"},
140
- sender_phone="admin-debug",
141
  user_mode="passenger",
142
  )
143
 
@@ -187,12 +187,12 @@ async def driver_service_debug(
187
  tool_results: list[dict[str, Any]] = []
188
  customer = {
189
  "id": "debug",
190
- "remoteJid": request.client_number,
191
  "name": "driver-debug",
192
  }
193
  registry = container.conversation._tool_registry(
194
  customer,
195
- sender_phone=request.client_number,
196
  user_mode="driver",
197
  )
198
 
 
137
  tool_results: list[dict[str, Any]] = []
138
  registry = container.conversation._tool_registry(
139
  {"id": "admin-debug", "remoteJid": "", "name": "admin-debug"},
140
+ remoteJid="admin-debug",
141
  user_mode="passenger",
142
  )
143
 
 
187
  tool_results: list[dict[str, Any]] = []
188
  customer = {
189
  "id": "debug",
190
+ "remoteJid": "823904580238",
191
  "name": "driver-debug",
192
  }
193
  registry = container.conversation._tool_registry(
194
  customer,
195
+ remoteJid=request.client_number,
196
  user_mode="driver",
197
  )
198
 
app/database/supabase.py CHANGED
@@ -83,6 +83,60 @@ class SupabaseRepository:
83
  data = _response_data(response)
84
  return data[0] if isinstance(data, list) else data
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  async def message_exists(self, whatsapp_message_id: str) -> bool:
87
  response = await (
88
  self.client.table("messages")
@@ -275,6 +329,14 @@ class SupabaseRepository:
275
  data = _response_data(response)
276
  return len(data) if isinstance(data, list) else len(trip_embeddings)
277
 
 
 
 
 
 
 
 
 
278
  def _apply_departure_request_filter(
279
  self,
280
  query: Any,
@@ -430,6 +492,34 @@ class SupabaseRepository:
430
  trip = data[0] if isinstance(data, list) else data
431
  return await self.get_trip_by_id(str(trip["id"])) or trip
432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  async def create_booking_lead(
434
  self,
435
  *,
 
83
  data = _response_data(response)
84
  return data[0] if isinstance(data, list) else data
85
 
86
+ async def get_customer_session(self, customer_id: str) -> dict[str, Any]:
87
+ response = await (
88
+ self.client.table("customers")
89
+ .select("session_data")
90
+ .eq("id", customer_id)
91
+ .maybe_single()
92
+ .execute()
93
+ )
94
+ data = _response_data(response) or {}
95
+ session_data = data.get("session_data")
96
+ return session_data if isinstance(session_data, dict) else {}
97
+
98
+ async def update_customer_session(
99
+ self,
100
+ *,
101
+ customer_id: str,
102
+ session_data: dict[str, Any],
103
+ ) -> dict[str, Any]:
104
+ response = await (
105
+ self.client.table("customers")
106
+ .update({"session_data": session_data})
107
+ .eq("id", customer_id)
108
+ .execute()
109
+ )
110
+ data = _response_data(response)
111
+ return data[0] if isinstance(data, list) else data
112
+
113
+ async def set_customer_session_field(
114
+ self,
115
+ *,
116
+ customer_id: str,
117
+ key: str,
118
+ value: Any,
119
+ ) -> dict[str, Any]:
120
+ session_data = await self.get_customer_session(customer_id)
121
+ session_data[key] = value
122
+ return await self.update_customer_session(
123
+ customer_id=customer_id,
124
+ session_data=session_data,
125
+ )
126
+
127
+ async def clear_customer_session_field(
128
+ self,
129
+ *,
130
+ customer_id: str,
131
+ key: str,
132
+ ) -> dict[str, Any]:
133
+ session_data = await self.get_customer_session(customer_id)
134
+ session_data.pop(key, None)
135
+ return await self.update_customer_session(
136
+ customer_id=customer_id,
137
+ session_data=session_data,
138
+ )
139
+
140
  async def message_exists(self, whatsapp_message_id: str) -> bool:
141
  response = await (
142
  self.client.table("messages")
 
329
  data = _response_data(response)
330
  return len(data) if isinstance(data, list) else len(trip_embeddings)
331
 
332
+ async def delete_trip_embedding(self, trip_id: str) -> None:
333
+ await (
334
+ self.client.table("driver_trip_embeddings")
335
+ .delete()
336
+ .eq("trip_id", trip_id)
337
+ .execute()
338
+ )
339
+
340
  def _apply_departure_request_filter(
341
  self,
342
  query: Any,
 
492
  trip = data[0] if isinstance(data, list) else data
493
  return await self.get_trip_by_id(str(trip["id"])) or trip
494
 
495
+ async def update_driver_trip(
496
+ self,
497
+ trip_id: str,
498
+ updates: dict[str, Any],
499
+ ) -> dict[str, Any]:
500
+ if not updates:
501
+ raise ValueError("updates must not be empty")
502
+ response = await (
503
+ self.client.table("driver_trips")
504
+ .update(updates)
505
+ .eq("id", trip_id)
506
+ .execute()
507
+ )
508
+ data = _response_data(response)
509
+ updated = data[0] if isinstance(data, list) else data
510
+ return await self.get_trip_by_id(str(updated.get("id") or trip_id)) or updated
511
+
512
+ async def cancel_driver_trip(self, trip_id: str) -> dict[str, Any]:
513
+ response = await (
514
+ self.client.table("driver_trips")
515
+ .update({"status": "cancelled"})
516
+ .eq("id", trip_id)
517
+ .execute()
518
+ )
519
+ data = _response_data(response)
520
+ updated = data[0] if isinstance(data, list) else data
521
+ return await self.get_trip_by_id(str(updated.get("id") or trip_id)) or updated
522
+
523
  async def create_booking_lead(
524
  self,
525
  *,
app/models/domain.py CHANGED
@@ -13,6 +13,8 @@ class WhatsAppInboundMessage:
13
  timestamp: str | None = None
14
  profile_name: str | None = None
15
  phone_number_id: str | None = None
 
 
16
  raw: dict[str, Any] = field(default_factory=dict)
17
 
18
 
 
13
  timestamp: str | None = None
14
  profile_name: str | None = None
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
 
app/services/conversation_service.py CHANGED
@@ -8,10 +8,12 @@ from app.config import Settings
8
  from app.database.supabase import SupabaseRepository
9
  from app.models.domain import UserMode, WhatsAppInboundMessage
10
  from app.services.embedding_service import JinaEmbeddingService
11
- from app.tools.handlers import FalsaToolHandlers
 
12
  from app.tools.registry import ToolRegistry
13
  from app.utils.time import now_in_timezone
14
  from app.whatsapp.client import WhatsAppClient
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
@@ -34,6 +36,10 @@ _TOOLS_BY_MODE: dict[UserMode, list[str]] = {
34
  "check_driver_trips",
35
  "add_driver_car",
36
  "add_trip_by_driver",
 
 
 
 
37
  "switch_to_passenger",
38
  ],
39
  "passenger": [
@@ -73,6 +79,7 @@ class ConversationService:
73
  remote_jid=inbound.remoteJid,
74
  name=inbound.profile_name,
75
  )
 
76
  current_message = await self.repository.create_message(
77
  customer_id=str(customer["id"]),
78
  sender_type="customer",
@@ -95,15 +102,131 @@ class ConversationService:
95
  registry=registry,
96
  )
97
 
 
 
98
  await self.repository.create_message(
99
  customer_id=str(customer["id"]),
100
  sender_type="assistant",
101
  message=reply,
102
  metadata={"provider_flow": "groq_primary_hf_fallback", "user_mode": user_mode},
103
  )
104
- await self.whatsapp.send_text(inbound.remoteJid, reply)
105
  return reply
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def _tool_registry(
108
  self,
109
  customer: dict[str, Any],
 
8
  from app.database.supabase import SupabaseRepository
9
  from app.models.domain import UserMode, WhatsAppInboundMessage
10
  from app.services.embedding_service import JinaEmbeddingService
11
+ from app.services.trip_indexing import unindex_trip
12
+ from app.tools.handlers import FalsaToolHandlers, _trip_summary
13
  from app.tools.registry import ToolRegistry
14
  from app.utils.time import now_in_timezone
15
  from app.whatsapp.client import WhatsAppClient
16
+ from app.whatsapp.trip_selection import parse_trip_action_reply
17
 
18
  logger = logging.getLogger(__name__)
19
 
 
36
  "check_driver_trips",
37
  "add_driver_car",
38
  "add_trip_by_driver",
39
+ "delete_trip_by_number",
40
+ "modify_trip_by_number",
41
+ "initiate_trip_action",
42
+ "update_trip_field",
43
  "switch_to_passenger",
44
  ],
45
  "passenger": [
 
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",
 
102
  registry=registry,
103
  )
104
 
105
+ await self.whatsapp.send_text(inbound.remoteJid, reply)
106
+
107
  await self.repository.create_message(
108
  customer_id=str(customer["id"]),
109
  sender_type="assistant",
110
  message=reply,
111
  metadata={"provider_flow": "groq_primary_hf_fallback", "user_mode": user_mode},
112
  )
113
+
114
  return reply
115
 
116
+ async def _handle_trip_interactive_reply(
117
+ self,
118
+ inbound: WhatsAppInboundMessage,
119
+ customer: dict[str, Any],
120
+ *,
121
+ action: str,
122
+ trip_id: str,
123
+ ) -> str | None:
124
+ current_message = await self.repository.create_message(
125
+ customer_id=str(customer["id"]),
126
+ sender_type="customer",
127
+ message=inbound.text,
128
+ whatsapp_message_id=inbound.message_id,
129
+ metadata={
130
+ "whatsapp": inbound.raw,
131
+ "timestamp": inbound.timestamp,
132
+ "interactive_reply_id": inbound.interactive_reply_id,
133
+ },
134
+ )
135
+
136
+ driver = await self.repository.get_driver_by_remoteJid(inbound.remoteJid)
137
+ if not driver:
138
+ reply = (
139
+ "لا يوجد حساب سائق مرتبط بهذا الرفم"
140
+ "من فضللك, سجل كحساب سائق اولا"
141
+ )
142
+ await self._store_and_send_assistant_reply(
143
+ customer,
144
+ inbound.remoteJid,
145
+ reply,
146
+ user_mode=_resolve_user_mode(customer),
147
+ )
148
+ return reply
149
+
150
+ trip = await self.repository.get_trip_by_id(trip_id)
151
+ if not trip or str(trip.get("driver_id")) != str(driver["id"]):
152
+ reply = "That trip could not be found for your account."
153
+ await self._store_and_send_assistant_reply(
154
+ customer,
155
+ inbound.remoteJid,
156
+ reply,
157
+ user_mode=_resolve_user_mode(customer),
158
+ )
159
+ return reply
160
+
161
+ if action == "DELETE":
162
+ await self.repository.cancel_driver_trip(trip_id)
163
+ await unindex_trip(repository=self.repository, trip_id=trip_id)
164
+ reply = "Success! Your trip has been canceled."
165
+ await self._store_and_send_assistant_reply(
166
+ customer,
167
+ inbound.remoteJid,
168
+ reply,
169
+ user_mode="driver",
170
+ )
171
+ return reply
172
+
173
+ if action == "MODIFY":
174
+ await self.repository.set_customer_session_field(
175
+ customer_id=str(customer["id"]),
176
+ key="active_edit_trip_id",
177
+ value=trip_id,
178
+ )
179
+ summary = _trip_summary(trip)
180
+ route = f"{summary.get('departure')} -> {summary.get('destination')}"
181
+ time_label = summary.get("departure_time") or summary.get("departure_time_type")
182
+ system_note = (
183
+ f"SYSTEM: Driver selected trip {trip_id} ({route}, {time_label}) to modify. "
184
+ "Ask them what details they want to change."
185
+ )
186
+ user_mode = "driver"
187
+ registry = self._tool_registry(
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=4,
196
+ )
197
+ messages = self._ai_messages(context, user_mode=user_mode)
198
+ messages.append({"role": "system", "content": system_note})
199
+ reply = await self.ai.generate_reply(
200
+ messages=messages,
201
+ tools=get_tool_schemas(user_mode),
202
+ registry=registry,
203
+ )
204
+ await self._store_and_send_assistant_reply(
205
+ customer,
206
+ inbound.remoteJid,
207
+ reply,
208
+ user_mode=user_mode,
209
+ )
210
+ return reply
211
+
212
+ return None
213
+
214
+ async def _store_and_send_assistant_reply(
215
+ self,
216
+ customer: dict[str, Any],
217
+ remoteJid: str,
218
+ reply: str,
219
+ *,
220
+ user_mode: UserMode,
221
+ ) -> None:
222
+ await self.repository.create_message(
223
+ customer_id=str(customer["id"]),
224
+ sender_type="assistant",
225
+ message=reply,
226
+ metadata={"provider_flow": "trip_interactive_reply", "user_mode": user_mode},
227
+ )
228
+ await self.whatsapp.send_text(remoteJid, reply)
229
+
230
  def _tool_registry(
231
  self,
232
  customer: dict[str, Any],
app/services/trip_indexing.py CHANGED
@@ -37,6 +37,10 @@ async def index_trip(
37
  await repository.upsert_trip_embeddings([record])
38
 
39
 
 
 
 
 
40
  def _first_or_dict(value: Any) -> dict[str, Any] | None:
41
  if isinstance(value, list):
42
  return value[0] if value else None
 
37
  await repository.upsert_trip_embeddings([record])
38
 
39
 
40
+ async def unindex_trip(*, repository: SupabaseRepository, trip_id: str) -> None:
41
+ await repository.delete_trip_embedding(trip_id)
42
+
43
+
44
  def _first_or_dict(value: Any) -> dict[str, Any] | None:
45
  if isinstance(value, list):
46
  return value[0] if value else None
app/tools/handlers.py CHANGED
@@ -4,7 +4,7 @@ from typing import Any
4
  from app.database.supabase import SupabaseRepository
5
  from app.models.domain import ToolResult
6
  from app.services.embedding_service import JinaEmbeddingService
7
- from app.services.trip_indexing import index_trip
8
  from app.utils.departure import (
9
  _parse_date_value,
10
  normalize_departure_bucket,
@@ -15,6 +15,7 @@ from app.utils.departure import (
15
  trip_satisfies_departure_request,
16
  )
17
  from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
 
18
 
19
 
20
  class FalsaToolHandlers:
@@ -248,6 +249,12 @@ class FalsaToolHandlers:
248
  },
249
  )
250
 
 
 
 
 
 
 
251
  async def check_driver_info(self, arguments: dict[str, Any]) -> ToolResult:
252
  driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
253
  if not driver:
@@ -282,9 +289,7 @@ class FalsaToolHandlers:
282
  }
283
  for car in cars
284
  ],
285
- "active_trips": [
286
- _trip_summary(trip) for trip in upcoming_trips
287
- ],
288
  },
289
  )
290
 
@@ -305,7 +310,7 @@ class FalsaToolHandlers:
305
  ok=True,
306
  data={
307
  "driver_id": driver["id"],
308
- "upcoming_trips": [_trip_summary(trip) for trip in trips],
309
  "count": len(trips),
310
  "message": (
311
  "No upcoming active trips found."
@@ -505,6 +510,282 @@ class FalsaToolHandlers:
505
  },
506
  )
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  async def switch_to_driver(self, arguments: dict[str, Any]) -> ToolResult:
509
  driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
510
  if not driver:
@@ -660,10 +941,10 @@ def _is_trip_match(
660
  return True
661
 
662
 
663
- def _trip_summary(trip: dict[str, Any]) -> dict[str, Any]:
664
  driver = _first_or_dict(trip.get("drivers")) or {}
665
  car = _first_or_dict(trip.get("driver_cars")) or {}
666
- return {
667
  "trip_id": trip.get("trip_id") or trip.get("id"),
668
  "departure": trip.get("departure"),
669
  "destination": trip.get("destination"),
@@ -681,6 +962,9 @@ def _trip_summary(trip: dict[str, Any]) -> dict[str, Any]:
681
  "similarity": trip.get("similarity"),
682
  "time_difference_minutes": trip.get("time_difference_minutes"),
683
  }
 
 
 
684
 
685
 
686
  def _sort_trip_summaries(trips: list[dict[str, Any]]) -> list[dict[str, Any]]:
 
4
  from app.database.supabase import SupabaseRepository
5
  from app.models.domain import ToolResult
6
  from app.services.embedding_service import JinaEmbeddingService
7
+ from app.services.trip_indexing import index_trip, unindex_trip
8
  from app.utils.departure import (
9
  _parse_date_value,
10
  normalize_departure_bucket,
 
15
  trip_satisfies_departure_request,
16
  )
17
  from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
18
+ from app.whatsapp.trip_selection import build_trip_selection_list, build_trip_selection_text
19
 
20
 
21
  class FalsaToolHandlers:
 
249
  },
250
  )
251
 
252
+ async def _summarize_trips(self, trips: list[dict[str, Any]]) -> list[dict[str, Any]]:
253
+ return [
254
+ _trip_summary(trip, trip_number=index + 1)
255
+ for index, trip in enumerate(trips)
256
+ ]
257
+
258
  async def check_driver_info(self, arguments: dict[str, Any]) -> ToolResult:
259
  driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
260
  if not driver:
 
289
  }
290
  for car in cars
291
  ],
292
+ "active_trips": await self._summarize_trips(upcoming_trips),
 
 
293
  },
294
  )
295
 
 
310
  ok=True,
311
  data={
312
  "driver_id": driver["id"],
313
+ "upcoming_trips": await self._summarize_trips(trips),
314
  "count": len(trips),
315
  "message": (
316
  "No upcoming active trips found."
 
510
  },
511
  )
512
 
513
+ async def initiate_trip_action(self, arguments: dict[str, Any]) -> ToolResult:
514
+ action_type = _optional_string(arguments.get("action_type"))
515
+ if action_type not in {"DELETE", "MODIFY"}:
516
+ return ToolResult(
517
+ ok=False,
518
+ data={},
519
+ error="action_type must be DELETE or MODIFY",
520
+ )
521
+
522
+ driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
523
+ if not driver:
524
+ return ToolResult(
525
+ ok=False,
526
+ data={"action": "create_driver_account"},
527
+ error=(
528
+ "No driver account for this WhatsApp number. "
529
+ "Ask the sender to register with create_driver_account first."
530
+ ),
531
+ )
532
+
533
+ trips = await self.repository.list_driver_trips(str(driver["id"]))
534
+ travel_date = _optional_string(arguments.get("travel_date"))
535
+ travel_time = _optional_string(arguments.get("travel_time"))
536
+ departure_request = parse_departure_request(
537
+ travel_date=travel_date,
538
+ travel_time=travel_time,
539
+ )
540
+ if departure_request.departure_date or departure_request.departure_time:
541
+ trips = [
542
+ trip
543
+ for trip in trips
544
+ if trip_satisfies_departure_request(trip, departure_request)
545
+ ]
546
+
547
+ if not trips:
548
+ return ToolResult(
549
+ ok=True,
550
+ data={
551
+ "count": 0,
552
+ "message": "No trips found. Ask the user for clarification.",
553
+ },
554
+ )
555
+
556
+ body = build_trip_selection_text(trips=trips, action_type=action_type)
557
+ try:
558
+ await self.whatsapp.send_text(self.remoteJid, body)
559
+ except WhatsAppClientError as exc:
560
+ return ToolResult(
561
+ ok=False,
562
+ data={"count": len(trips)},
563
+ error=f"Failed to send WhatsApp trip selection text: {exc}",
564
+ )
565
+
566
+ return ToolResult(
567
+ ok=True,
568
+ data={
569
+ "count": len(trips),
570
+ "action_type": action_type,
571
+ "message": (
572
+ "Sent a numbered trip list to the driver via WhatsApp. "
573
+ "Ask them to reply with the trip number."
574
+ ),
575
+ },
576
+ )
577
+
578
+ async def delete_trip_by_number(self, arguments: dict[str, Any]) -> ToolResult:
579
+ driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
580
+ if not driver:
581
+ return ToolResult(
582
+ ok=False,
583
+ data={"action": "create_driver_account"},
584
+ error=(
585
+ "No driver account for this WhatsApp number. "
586
+ "Ask the sender to register with create_driver_account first."
587
+ ),
588
+ )
589
+
590
+ trip_number = _optional_int(arguments.get("trip_number"))
591
+ if trip_number is None or trip_number < 1:
592
+ return ToolResult(ok=False, data={}, error="trip_number must be an integer >= 1")
593
+
594
+ trips = await self.repository.list_driver_trips(str(driver["id"]))
595
+ if trip_number > len(trips):
596
+ return ToolResult(
597
+ ok=False,
598
+ data={"count": len(trips)},
599
+ error=f"trip_number must be between 1 and {len(trips)}",
600
+ )
601
+
602
+ trip = trips[trip_number - 1]
603
+ await self.repository.cancel_driver_trip(str(trip["id"]))
604
+ await unindex_trip(repository=self.repository, trip_id=str(trip["id"]))
605
+
606
+ return ToolResult(
607
+ ok=True,
608
+ data={
609
+ "trip_number": trip_number,
610
+ "trip_id": trip.get("id"),
611
+ "trip": _trip_summary(trip, trip_number=trip_number),
612
+ "message": "Success! Your trip has been canceled.",
613
+ },
614
+ )
615
+
616
+ async def modify_trip_by_number(self, arguments: dict[str, Any]) -> ToolResult:
617
+ print("\n modefing our new \n")
618
+ driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
619
+ if not driver:
620
+ return ToolResult(
621
+ ok=False,
622
+ data={"action": "create_driver_account"},
623
+ error=(
624
+ "No driver account for this WhatsApp number. "
625
+ "Ask the sender to register with create_driver_account first."
626
+ ),
627
+ )
628
+
629
+ trip_number = _optional_int(arguments.get("trip_number"))
630
+ field = _optional_string(arguments.get("field"))
631
+ value = _optional_string(arguments.get("value"))
632
+ if trip_number is None or trip_number < 1:
633
+ return ToolResult(ok=False, data={}, error="trip_number must be an integer >= 1")
634
+ if not field or value is None:
635
+ return ToolResult(ok=False, data={}, error="field and value are required")
636
+
637
+ trips = await self.repository.list_driver_trips(str(driver["id"]))
638
+ if trip_number > len(trips):
639
+ return ToolResult(
640
+ ok=False,
641
+ data={"count": len(trips)},
642
+ error=f"trip_number must be between 1 and {len(trips)}",
643
+ )
644
+
645
+ trip = trips[trip_number - 1]
646
+ updates, error = await self._build_trip_field_update(
647
+ driver_id=str(driver["id"]),
648
+ field=field,
649
+ value=value,
650
+ )
651
+ if error:
652
+ return ToolResult(ok=False, data={}, error=error)
653
+
654
+ updated_trip = await self.repository.update_driver_trip(str(trip["id"]), updates)
655
+ await index_trip(
656
+ repository=self.repository,
657
+ embeddings=self.embeddings,
658
+ embedding_model=self.embedding_model,
659
+ trip=updated_trip,
660
+ )
661
+
662
+ return ToolResult(
663
+ ok=True,
664
+ data={
665
+ "trip_number": trip_number,
666
+ "trip_id": updated_trip.get("id"),
667
+ "field": field,
668
+ "value": value,
669
+ "trip": _trip_summary(updated_trip, trip_number=trip_number),
670
+ "message": "Trip updated successfully.",
671
+ },
672
+ )
673
+
674
+ async def update_trip_field(self, arguments: dict[str, Any]) -> ToolResult:
675
+ driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
676
+ if not driver:
677
+ return ToolResult(
678
+ ok=False,
679
+ data={"action": "create_driver_account"},
680
+ error=(
681
+ "No driver account for this WhatsApp number. "
682
+ "Ask the sender to register with create_driver_account first."
683
+ ),
684
+ )
685
+
686
+ session = await self.repository.get_customer_session(str(self.customer["id"]))
687
+ trip_id = _optional_string(session.get("active_edit_trip_id"))
688
+ if not trip_id:
689
+ return ToolResult(
690
+ ok=False,
691
+ data={},
692
+ error=(
693
+ "No trip is selected for editing. "
694
+ "Use initiate_trip_action with MODIFY first."
695
+ ),
696
+ )
697
+
698
+ trip = await self.repository.get_trip_by_id(trip_id)
699
+ if not trip:
700
+ return ToolResult(ok=False, data={}, error="Trip was not found")
701
+ if str(trip.get("driver_id")) != str(driver["id"]):
702
+ return ToolResult(ok=False, data={}, error="Trip does not belong to this driver")
703
+ if trip.get("status") != "active":
704
+ return ToolResult(ok=False, data={}, error="Trip is not active")
705
+
706
+ field = _optional_string(arguments.get("field"))
707
+ value = _optional_string(arguments.get("value"))
708
+ if not field or value is None:
709
+ return ToolResult(ok=False, data={}, error="field and value are required")
710
+
711
+ updates, error = await self._build_trip_field_update(
712
+ driver_id=str(driver["id"]),
713
+ field=field,
714
+ value=value,
715
+ )
716
+ if error:
717
+ return ToolResult(ok=False, data={}, error=error)
718
+
719
+ updated_trip = await self.repository.update_driver_trip(trip_id, updates)
720
+ await index_trip(
721
+ repository=self.repository,
722
+ embeddings=self.embeddings,
723
+ embedding_model=self.embedding_model,
724
+ trip=updated_trip,
725
+ )
726
+ await self.repository.clear_customer_session_field(
727
+ customer_id=str(self.customer["id"]),
728
+ key="active_edit_trip_id",
729
+ )
730
+
731
+ return ToolResult(
732
+ ok=True,
733
+ data={
734
+ "trip_id": trip_id,
735
+ "field": field,
736
+ "value": value,
737
+ "trip": _trip_summary(updated_trip),
738
+ "message": "Trip updated successfully.",
739
+ },
740
+ )
741
+
742
+ async def _build_trip_field_update(
743
+ self,
744
+ *,
745
+ driver_id: str,
746
+ field: str,
747
+ value: str,
748
+ ) -> tuple[dict[str, Any], str | None]:
749
+ if field in {"departure", "destination"}:
750
+ return {field: value}, None
751
+
752
+ if field == "departure_date":
753
+ parsed_date = _parse_date_value(value)
754
+ if not parsed_date:
755
+ return {}, "departure_date must be YYYY-MM-DD"
756
+ return {"departure_date": parsed_date.isoformat()}, None
757
+
758
+ if field in {"departure_time", "pickup_time"}:
759
+ bucket = normalize_departure_bucket(value)
760
+ if not bucket:
761
+ return {}, "departure_time must be morning, noon, night, or HH:MM"
762
+ return {"departure_time": bucket}, None
763
+
764
+ if field == "vehicle_type":
765
+ cars = await self.repository.list_driver_cars(driver_id)
766
+ matched_car = _resolve_driver_car(cars, vehicle_type=value)
767
+ if not matched_car:
768
+ return {}, f"No registered vehicle matches '{value}'"
769
+ return {"car_id": str(matched_car["id"])}, None
770
+
771
+ if field in {"available_seats", "total_seats"}:
772
+ seats = _optional_int(value)
773
+ if seats is None:
774
+ return {}, f"{field} must be an integer"
775
+ if field == "available_seats" and seats < 0:
776
+ return {}, "available_seats must be at least 0"
777
+ if field == "total_seats" and seats < 1:
778
+ return {}, "total_seats must be at least 1"
779
+ return {field: seats}, None
780
+
781
+ if field == "price":
782
+ price = _optional_price(value)
783
+ if price is None or price < 0:
784
+ return {}, "price must be zero or greater"
785
+ return {"price": price}, None
786
+
787
+ return {}, f"Unsupported field: {field}"
788
+
789
  async def switch_to_driver(self, arguments: dict[str, Any]) -> ToolResult:
790
  driver = await self.repository.get_driver_by_remoteJid(self.remoteJid)
791
  if not driver:
 
941
  return True
942
 
943
 
944
+ def _trip_summary(trip: dict[str, Any], *, trip_number: int | None = None) -> dict[str, Any]:
945
  driver = _first_or_dict(trip.get("drivers")) or {}
946
  car = _first_or_dict(trip.get("driver_cars")) or {}
947
+ summary = {
948
  "trip_id": trip.get("trip_id") or trip.get("id"),
949
  "departure": trip.get("departure"),
950
  "destination": trip.get("destination"),
 
962
  "similarity": trip.get("similarity"),
963
  "time_difference_minutes": trip.get("time_difference_minutes"),
964
  }
965
+ if trip_number is not None:
966
+ summary["trip_number"] = trip_number
967
+ return summary
968
 
969
 
970
  def _sort_trip_summaries(trips: list[dict[str, Any]]) -> list[dict[str, Any]]:
app/whatsapp/client.py CHANGED
@@ -17,13 +17,14 @@ class WhatsAppClient:
17
  base = self._settings.whatsapp_graph_url.rstrip("/")
18
  version = self._settings.whatsapp_api_version.strip("/")
19
  phone_number_id = self._settings.whatsapp_phone_number_id
 
20
  return f"{base}/{version}/{phone_number_id}/messages"
21
 
22
- async def send_text(self, to_phone: str, text: str) -> dict:
23
  payload = {
24
  "messaging_product": "whatsapp",
25
  "recipient_type": "individual",
26
- "to": to_phone,
27
  "type": "text",
28
  "text": {"preview_url": False, "body": text},
29
  }
@@ -44,3 +45,33 @@ class WhatsAppClient:
44
  )
45
 
46
  return response.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  base = self._settings.whatsapp_graph_url.rstrip("/")
18
  version = self._settings.whatsapp_api_version.strip("/")
19
  phone_number_id = self._settings.whatsapp_phone_number_id
20
+ print(base)
21
  return f"{base}/{version}/{phone_number_id}/messages"
22
 
23
+ async def send_text(self, remoteJid: str, text: str) -> dict:
24
  payload = {
25
  "messaging_product": "whatsapp",
26
  "recipient_type": "individual",
27
+ "to": remoteJid,
28
  "type": "text",
29
  "text": {"preview_url": False, "body": text},
30
  }
 
45
  )
46
 
47
  return response.json()
48
+
49
+ async def send_interactive_list(
50
+ self,
51
+ remoteJid: str,
52
+ interactive: dict[str, Any],
53
+ ) -> dict:
54
+ payload = {
55
+ "messaging_product": "whatsapp",
56
+ "recipient_type": "individual",
57
+ "to": remoteJid,
58
+ "type": "interactive",
59
+ "interactive": interactive,
60
+ }
61
+ headers = {
62
+ "Authorization": f"Bearer {self._settings.whatsapp_access_token}",
63
+ "Content-Type": "application/json",
64
+ }
65
+
66
+ if self._client is not None:
67
+ response = await self._client.post(self._messages_url, json=payload, headers=headers)
68
+ else:
69
+ async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client:
70
+ response = await client.post(self._messages_url, json=payload, headers=headers)
71
+
72
+ if response.status_code >= 400:
73
+ raise WhatsAppClientError(
74
+ f"WhatsApp API failed with {response.status_code}: {response.text}"
75
+ )
76
+
77
+ return response.json()
app/whatsapp/parser.py CHANGED
@@ -2,7 +2,7 @@ from typing import Any
2
 
3
  from app.models.domain import WhatsAppInboundMessage
4
 
5
- SUPPORTED_MESSAGE_TYPES = {"text"}
6
 
7
 
8
  def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessage]:
@@ -24,18 +24,49 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
24
 
25
  remoteJid = message.get("from")
26
  message_id = message.get("id")
 
27
  text = message.get("text", {}).get("body")
28
  if not remoteJid or not message_id or text is None:
29
  continue
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  messages.append(
32
  WhatsAppInboundMessage(
33
  message_id=message_id,
34
  remoteJid=remoteJid,
35
- text=text,
36
  timestamp=message.get("timestamp"),
37
  profile_name=contacts_by_wa_id.get(remoteJid),
38
  phone_number_id=phone_number_id,
 
 
39
  raw=message,
40
  )
41
  )
 
2
 
3
  from app.models.domain import WhatsAppInboundMessage
4
 
5
+ SUPPORTED_MESSAGE_TYPES = {"text", "interactive"}
6
 
7
 
8
  def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessage]:
 
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:
30
  continue
31
 
32
+ if message_type == "text":
33
+ text = message.get("text", {}).get("body")
34
+ if text is None:
35
+ continue
36
+ messages.append(
37
+ WhatsAppInboundMessage(
38
+ message_id=message_id,
39
+ remoteJid=remoteJid,
40
+ text=text,
41
+ timestamp=message.get("timestamp"),
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
+ )
48
+ continue
49
+
50
+ interactive = message.get("interactive", {})
51
+ if interactive.get("type") != "list_reply":
52
+ continue
53
+
54
+ list_reply = interactive.get("list_reply", {})
55
+ reply_id = list_reply.get("id")
56
+ if not reply_id:
57
+ continue
58
+
59
+ title = list_reply.get("title") or reply_id
60
  messages.append(
61
  WhatsAppInboundMessage(
62
  message_id=message_id,
63
  remoteJid=remoteJid,
64
+ text=title,
65
  timestamp=message.get("timestamp"),
66
  profile_name=contacts_by_wa_id.get(remoteJid),
67
  phone_number_id=phone_number_id,
68
+ message_type="interactive",
69
+ interactive_reply_id=reply_id,
70
  raw=message,
71
  )
72
  )
app/whatsapp/trip_selection.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from app.utils.departure import trip_departure_bucket, trip_departure_date
4
+
5
+ _BUCKET_LABELS = {
6
+ "morning": "صباحا",
7
+ "noon": "ظهرا",
8
+ "night": "مساء",
9
+ }
10
+
11
+ _ACTION_DESCRIPTIONS = {
12
+ "DELETE": "🗑️ اختر رقم الرحله التي تريد حذفها من فضلك",
13
+ "MODIFY": "✏️ يمكنك ارسال رقم الرحلة التي تريد تعديلها",
14
+ }
15
+ _PERFFIX= "قائمة الرحلات المسجله في حسابك لدينا\n\n"+("-"*7)
16
+
17
+
18
+ def parse_trip_action_reply(reply_id: str) -> tuple[str, str] | None:
19
+ if "_" not in reply_id:
20
+ return None
21
+ action, trip_id = reply_id.split("_", 1)
22
+ if action not in {"DELETE", "MODIFY"} or not trip_id:
23
+ return None
24
+ return action, trip_id
25
+
26
+
27
+ def build_trip_selection_list(
28
+ *,
29
+ trips: list[dict[str, Any]],
30
+ action_type: str,
31
+ ) -> dict[str, Any]:
32
+ action = action_type.upper()
33
+ suffix = _ACTION_DESCRIPTIONS.get(action, "")
34
+ rows = [
35
+ f"""
36
+ [{str(trips.index(trip))}] :الرحلة رقم
37
+ \n
38
+ {_trip_row_description(trip)}
39
+ """
40
+ for trip in trips[:10]
41
+ ]
42
+ rows_text = "\n".join(rows)
43
+ return {
44
+ "type": "text",
45
+ "text": {
46
+ "body": f"{_PERFFIX} {rows_text} {suffix}"
47
+
48
+ }
49
+ }
50
+
51
+
52
+ def build_trip_selection_text(
53
+ *,
54
+ trips: list[dict[str, Any]],
55
+ action_type: str,
56
+ ) -> str:
57
+ action = action_type.upper()
58
+ suffix = {
59
+ "DELETE": "ارسل رقم الرحلة التي تريد حذفها.",
60
+ "MODIFY": "ارسل رقم الرحلة التي تريد تعديلها.",
61
+ }.get(action, "")
62
+
63
+ lines = [
64
+ f"قائمة الرحلات المسجلة لك:\n"
65
+ ]
66
+ for index, trip in enumerate(trips, start=1):
67
+ parsed_date = trip_departure_date(trip)
68
+ bucket = trip_departure_bucket(trip)
69
+ bucket_text = _BUCKET_LABELS.get(bucket, "") if bucket else ""
70
+ date_text = parsed_date.isoformat() if parsed_date else str(trip.get("departure_date") or "")
71
+ lines.append(
72
+ f"{index}. {trip.get('departure', '')} → {trip.get('destination', '')} | {date_text} | {bucket_text} | seats: {trip.get('available_seats')} / {trip.get('total_seats')} | price: {trip.get('price')}"
73
+ )
74
+ lines.append("")
75
+ lines.append(suffix)
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 "")
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
+ return (
88
+ f"🚌 {departure} → {destination}\n"
89
+ f"📅 التاريخ: {date_text}\n"
90
+ f"🕰️ الوقت: {bucket_text}"
91
+ )
prompts/system_driver.md CHANGED
@@ -8,6 +8,9 @@ Behavior:
8
  - Use `check_driver_trips` when the driver wants upcoming active trips.
9
  - Use `add_driver_car` to register a vehicle. Only `name` is required.
10
  - Use `add_trip_by_driver` to publish a trip. Collect route, `departure_date`, and `departure_time`.
 
 
 
11
  - For function arguments, use Arabic text for `departure`, `destination`, `vehicle_type`, and time bucket labels. Keep digits and exact times in English format.
12
  - Never pass `phone_number` in tool arguments; the WhatsApp session supplies it.
13
  - Never invent trips, prices, or booking confirmations.
 
8
  - Use `check_driver_trips` when the driver wants upcoming active trips.
9
  - Use `add_driver_car` to register a vehicle. Only `name` is required.
10
  - Use `add_trip_by_driver` to publish a trip. Collect route, `departure_date`, and `departure_time`.
11
+ - When a driver asks to delete, cancel, edit, or modify an existing trip, do not use WhatsApp interactive list responses. Instead invoke `initiate_trip_action` to send the driver a numbered list of their active trips, then let the driver reply with the trip number.
12
+ - When the driver replies with a trip number, use `delete_trip_by_number` or `modify_trip_by_number` as appropriate; the tool will map the one-based trip number to the trip ID using the active trip list ordered from oldest to newest.
13
+ - After the driver selects a trip to modify, use `modify_trip_by_number` with `trip_number`, `field`, and `value`. Do not pass `trip_id` directly.
14
  - For function arguments, use Arabic text for `departure`, `destination`, `vehicle_type`, and time bucket labels. Keep digits and exact times in English format.
15
  - Never pass `phone_number` in tool arguments; the WhatsApp session supplies it.
16
  - Never invent trips, prices, or booking confirmations.
supabase/migrations/202606140001_customer_session_data.sql ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ alter table public.customers
2
+ add column if not exists session_data jsonb not null default '{}'::jsonb;
tests/conftest.py CHANGED
@@ -129,6 +129,7 @@ class FakeRepository:
129
  "name": name,
130
  "preferred_language": preferred_language,
131
  "user_mode": None,
 
132
  }
133
  self.customers_by_remote_jid[remote_jid] = customer
134
  elif name:
@@ -159,6 +160,51 @@ class FakeRepository:
159
  return customer
160
  raise KeyError(customer_id)
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  async def message_exists(self, whatsapp_message_id: str) -> bool:
163
  return any(
164
  message.get("whatsapp_message_id") == whatsapp_message_id
@@ -340,6 +386,25 @@ class FakeRepository:
340
  async def get_trip_by_id(self, trip_id: str) -> dict[str, Any] | None:
341
  return self.trips_by_id.get(trip_id)
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  async def upsert_trip_embeddings(self, trip_embeddings: list[dict[str, Any]]) -> int:
344
  self.trip_embeddings.extend(trip_embeddings)
345
  return len(trip_embeddings)
@@ -364,6 +429,7 @@ class FakeWhatsApp:
364
  def __init__(self, *, fail: bool = False) -> None:
365
  self.fail = fail
366
  self.sent: list[tuple[str, str]] = []
 
367
 
368
  async def send_text(self, to_phone: str, text: str) -> dict[str, Any]:
369
  if self.fail:
@@ -371,6 +437,16 @@ class FakeWhatsApp:
371
  self.sent.append((to_phone, text))
372
  return {"messages": [{"id": "sent"}]}
373
 
 
 
 
 
 
 
 
 
 
 
374
 
375
  class FakeAI:
376
  def __init__(self, reply: str = "Here is your reply") -> None:
 
129
  "name": name,
130
  "preferred_language": preferred_language,
131
  "user_mode": None,
132
+ "session_data": {},
133
  }
134
  self.customers_by_remote_jid[remote_jid] = customer
135
  elif name:
 
160
  return customer
161
  raise KeyError(customer_id)
162
 
163
+ async def get_customer_session(self, customer_id: str) -> dict[str, Any]:
164
+ for customer in self.customers_by_phone.values():
165
+ if customer["id"] == customer_id:
166
+ return dict(customer.get("session_data") or {})
167
+ return {}
168
+
169
+ async def update_customer_session(
170
+ self,
171
+ *,
172
+ customer_id: str,
173
+ session_data: dict[str, Any],
174
+ ) -> dict[str, Any]:
175
+ for customer in self.customers_by_phone.values():
176
+ if customer["id"] == customer_id:
177
+ customer["session_data"] = session_data
178
+ return customer
179
+ raise KeyError(customer_id)
180
+
181
+ async def set_customer_session_field(
182
+ self,
183
+ *,
184
+ customer_id: str,
185
+ key: str,
186
+ value: Any,
187
+ ) -> dict[str, Any]:
188
+ session_data = await self.get_customer_session(customer_id)
189
+ session_data[key] = value
190
+ return await self.update_customer_session(
191
+ customer_id=customer_id,
192
+ session_data=session_data,
193
+ )
194
+
195
+ async def clear_customer_session_field(
196
+ self,
197
+ *,
198
+ customer_id: str,
199
+ key: str,
200
+ ) -> dict[str, Any]:
201
+ session_data = await self.get_customer_session(customer_id)
202
+ session_data.pop(key, None)
203
+ return await self.update_customer_session(
204
+ customer_id=customer_id,
205
+ session_data=session_data,
206
+ )
207
+
208
  async def message_exists(self, whatsapp_message_id: str) -> bool:
209
  return any(
210
  message.get("whatsapp_message_id") == whatsapp_message_id
 
386
  async def get_trip_by_id(self, trip_id: str) -> dict[str, Any] | None:
387
  return self.trips_by_id.get(trip_id)
388
 
389
+ async def update_driver_trip(
390
+ self,
391
+ trip_id: str,
392
+ updates: dict[str, Any],
393
+ ) -> dict[str, Any]:
394
+ trip = self.trips_by_id[trip_id]
395
+ trip.update(updates)
396
+ return trip
397
+
398
+ async def cancel_driver_trip(self, trip_id: str) -> dict[str, Any]:
399
+ trip = self.trips_by_id[trip_id]
400
+ trip["status"] = "cancelled"
401
+ return trip
402
+
403
+ async def delete_trip_embedding(self, trip_id: str) -> None:
404
+ self.trip_embeddings = [
405
+ row for row in self.trip_embeddings if row.get("trip_id") != trip_id
406
+ ]
407
+
408
  async def upsert_trip_embeddings(self, trip_embeddings: list[dict[str, Any]]) -> int:
409
  self.trip_embeddings.extend(trip_embeddings)
410
  return len(trip_embeddings)
 
429
  def __init__(self, *, fail: bool = False) -> None:
430
  self.fail = fail
431
  self.sent: list[tuple[str, str]] = []
432
+ self.interactive_lists: list[tuple[str, dict[str, Any]]] = []
433
 
434
  async def send_text(self, to_phone: str, text: str) -> dict[str, Any]:
435
  if self.fail:
 
437
  self.sent.append((to_phone, text))
438
  return {"messages": [{"id": "sent"}]}
439
 
440
+ async def send_interactive_list(
441
+ self,
442
+ to_phone: str,
443
+ interactive: dict[str, Any],
444
+ ) -> dict[str, Any]:
445
+ if self.fail:
446
+ raise RuntimeError("send failed")
447
+ self.interactive_lists.append((to_phone, interactive))
448
+ return {"messages": [{"id": "sent-interactive"}]}
449
+
450
 
451
  class FakeAI:
452
  def __init__(self, reply: str = "Here is your reply") -> None:
tests/test_conversation_service.py CHANGED
@@ -122,3 +122,135 @@ async def test_conversation_uses_passenger_tools_when_user_mode_is_passenger(set
122
  "switch_to_driver",
123
  }
124
  assert "travel booking assistant" in ai.calls[0]["messages"][0]["content"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  "switch_to_driver",
123
  }
124
  assert "travel booking assistant" in ai.calls[0]["messages"][0]["content"]
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_conversation_uses_driver_tools_when_user_mode_is_driver(settings):
129
+ repository = FakeRepository()
130
+ customer = await repository.upsert_customer(remote_jid="967700000010", name="Ali")
131
+ customer["user_mode"] = "driver"
132
+ ai = FakeAI(reply="Driver reply")
133
+ whatsapp = FakeWhatsApp()
134
+ service = ConversationService(
135
+ repository=repository,
136
+ embeddings=FakeEmbeddings(),
137
+ whatsapp=whatsapp,
138
+ ai=ai,
139
+ settings=settings,
140
+ )
141
+
142
+ await service.handle_inbound_message(
143
+ WhatsAppInboundMessage(
144
+ message_id="wamid.driver",
145
+ from_phone="967700000010",
146
+ text="Check my trips",
147
+ profile_name="Ali",
148
+ )
149
+ )
150
+
151
+ tool_names = {tool["function"]["name"] for tool in ai.calls[0]["tools"]}
152
+ assert tool_names == {
153
+ "about_falsa",
154
+ "check_driver_info",
155
+ "check_driver_trips",
156
+ "add_driver_car",
157
+ "add_trip_by_driver",
158
+ "delete_trip_by_number",
159
+ "modify_trip_by_number",
160
+ "initiate_trip_action",
161
+ "update_trip_field",
162
+ "switch_to_passenger",
163
+ }
164
+
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_conversation_handles_delete_interactive_reply(settings):
168
+ repository = FakeRepository()
169
+ customer = await repository.upsert_customer(phone_number="967700000010", name="Ali")
170
+ customer["user_mode"] = "driver"
171
+ repository.drivers_by_phone["967700000010"] = {
172
+ "id": "driver-1",
173
+ "name": "Ali",
174
+ "phone_number": "967700000010",
175
+ }
176
+ repository.trips_by_id["trip-1"] = {
177
+ "id": "trip-1",
178
+ "driver_id": "driver-1",
179
+ "departure": "Aden",
180
+ "destination": "Mukalla",
181
+ "departure_date": "2026-12-01",
182
+ "departure_time": "morning",
183
+ "status": "active",
184
+ "driver_cars": {"car_type": "SUV"},
185
+ "drivers": {"name": "Ali"},
186
+ }
187
+ repository.trip_embeddings.append({"trip_id": "trip-1"})
188
+ ai = FakeAI()
189
+ service = ConversationService(
190
+ repository=repository,
191
+ embeddings=FakeEmbeddings(),
192
+ whatsapp=FakeWhatsApp(),
193
+ ai=ai,
194
+ settings=settings,
195
+ )
196
+
197
+ reply = await service.handle_inbound_message(
198
+ WhatsAppInboundMessage(
199
+ message_id="wamid.delete",
200
+ from_phone="967700000010",
201
+ text="Noon - Mukalla",
202
+ message_type="interactive",
203
+ interactive_reply_id="DELETE_trip-1",
204
+ )
205
+ )
206
+
207
+ assert reply == "Success! Your trip has been canceled."
208
+ assert repository.trips_by_id["trip-1"]["status"] == "cancelled"
209
+ assert repository.trip_embeddings == []
210
+ assert ai.calls == []
211
+
212
+
213
+ @pytest.mark.asyncio
214
+ async def test_conversation_handles_modify_interactive_reply(settings):
215
+ repository = FakeRepository()
216
+ customer = await repository.upsert_customer(phone_number="967700000010", name="Ali")
217
+ customer["user_mode"] = "driver"
218
+ repository.drivers_by_phone["967700000010"] = {
219
+ "id": "driver-1",
220
+ "name": "Ali",
221
+ "phone_number": "967700000010",
222
+ }
223
+ repository.trips_by_id["trip-1"] = {
224
+ "id": "trip-1",
225
+ "driver_id": "driver-1",
226
+ "departure": "Aden",
227
+ "destination": "Mukalla",
228
+ "departure_date": "2026-12-01",
229
+ "departure_time": "morning",
230
+ "status": "active",
231
+ "driver_cars": {"car_type": "SUV"},
232
+ "drivers": {"name": "Ali"},
233
+ }
234
+ ai = FakeAI(reply="What would you like to change?")
235
+ service = ConversationService(
236
+ repository=repository,
237
+ embeddings=FakeEmbeddings(),
238
+ whatsapp=FakeWhatsApp(),
239
+ ai=ai,
240
+ settings=settings,
241
+ )
242
+
243
+ reply = await service.handle_inbound_message(
244
+ WhatsAppInboundMessage(
245
+ message_id="wamid.modify",
246
+ from_phone="967700000010",
247
+ text="Morning - Mukalla",
248
+ message_type="interactive",
249
+ interactive_reply_id="MODIFY_trip-1",
250
+ )
251
+ )
252
+
253
+ assert reply == "What would you like to change?"
254
+ session = await repository.get_customer_session(customer["id"])
255
+ assert session["active_edit_trip_id"] == "trip-1"
256
+ assert ai.calls[0]["messages"][-1]["content"].startswith("SYSTEM: Driver selected trip trip-1")
tests/test_tool_schemas.py CHANGED
@@ -15,6 +15,8 @@ def test_driver_tools_exclude_passenger_booking_tools():
15
  names = {schema["function"]["name"] for schema in get_tool_schemas("driver")}
16
  assert "search_trips" not in names
17
  assert "create_booking_lead" not in names
 
 
18
  assert "switch_to_passenger" in names
19
 
20
 
@@ -28,4 +30,4 @@ def test_passenger_tools_exclude_driver_management_tools():
28
 
29
  def test_all_tool_schemas_include_every_tool():
30
  names = {schema["function"]["name"] for schema in get_all_tool_schemas()}
31
- assert len(names) == 10
 
15
  names = {schema["function"]["name"] for schema in get_tool_schemas("driver")}
16
  assert "search_trips" not in names
17
  assert "create_booking_lead" not in names
18
+ assert "initiate_trip_action" in names
19
+ assert "update_trip_field" in names
20
  assert "switch_to_passenger" in names
21
 
22
 
 
30
 
31
  def test_all_tool_schemas_include_every_tool():
32
  names = {schema["function"]["name"] for schema in get_all_tool_schemas()}
33
+ assert len(names) == 14
tests/test_tools.py CHANGED
@@ -521,3 +521,117 @@ async def test_switch_to_passenger_without_name():
521
  assert result.ok is True
522
  assert customer["user_mode"] == "passenger"
523
  assert customer["name"] == "Existing"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  assert result.ok is True
522
  assert customer["user_mode"] == "passenger"
523
  assert customer["name"] == "Existing"
524
+
525
+
526
+ def _driver_setup(repository: FakeRepository, *, phone: str = "967700000010") -> None:
527
+ repository.drivers_by_phone[phone] = {
528
+ "id": "driver-1",
529
+ "name": "Ali",
530
+ "phone_number": phone,
531
+ }
532
+ repository.trips_by_id["trip-1"] = {
533
+ "id": "trip-1",
534
+ "driver_id": "driver-1",
535
+ "departure": "عدن",
536
+ "destination": "المكلا",
537
+ "departure_date": "2026-12-01",
538
+ "departure_time": "morning",
539
+ "available_seats": 2,
540
+ "total_seats": 4,
541
+ "price": "80.00",
542
+ "status": "active",
543
+ "driver_cars": {"car_type": "SUV"},
544
+ "drivers": {"name": "Ali"},
545
+ }
546
+
547
+
548
+ @pytest.mark.asyncio
549
+ async def test_initiate_trip_action_sends_whatsapp_list():
550
+ repository = FakeRepository()
551
+ whatsapp = FakeWhatsApp()
552
+ _driver_setup(repository)
553
+ handlers = make_handlers(
554
+ repository=repository,
555
+ whatsapp=whatsapp,
556
+ sender_phone="967700000010",
557
+ )
558
+
559
+ result = await handlers.initiate_trip_action({"action_type": "DELETE"})
560
+
561
+ assert result.ok is True
562
+ assert result.data["count"] == 1
563
+ assert "Pausing for response" in result.data["message"]
564
+ assert len(whatsapp.interactive_lists) == 1
565
+ _, interactive = whatsapp.interactive_lists[0]
566
+ assert interactive["action"]["sections"][0]["rows"][0]["id"] == "DELETE_trip-1"
567
+
568
+
569
+ @pytest.mark.asyncio
570
+ async def test_initiate_trip_action_returns_no_trips_message():
571
+ repository = FakeRepository()
572
+ _driver_setup(repository)
573
+ repository.trips_by_id.clear()
574
+ handlers = make_handlers(repository=repository, sender_phone="967700000010")
575
+
576
+ result = await handlers.initiate_trip_action({"action_type": "MODIFY"})
577
+
578
+ assert result.ok is True
579
+ assert result.data["count"] == 0
580
+ assert "No trips found" in result.data["message"]
581
+
582
+
583
+ @pytest.mark.asyncio
584
+ async def test_delete_trip_by_number_cancels_trip():
585
+ repository = FakeRepository()
586
+ _driver_setup(repository)
587
+ handlers = make_handlers(repository=repository, sender_phone="967700000010")
588
+
589
+ result = await handlers.delete_trip_by_number({"trip_number": 1})
590
+
591
+ assert result.ok is True
592
+ assert result.data["trip_number"] == 1
593
+ assert result.data["trip_id"] == "trip-1"
594
+ assert repository.trips_by_id["trip-1"]["status"] == "cancelled"
595
+
596
+
597
+ @pytest.mark.asyncio
598
+ async def test_modify_trip_by_number_updates_trip_field():
599
+ repository = FakeRepository()
600
+ _driver_setup(repository)
601
+ handlers = make_handlers(repository=repository, sender_phone="967700000010")
602
+
603
+ result = await handlers.modify_trip_by_number(
604
+ {"trip_number": 1, "field": "departure", "value": "تعز"}
605
+ )
606
+
607
+ assert result.ok is True
608
+ assert result.data["trip_number"] == 1
609
+ assert result.data["field"] == "departure"
610
+ assert result.data["value"] == "تعز"
611
+ assert repository.trips_by_id["trip-1"]["departure"] == "تعز"
612
+
613
+
614
+ @pytest.mark.asyncio
615
+ async def test_update_trip_field_uses_active_session_and_clears_it():
616
+ repository = FakeRepository()
617
+ customer = await repository.upsert_customer(phone_number="967700000010")
618
+ await repository.set_customer_session_field(
619
+ customer_id=customer["id"],
620
+ key="active_edit_trip_id",
621
+ value="trip-1",
622
+ )
623
+ _driver_setup(repository)
624
+ handlers = make_handlers(
625
+ repository=repository,
626
+ customer=customer,
627
+ sender_phone="967700000010",
628
+ )
629
+
630
+ result = await handlers.update_trip_field(
631
+ {"field": "pickup_time", "value": "15:00"},
632
+ )
633
+
634
+ assert result.ok is True
635
+ assert repository.trips_by_id["trip-1"]["departure_time"] == "noon"
636
+ assert await repository.get_customer_session(customer["id"]) == {}
637
+ assert len(repository.trip_embeddings) == 1
tests/test_trip_selection.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.whatsapp.trip_selection import build_trip_selection_list, parse_trip_action_reply
2
+
3
+
4
+ def test_parse_trip_action_reply():
5
+ assert parse_trip_action_reply("DELETE_trip-1") == ("DELETE", "trip-1")
6
+ assert parse_trip_action_reply("MODIFY_abc-123") == ("MODIFY", "abc-123")
7
+ assert parse_trip_action_reply("bad") is None
8
+
9
+
10
+ def test_build_trip_selection_list_text_message():
11
+ result = build_trip_selection_list(
12
+ trips=[
13
+ {
14
+ "id": "trip-1",
15
+ "departure": "Aden",
16
+ "destination": "Very Long Destination Name Here",
17
+ "departure_date": "2026-12-01",
18
+ "departure_time": "noon",
19
+ }
20
+ ],
21
+ action_type="DELETE",
22
+ )
23
+
24
+ assert result["type"] == "text"
25
+ body = result["text"]["body"]
26
+ assert "📋" in body
27
+ assert "🗑️" in body
28
+ assert "قائمة الرحلات" in body