feature: modify and delete driver trips
Browse files- .github/prompts/plan-mergeDriverChecksAddTripTools.prompt.md +61 -0
- app/ai/orchestrator.py +1 -2
- app/ai/tool_schemas.py +153 -0
- app/api/routes.py +3 -3
- app/database/supabase.py +90 -0
- app/models/domain.py +2 -0
- app/services/conversation_service.py +125 -2
- app/services/trip_indexing.py +4 -0
- app/tools/handlers.py +291 -7
- app/whatsapp/client.py +33 -2
- app/whatsapp/parser.py +33 -2
- app/whatsapp/trip_selection.py +91 -0
- prompts/system_driver.md +3 -0
- supabase/migrations/202606140001_customer_session_data.sql +2 -0
- tests/conftest.py +76 -0
- tests/test_conversation_service.py +132 -0
- tests/test_tool_schemas.py +3 -1
- tests/test_tools.py +114 -0
- tests/test_trip_selection.py +28 -0
.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 |
-
|
| 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 |
-
|
| 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":
|
| 191 |
"name": "driver-debug",
|
| 192 |
}
|
| 193 |
registry = container.conversation._tool_registry(
|
| 194 |
customer,
|
| 195 |
-
|
| 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.
|
|
|
|
| 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 |
-
|
| 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":
|
| 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 |
-
|
| 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,
|
| 23 |
payload = {
|
| 24 |
"messaging_product": "whatsapp",
|
| 25 |
"recipient_type": "individual",
|
| 26 |
-
"to":
|
| 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=
|
| 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) ==
|
|
|
|
| 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
|