rename booking leads to trip selections
Browse files- create_booking_lead -> select_trip across tools, schemas, handlers, prompts
- booking_leads table -> trip_selections (with migration)
- update Arabic notifications and prompts to reflect 'interest' not 'booking'
- add selection_count to trip cards and summaries
- update README and falsa_info.md to reflect new coordination model
- README.md +6 -6
- app/ai/tool_schemas.py +6 -5
- app/database/supabase.py +15 -5
- app/services/conversation_service.py +4 -4
- app/tools/handlers.py +12 -8
- app/whatsapp/trip_selection.py +2 -0
- prompts/falsa_info.md +10 -8
- pyproject.toml +1 -1
- tests/conftest.py +12 -9
- tests/test_conversation_service.py +1 -1
- tests/test_tool_schemas.py +1 -1
- tests/test_tools.py +10 -10
README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# FALSA
|
| 2 |
|
| 3 |
-
> **AI-powered travel
|
| 4 |
|
| 5 |
> **🚧 Work in progress — not yet published. Coming soon.**
|
| 6 |
|
|
@@ -8,7 +8,7 @@
|
|
| 8 |
<img src="assets/Falsa.png" alt="FALSA logo" width="100%">
|
| 9 |
</p>
|
| 10 |
|
| 11 |
-
FALSA lets passengers search trips and
|
| 12 |
|
| 13 |
---
|
| 14 |
|
|
@@ -25,8 +25,8 @@ Travel booking platforms in my country are poorly adopted. Not because people do
|
|
| 25 |
|
| 26 |
FALSA removes all of that. The entire interface is a single chat conversation. **You say where you're going and when — FALSA handles the rest.**
|
| 27 |
|
| 28 |
-
- **Passengers** describe their trip in natural language; FALSA searches available trips, shows options, and
|
| 29 |
-
- **Drivers** publish trips, manage schedules, and receive
|
| 30 |
- No app install, no account creation flow, no confusing dashboard.
|
| 31 |
|
| 32 |
---
|
|
@@ -35,8 +35,8 @@ FALSA removes all of that. The entire interface is a single chat conversation. *
|
|
| 35 |
|
| 36 |
- **Natural-language trip search** — "I want to go from Sana'a to Taiz tomorrow morning"
|
| 37 |
- **WhatsApp-native interaction** — interactive lists, text replies, no extra UI
|
| 38 |
-
- **Driver tools** — publish trips, add cars, modify/delete trips, receive
|
| 39 |
-
- **Passenger tools** — search trips,
|
| 40 |
- **AI-powered orchestration** — tool-calling LLM (Groq primary, Hugging Face fallback) routes intent to the right action
|
| 41 |
- **RAG knowledge base** — company info, pricing, policies embedded via Jina AI and retrieved on demand
|
| 42 |
- **Trip vector search** — semantic search over driver trips for flexible departure matching
|
|
|
|
| 1 |
# FALSA
|
| 2 |
|
| 3 |
+
> **AI-powered travel service — on WhatsApp.**
|
| 4 |
|
| 5 |
> **🚧 Work in progress — not yet published. Coming soon.**
|
| 6 |
|
|
|
|
| 8 |
<img src="assets/Falsa.png" alt="FALSA logo" width="100%">
|
| 9 |
</p>
|
| 10 |
|
| 11 |
+
FALSA lets passengers search trips and connect with drivers, and lets drivers publish, update, and manage their trips — all through a natural conversation on WhatsApp. No app to install, no complex UI to learn. Just type what you want.
|
| 12 |
|
| 13 |
---
|
| 14 |
|
|
|
|
| 25 |
|
| 26 |
FALSA removes all of that. The entire interface is a single chat conversation. **You say where you're going and when — FALSA handles the rest.**
|
| 27 |
|
| 28 |
+
- **Passengers** describe their trip in natural language; FALSA searches available trips, shows options, and connects them with drivers.
|
| 29 |
+
- **Drivers** publish trips, manage schedules, and receive trip interest notifications — all by chatting.
|
| 30 |
- No app install, no account creation flow, no confusing dashboard.
|
| 31 |
|
| 32 |
---
|
|
|
|
| 35 |
|
| 36 |
- **Natural-language trip search** — "I want to go from Sana'a to Taiz tomorrow morning"
|
| 37 |
- **WhatsApp-native interaction** — interactive lists, text replies, no extra UI
|
| 38 |
+
- **Driver tools** — publish trips, add cars, modify/delete trips, receive trip interest notifications
|
| 39 |
+
- **Passenger tools** — search trips, select trips, get driver contact, get FALSA information
|
| 40 |
- **AI-powered orchestration** — tool-calling LLM (Groq primary, Hugging Face fallback) routes intent to the right action
|
| 41 |
- **RAG knowledge base** — company info, pricing, policies embedded via Jina AI and retrieved on demand
|
| 42 |
- **Trip vector search** — semantic search over driver trips for flexible departure matching
|
app/ai/tool_schemas.py
CHANGED
|
@@ -85,12 +85,13 @@ _SEARCH_TRIPS = {
|
|
| 85 |
},
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
| 89 |
"type": "function",
|
| 90 |
"function": {
|
| 91 |
-
"name": "
|
| 92 |
"description": (
|
| 93 |
-
"
|
|
|
|
| 94 |
"Call on trip-card reply. Does not reserve or confirm."
|
| 95 |
),
|
| 96 |
"parameters": {
|
|
@@ -399,7 +400,7 @@ _SWITCH_TO_PASSENGER = {
|
|
| 399 |
_TOOL_SCHEMAS: dict[str, dict[str, Any]] = {
|
| 400 |
"about_falsa": _ABOUT_FALSA,
|
| 401 |
"search_trips": _SEARCH_TRIPS,
|
| 402 |
-
"
|
| 403 |
"create_driver_account": _CREATE_DRIVER_ACCOUNT,
|
| 404 |
"check_driver_info": _CHECK_DRIVER_INFO,
|
| 405 |
"check_driver_trips": _CHECK_DRIVER_TRIPS,
|
|
@@ -435,7 +436,7 @@ _TOOLS_BY_MODE: dict[UserMode, list[str]] = {
|
|
| 435 |
"passenger": [
|
| 436 |
"about_falsa",
|
| 437 |
"search_trips",
|
| 438 |
-
"
|
| 439 |
"create_driver_account",
|
| 440 |
"switch_to_driver",
|
| 441 |
],
|
|
|
|
| 85 |
},
|
| 86 |
}
|
| 87 |
|
| 88 |
+
_SELECT_TRIP = {
|
| 89 |
"type": "function",
|
| 90 |
"function": {
|
| 91 |
+
"name": "select_trip",
|
| 92 |
"description": (
|
| 93 |
+
"Record customer interest in a trip (default 1 seat), notify driver, "
|
| 94 |
+
"and return driver phone number so they can coordinate directly. "
|
| 95 |
"Call on trip-card reply. Does not reserve or confirm."
|
| 96 |
),
|
| 97 |
"parameters": {
|
|
|
|
| 400 |
_TOOL_SCHEMAS: dict[str, dict[str, Any]] = {
|
| 401 |
"about_falsa": _ABOUT_FALSA,
|
| 402 |
"search_trips": _SEARCH_TRIPS,
|
| 403 |
+
"select_trip": _SELECT_TRIP,
|
| 404 |
"create_driver_account": _CREATE_DRIVER_ACCOUNT,
|
| 405 |
"check_driver_info": _CHECK_DRIVER_INFO,
|
| 406 |
"check_driver_trips": _CHECK_DRIVER_TRIPS,
|
|
|
|
| 436 |
"passenger": [
|
| 437 |
"about_falsa",
|
| 438 |
"search_trips",
|
| 439 |
+
"select_trip",
|
| 440 |
"create_driver_account",
|
| 441 |
"switch_to_driver",
|
| 442 |
],
|
app/database/supabase.py
CHANGED
|
@@ -555,7 +555,7 @@ class SupabaseRepository:
|
|
| 555 |
updated = data[0] if isinstance(data, list) else data
|
| 556 |
return await self.get_trip_by_id(str(updated.get("id") or trip_id)) or updated
|
| 557 |
|
| 558 |
-
async def
|
| 559 |
self,
|
| 560 |
*,
|
| 561 |
customer_id: str,
|
|
@@ -571,14 +571,14 @@ class SupabaseRepository:
|
|
| 571 |
"notes": notes,
|
| 572 |
"driver_notification_status": "not_sent",
|
| 573 |
}
|
| 574 |
-
response = await self.client.table("
|
| 575 |
data = _response_data(response)
|
| 576 |
return data[0] if isinstance(data, list) else data
|
| 577 |
|
| 578 |
-
async def
|
| 579 |
self,
|
| 580 |
*,
|
| 581 |
-
|
| 582 |
status: str,
|
| 583 |
metadata: dict[str, Any] | None = None,
|
| 584 |
) -> dict[str, Any]:
|
|
@@ -586,7 +586,17 @@ class SupabaseRepository:
|
|
| 586 |
if metadata is not None:
|
| 587 |
payload["metadata"] = metadata
|
| 588 |
response = (
|
| 589 |
-
await self.client.table("
|
| 590 |
)
|
| 591 |
data = _response_data(response)
|
| 592 |
return data[0] if isinstance(data, list) else data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
updated = data[0] if isinstance(data, list) else data
|
| 556 |
return await self.get_trip_by_id(str(updated.get("id") or trip_id)) or updated
|
| 557 |
|
| 558 |
+
async def create_trip_selection(
|
| 559 |
self,
|
| 560 |
*,
|
| 561 |
customer_id: str,
|
|
|
|
| 571 |
"notes": notes,
|
| 572 |
"driver_notification_status": "not_sent",
|
| 573 |
}
|
| 574 |
+
response = await self.client.table("trip_selections").insert(payload).execute()
|
| 575 |
data = _response_data(response)
|
| 576 |
return data[0] if isinstance(data, list) else data
|
| 577 |
|
| 578 |
+
async def update_selection_notification(
|
| 579 |
self,
|
| 580 |
*,
|
| 581 |
+
selection_id: str,
|
| 582 |
status: str,
|
| 583 |
metadata: dict[str, Any] | None = None,
|
| 584 |
) -> dict[str, Any]:
|
|
|
|
| 586 |
if metadata is not None:
|
| 587 |
payload["metadata"] = metadata
|
| 588 |
response = (
|
| 589 |
+
await self.client.table("trip_selections").update(payload).eq("id", selection_id).execute()
|
| 590 |
)
|
| 591 |
data = _response_data(response)
|
| 592 |
return data[0] if isinstance(data, list) else data
|
| 593 |
+
|
| 594 |
+
async def count_trip_selections(self, trip_id: str) -> int:
|
| 595 |
+
response = await (
|
| 596 |
+
self.client.table("trip_selections")
|
| 597 |
+
.select("id", count="exact")
|
| 598 |
+
.eq("trip_id", trip_id)
|
| 599 |
+
.eq("status", "pending")
|
| 600 |
+
.execute()
|
| 601 |
+
)
|
| 602 |
+
return response.count if hasattr(response, "count") and response.count is not None else 0
|
app/services/conversation_service.py
CHANGED
|
@@ -97,18 +97,18 @@ class ConversationService:
|
|
| 97 |
embedding_model=self.settings.jina_embedding_model,
|
| 98 |
current_message=current_message,
|
| 99 |
)
|
| 100 |
-
result = await handlers.
|
| 101 |
{"trip_id": trip_id, "requested_seats": 1}
|
| 102 |
)
|
| 103 |
if result.ok:
|
| 104 |
driver_phone = result.data.get("driver_phone")
|
| 105 |
reply = (
|
| 106 |
-
f"تم ت
|
| 107 |
if driver_phone
|
| 108 |
-
else "تم ت
|
| 109 |
)
|
| 110 |
else:
|
| 111 |
-
reply = f"عذراً، لم يتم
|
| 112 |
await self.whatsapp.send_text(inbound.remoteJid, reply)
|
| 113 |
await self.repository.create_message(
|
| 114 |
customer_id=str(customer["id"]),
|
|
|
|
| 97 |
embedding_model=self.settings.jina_embedding_model,
|
| 98 |
current_message=current_message,
|
| 99 |
)
|
| 100 |
+
result = await handlers.select_trip(
|
| 101 |
{"trip_id": trip_id, "requested_seats": 1}
|
| 102 |
)
|
| 103 |
if result.ok:
|
| 104 |
driver_phone = result.data.get("driver_phone")
|
| 105 |
reply = (
|
| 106 |
+
f"تم إرسال معلوماتك إلى السائق. يمكنك التواصل معه على الرقم: {driver_phone}"
|
| 107 |
if driver_phone
|
| 108 |
+
else "تم إرسال معلوماتك إلى السائق. سيتم التواصل معك."
|
| 109 |
)
|
| 110 |
else:
|
| 111 |
+
reply = f"عذراً، لم يتم إرسال الطلب: {result.error}"
|
| 112 |
await self.whatsapp.send_text(inbound.remoteJid, reply)
|
| 113 |
await self.repository.create_message(
|
| 114 |
customer_id=str(customer["id"]),
|
app/tools/handlers.py
CHANGED
|
@@ -171,7 +171,10 @@ class FalsaToolHandlers:
|
|
| 171 |
if top_trips:
|
| 172 |
for trip_summary in top_trips:
|
| 173 |
trip_id = trip_summary["trip_id"]
|
|
|
|
|
|
|
| 174 |
trip = next((t for t in trips if (t.get("trip_id") or t.get("id")) == trip_id), {})
|
|
|
|
| 175 |
card = format_trip_card(trip)
|
| 176 |
try:
|
| 177 |
resp = await self.whatsapp.send_text(self.remoteJid, card)
|
|
@@ -219,7 +222,7 @@ class FalsaToolHandlers:
|
|
| 219 |
},
|
| 220 |
)
|
| 221 |
|
| 222 |
-
async def
|
| 223 |
trip_id = _optional_string(arguments.get("trip_id"))
|
| 224 |
requested_seats = _optional_int(arguments.get("requested_seats")) or 1
|
| 225 |
notes = _optional_string(arguments.get("notes"))
|
|
@@ -243,7 +246,7 @@ class FalsaToolHandlers:
|
|
| 243 |
error="Not enough available seats",
|
| 244 |
)
|
| 245 |
|
| 246 |
-
|
| 247 |
customer_id=str(self.customer["id"]),
|
| 248 |
trip_id=trip_id,
|
| 249 |
requested_seats=requested_seats,
|
|
@@ -273,8 +276,8 @@ class FalsaToolHandlers:
|
|
| 273 |
notification_status = "failed"
|
| 274 |
notification_error = str(exc)
|
| 275 |
|
| 276 |
-
await self.repository.
|
| 277 |
-
|
| 278 |
status=notification_status,
|
| 279 |
metadata={"error": notification_error} if notification_error else None,
|
| 280 |
)
|
|
@@ -282,12 +285,12 @@ class FalsaToolHandlers:
|
|
| 282 |
return ToolResult(
|
| 283 |
ok=True,
|
| 284 |
data={
|
| 285 |
-
"
|
| 286 |
"status": "pending",
|
| 287 |
"driver_notification_status": notification_status,
|
| 288 |
"driver_notification_error": notification_error,
|
| 289 |
"driver_phone": driver_phone,
|
| 290 |
-
"message": "
|
| 291 |
},
|
| 292 |
)
|
| 293 |
|
|
@@ -1019,6 +1022,7 @@ def _trip_summary(trip: dict[str, Any], *, trip_number: int | None = None) -> di
|
|
| 1019 |
"status": trip.get("status"),
|
| 1020 |
"similarity": trip.get("similarity"),
|
| 1021 |
"time_difference_minutes": trip.get("time_difference_minutes"),
|
|
|
|
| 1022 |
}
|
| 1023 |
if trip_number is not None:
|
| 1024 |
summary["trip_number"] = trip_number
|
|
@@ -1076,12 +1080,12 @@ def _driver_notification_text(
|
|
| 1076 |
notes: str | None,
|
| 1077 |
) -> str:
|
| 1078 |
return (
|
| 1079 |
-
"🔔
|
| 1080 |
f"العميل: {customer.get('name') or 'عميل جديد'}\n"
|
| 1081 |
f"رقم العميل: {customer.get('phone_number') or 'غير متوفر'}\n"
|
| 1082 |
f"الرحلة: {trip.get('departure')} ← {trip.get('destination')}\n"
|
| 1083 |
f"التاريخ: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n"
|
| 1084 |
f"المقاعد المطلوبة: {requested_seats}\n"
|
| 1085 |
f"ملاحظات: {notes or 'لا يوجد'}\n"
|
| 1086 |
-
"ال
|
| 1087 |
)
|
|
|
|
| 171 |
if top_trips:
|
| 172 |
for trip_summary in top_trips:
|
| 173 |
trip_id = trip_summary["trip_id"]
|
| 174 |
+
selection_count = await self.repository.count_trip_selections(trip_id)
|
| 175 |
+
trip_summary["selection_count"] = selection_count
|
| 176 |
trip = next((t for t in trips if (t.get("trip_id") or t.get("id")) == trip_id), {})
|
| 177 |
+
trip["selection_count"] = selection_count
|
| 178 |
card = format_trip_card(trip)
|
| 179 |
try:
|
| 180 |
resp = await self.whatsapp.send_text(self.remoteJid, card)
|
|
|
|
| 222 |
},
|
| 223 |
)
|
| 224 |
|
| 225 |
+
async def select_trip(self, arguments: dict[str, Any]) -> ToolResult:
|
| 226 |
trip_id = _optional_string(arguments.get("trip_id"))
|
| 227 |
requested_seats = _optional_int(arguments.get("requested_seats")) or 1
|
| 228 |
notes = _optional_string(arguments.get("notes"))
|
|
|
|
| 246 |
error="Not enough available seats",
|
| 247 |
)
|
| 248 |
|
| 249 |
+
selection = await self.repository.create_trip_selection(
|
| 250 |
customer_id=str(self.customer["id"]),
|
| 251 |
trip_id=trip_id,
|
| 252 |
requested_seats=requested_seats,
|
|
|
|
| 276 |
notification_status = "failed"
|
| 277 |
notification_error = str(exc)
|
| 278 |
|
| 279 |
+
await self.repository.update_selection_notification(
|
| 280 |
+
selection_id=str(selection["id"]),
|
| 281 |
status=notification_status,
|
| 282 |
metadata={"error": notification_error} if notification_error else None,
|
| 283 |
)
|
|
|
|
| 285 |
return ToolResult(
|
| 286 |
ok=True,
|
| 287 |
data={
|
| 288 |
+
"selection_id": selection["id"],
|
| 289 |
"status": "pending",
|
| 290 |
"driver_notification_status": notification_status,
|
| 291 |
"driver_notification_error": notification_error,
|
| 292 |
"driver_phone": driver_phone,
|
| 293 |
+
"message": "Customer interest recorded. Driver has been notified — share their number so the two parties can coordinate directly.",
|
| 294 |
},
|
| 295 |
)
|
| 296 |
|
|
|
|
| 1022 |
"status": trip.get("status"),
|
| 1023 |
"similarity": trip.get("similarity"),
|
| 1024 |
"time_difference_minutes": trip.get("time_difference_minutes"),
|
| 1025 |
+
"selection_count": trip.get("selection_count", 0),
|
| 1026 |
}
|
| 1027 |
if trip_number is not None:
|
| 1028 |
summary["trip_number"] = trip_number
|
|
|
|
| 1080 |
notes: str | None,
|
| 1081 |
) -> str:
|
| 1082 |
return (
|
| 1083 |
+
"🔔 اهتمام جديد برحلة في فلسا\n"
|
| 1084 |
f"العميل: {customer.get('name') or 'عميل جديد'}\n"
|
| 1085 |
f"رقم العميل: {customer.get('phone_number') or 'غير متوفر'}\n"
|
| 1086 |
f"الرحلة: {trip.get('departure')} ← {trip.get('destination')}\n"
|
| 1087 |
f"التاريخ: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n"
|
| 1088 |
f"المقاعد المطلوبة: {requested_seats}\n"
|
| 1089 |
f"ملاحظات: {notes or 'لا يوجد'}\n"
|
| 1090 |
+
"يرجى التواصل مع العميل للاتفاق على الحجز"
|
| 1091 |
)
|
app/whatsapp/trip_selection.py
CHANGED
|
@@ -92,6 +92,7 @@ def format_trip_card(trip: dict[str, Any]) -> str:
|
|
| 92 |
available = trip.get("available_seats") or 0
|
| 93 |
total = trip.get("total_seats") or 0
|
| 94 |
price = trip.get("price") or ""
|
|
|
|
| 95 |
|
| 96 |
lines = [
|
| 97 |
"─" * 14,
|
|
@@ -101,6 +102,7 @@ def format_trip_card(trip: dict[str, Any]) -> str:
|
|
| 101 |
f"السعر: {price}" if price else "",
|
| 102 |
f"السيارة: {car_type}" if car_type else "",
|
| 103 |
f"السائق: {driver_name}" if driver_name else "",
|
|
|
|
| 104 |
"─" * 14,
|
| 105 |
]
|
| 106 |
return "\n".join(line for line in lines if line)
|
|
|
|
| 92 |
available = trip.get("available_seats") or 0
|
| 93 |
total = trip.get("total_seats") or 0
|
| 94 |
price = trip.get("price") or ""
|
| 95 |
+
selection_count = trip.get("selection_count")
|
| 96 |
|
| 97 |
lines = [
|
| 98 |
"─" * 14,
|
|
|
|
| 102 |
f"السعر: {price}" if price else "",
|
| 103 |
f"السيارة: {car_type}" if car_type else "",
|
| 104 |
f"السائق: {driver_name}" if driver_name else "",
|
| 105 |
+
f"عدد المهتمين: {selection_count}" if selection_count is not None else "",
|
| 106 |
"─" * 14,
|
| 107 |
]
|
| 108 |
return "\n".join(line for line in lines if line)
|
prompts/falsa_info.md
CHANGED
|
@@ -1,23 +1,25 @@
|
|
| 1 |
# FALSA Information Seed
|
| 2 |
|
| 3 |
## Company
|
| 4 |
-
FALSA is an AI-powered travel
|
| 5 |
-
about car and bus trips through WhatsApp, search available routes, and
|
| 6 |
-
|
| 7 |
|
| 8 |
## How The Service Works
|
| 9 |
Customers send a message on WhatsApp. FALSA checks available active trips, shares
|
| 10 |
-
matching options, and
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
## Pricing
|
| 14 |
Trip prices depend on route, vehicle type, seat availability, and driver pricing.
|
| 15 |
FALSA should only share prices returned by the trip search results.
|
| 16 |
|
| 17 |
## Policies
|
| 18 |
-
FALSA does not
|
| 19 |
-
|
| 20 |
-
accurate travel details, preferred time, number of seats,
|
|
|
|
| 21 |
|
| 22 |
## Support
|
| 23 |
If FALSA cannot find a suitable answer or trip, the assistant should collect the
|
|
|
|
| 1 |
# FALSA Information Seed
|
| 2 |
|
| 3 |
## Company
|
| 4 |
+
FALSA is an AI-powered travel service platform on WhatsApp. Customers can ask
|
| 5 |
+
about car and bus trips through WhatsApp, search available routes, and express
|
| 6 |
+
interest in a trip — the driver and customer then coordinate directly.
|
| 7 |
|
| 8 |
## How The Service Works
|
| 9 |
Customers send a message on WhatsApp. FALSA checks available active trips, shares
|
| 10 |
+
matching options, and records the customer's interest when they select a trip.
|
| 11 |
+
Seats are not reserved. The driver is notified and the customer receives the
|
| 12 |
+
driver's phone number to arrange the booking directly between themselves.
|
| 13 |
|
| 14 |
## Pricing
|
| 15 |
Trip prices depend on route, vehicle type, seat availability, and driver pricing.
|
| 16 |
FALSA should only share prices returned by the trip search results.
|
| 17 |
|
| 18 |
## Policies
|
| 19 |
+
FALSA does not handle reservations, confirmations, or payments. Customers select
|
| 20 |
+
a trip, receive the driver's contact, and coordinate directly with the driver.
|
| 21 |
+
Customers should provide accurate travel details, preferred time, number of seats,
|
| 22 |
+
and pickup notes.
|
| 23 |
|
| 24 |
## Support
|
| 25 |
If FALSA cannot find a suitable answer or trip, the assistant should collect the
|
pyproject.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
[project]
|
| 2 |
name = "falsa"
|
| 3 |
version = "0.1.0"
|
| 4 |
-
description = "AI-powered WhatsApp travel
|
| 5 |
requires-python = ">=3.12"
|
| 6 |
dependencies = [
|
| 7 |
"fastapi>=0.115.0",
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "falsa"
|
| 3 |
version = "0.1.0"
|
| 4 |
+
description = "AI-powered WhatsApp travel service backend"
|
| 5 |
requires-python = ">=3.12"
|
| 6 |
dependencies = [
|
| 7 |
"fastapi>=0.115.0",
|
tests/conftest.py
CHANGED
|
@@ -106,7 +106,7 @@ class FakeRepository:
|
|
| 106 |
self.created_trips: list[dict[str, Any]] = []
|
| 107 |
self.trip_embeddings: list[dict[str, Any]] = []
|
| 108 |
self.messages: list[dict[str, Any]] = []
|
| 109 |
-
self.
|
| 110 |
self.notification_updates: list[dict[str, Any]] = []
|
| 111 |
self.trips_by_id: dict[str, dict[str, Any]] = {}
|
| 112 |
self.active_search_results: list[dict[str, Any]] = []
|
|
@@ -277,7 +277,7 @@ class FakeRepository:
|
|
| 277 |
match_count = int(kwargs.get("match_count") or 10)
|
| 278 |
return self.trip_vector_search_results[:match_count]
|
| 279 |
|
| 280 |
-
async def
|
| 281 |
self,
|
| 282 |
*,
|
| 283 |
customer_id: str,
|
|
@@ -285,27 +285,30 @@ class FakeRepository:
|
|
| 285 |
requested_seats: int,
|
| 286 |
notes: str | None,
|
| 287 |
) -> dict[str, Any]:
|
| 288 |
-
|
| 289 |
-
"id": f"
|
| 290 |
"customer_id": customer_id,
|
| 291 |
"trip_id": trip_id,
|
| 292 |
"requested_seats": requested_seats,
|
| 293 |
"notes": notes,
|
| 294 |
}
|
| 295 |
-
self.
|
| 296 |
-
return
|
| 297 |
|
| 298 |
-
async def
|
| 299 |
self,
|
| 300 |
*,
|
| 301 |
-
|
| 302 |
status: str,
|
| 303 |
metadata: dict[str, Any] | None = None,
|
| 304 |
) -> dict[str, Any]:
|
| 305 |
-
update = {"
|
| 306 |
self.notification_updates.append(update)
|
| 307 |
return update
|
| 308 |
|
|
|
|
|
|
|
|
|
|
| 309 |
async def get_driver_by_phone(self, remote_jid: str) -> dict[str, Any] | None:
|
| 310 |
return self.drivers_by_remote_jid.get(remote_jid)
|
| 311 |
|
|
|
|
| 106 |
self.created_trips: list[dict[str, Any]] = []
|
| 107 |
self.trip_embeddings: list[dict[str, Any]] = []
|
| 108 |
self.messages: list[dict[str, Any]] = []
|
| 109 |
+
self.trip_selections: list[dict[str, Any]] = []
|
| 110 |
self.notification_updates: list[dict[str, Any]] = []
|
| 111 |
self.trips_by_id: dict[str, dict[str, Any]] = {}
|
| 112 |
self.active_search_results: list[dict[str, Any]] = []
|
|
|
|
| 277 |
match_count = int(kwargs.get("match_count") or 10)
|
| 278 |
return self.trip_vector_search_results[:match_count]
|
| 279 |
|
| 280 |
+
async def create_trip_selection(
|
| 281 |
self,
|
| 282 |
*,
|
| 283 |
customer_id: str,
|
|
|
|
| 285 |
requested_seats: int,
|
| 286 |
notes: str | None,
|
| 287 |
) -> dict[str, Any]:
|
| 288 |
+
selection = {
|
| 289 |
+
"id": f"sel-{len(self.trip_selections) + 1}",
|
| 290 |
"customer_id": customer_id,
|
| 291 |
"trip_id": trip_id,
|
| 292 |
"requested_seats": requested_seats,
|
| 293 |
"notes": notes,
|
| 294 |
}
|
| 295 |
+
self.trip_selections.append(selection)
|
| 296 |
+
return selection
|
| 297 |
|
| 298 |
+
async def update_selection_notification(
|
| 299 |
self,
|
| 300 |
*,
|
| 301 |
+
selection_id: str,
|
| 302 |
status: str,
|
| 303 |
metadata: dict[str, Any] | None = None,
|
| 304 |
) -> dict[str, Any]:
|
| 305 |
+
update = {"selection_id": selection_id, "status": status, "metadata": metadata}
|
| 306 |
self.notification_updates.append(update)
|
| 307 |
return update
|
| 308 |
|
| 309 |
+
async def count_trip_selections(self, trip_id: str) -> int:
|
| 310 |
+
return sum(1 for s in self.trip_selections if s["trip_id"] == trip_id)
|
| 311 |
+
|
| 312 |
async def get_driver_by_phone(self, remote_jid: str) -> dict[str, Any] | None:
|
| 313 |
return self.drivers_by_remote_jid.get(remote_jid)
|
| 314 |
|
tests/test_conversation_service.py
CHANGED
|
@@ -117,7 +117,7 @@ async def test_conversation_uses_passenger_tools_when_user_mode_is_passenger(set
|
|
| 117 |
assert tool_names == {
|
| 118 |
"about_falsa",
|
| 119 |
"search_trips",
|
| 120 |
-
"
|
| 121 |
"create_driver_account",
|
| 122 |
"switch_to_driver",
|
| 123 |
}
|
|
|
|
| 117 |
assert tool_names == {
|
| 118 |
"about_falsa",
|
| 119 |
"search_trips",
|
| 120 |
+
"select_trip",
|
| 121 |
"create_driver_account",
|
| 122 |
"switch_to_driver",
|
| 123 |
}
|
tests/test_tool_schemas.py
CHANGED
|
@@ -14,7 +14,7 @@ def test_new_user_tools_are_onboarding_only():
|
|
| 14 |
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 "
|
| 18 |
assert "initiate_trip_action" in names
|
| 19 |
assert "update_trip_field" in names
|
| 20 |
assert "switch_to_passenger" in names
|
|
|
|
| 14 |
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 "select_trip" not in names
|
| 18 |
assert "initiate_trip_action" in names
|
| 19 |
assert "update_trip_field" in names
|
| 20 |
assert "switch_to_passenger" in names
|
tests/test_tools.py
CHANGED
|
@@ -222,7 +222,7 @@ async def test_search_trips_includes_alternate_alert_when_first_result_is_over_o
|
|
| 222 |
|
| 223 |
|
| 224 |
@pytest.mark.asyncio
|
| 225 |
-
async def
|
| 226 |
repository = FakeRepository()
|
| 227 |
repository.trips_by_id["trip-1"] = trip()
|
| 228 |
whatsapp = FakeWhatsApp()
|
|
@@ -232,18 +232,18 @@ async def test_create_booking_lead_notifies_driver():
|
|
| 232 |
customer={"id": "cust-1", "remoteJid": "967700000001", "name": "Mona"},
|
| 233 |
)
|
| 234 |
|
| 235 |
-
result = await handlers.
|
| 236 |
{"trip_id": "trip-1", "requested_seats": 2, "notes": "Window seat"}
|
| 237 |
)
|
| 238 |
|
| 239 |
assert result.ok is True
|
| 240 |
assert result.data["driver_notification_status"] == "sent"
|
| 241 |
-
assert repository.
|
| 242 |
assert whatsapp.sent[0][0] == "967700000009"
|
| 243 |
|
| 244 |
|
| 245 |
@pytest.mark.asyncio
|
| 246 |
-
async def
|
| 247 |
repository = FakeRepository()
|
| 248 |
repository.trips_by_id["trip-1"] = trip(driver_phone_number="967700000099")
|
| 249 |
whatsapp = FakeWhatsApp()
|
|
@@ -253,7 +253,7 @@ async def test_create_booking_lead_uses_driver_phone_number():
|
|
| 253 |
customer={"id": "cust-1", "remoteJid": "967700000001", "name": "Mona"},
|
| 254 |
)
|
| 255 |
|
| 256 |
-
result = await handlers.
|
| 257 |
{"trip_id": "trip-1", "requested_seats": 1}
|
| 258 |
)
|
| 259 |
|
|
@@ -264,7 +264,7 @@ async def test_create_booking_lead_uses_driver_phone_number():
|
|
| 264 |
|
| 265 |
|
| 266 |
@pytest.mark.asyncio
|
| 267 |
-
async def
|
| 268 |
repository = FakeRepository()
|
| 269 |
repository.trips_by_id["trip-1"] = trip()
|
| 270 |
handlers = make_handlers(
|
|
@@ -272,7 +272,7 @@ async def test_create_booking_lead_keeps_pending_when_driver_notification_fails(
|
|
| 272 |
whatsapp=FakeWhatsApp(fail=True),
|
| 273 |
)
|
| 274 |
|
| 275 |
-
result = await handlers.
|
| 276 |
|
| 277 |
assert result.ok is True
|
| 278 |
assert result.data["status"] == "pending"
|
|
@@ -281,16 +281,16 @@ async def test_create_booking_lead_keeps_pending_when_driver_notification_fails(
|
|
| 281 |
|
| 282 |
|
| 283 |
@pytest.mark.asyncio
|
| 284 |
-
async def
|
| 285 |
repository = FakeRepository()
|
| 286 |
repository.trips_by_id["trip-1"] = trip(available_seats=1)
|
| 287 |
handlers = make_handlers(repository=repository)
|
| 288 |
|
| 289 |
-
result = await handlers.
|
| 290 |
|
| 291 |
assert result.ok is False
|
| 292 |
assert result.error == "Not enough available seats"
|
| 293 |
-
assert repository.
|
| 294 |
|
| 295 |
|
| 296 |
@pytest.mark.asyncio
|
|
|
|
| 222 |
|
| 223 |
|
| 224 |
@pytest.mark.asyncio
|
| 225 |
+
async def test_select_trip_notifies_driver():
|
| 226 |
repository = FakeRepository()
|
| 227 |
repository.trips_by_id["trip-1"] = trip()
|
| 228 |
whatsapp = FakeWhatsApp()
|
|
|
|
| 232 |
customer={"id": "cust-1", "remoteJid": "967700000001", "name": "Mona"},
|
| 233 |
)
|
| 234 |
|
| 235 |
+
result = await handlers.select_trip(
|
| 236 |
{"trip_id": "trip-1", "requested_seats": 2, "notes": "Window seat"}
|
| 237 |
)
|
| 238 |
|
| 239 |
assert result.ok is True
|
| 240 |
assert result.data["driver_notification_status"] == "sent"
|
| 241 |
+
assert repository.trip_selections[0]["requested_seats"] == 2
|
| 242 |
assert whatsapp.sent[0][0] == "967700000009"
|
| 243 |
|
| 244 |
|
| 245 |
@pytest.mark.asyncio
|
| 246 |
+
async def test_select_trip_uses_driver_phone_number():
|
| 247 |
repository = FakeRepository()
|
| 248 |
repository.trips_by_id["trip-1"] = trip(driver_phone_number="967700000099")
|
| 249 |
whatsapp = FakeWhatsApp()
|
|
|
|
| 253 |
customer={"id": "cust-1", "remoteJid": "967700000001", "name": "Mona"},
|
| 254 |
)
|
| 255 |
|
| 256 |
+
result = await handlers.select_trip(
|
| 257 |
{"trip_id": "trip-1", "requested_seats": 1}
|
| 258 |
)
|
| 259 |
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
@pytest.mark.asyncio
|
| 267 |
+
async def test_select_trip_keeps_pending_when_driver_notification_fails():
|
| 268 |
repository = FakeRepository()
|
| 269 |
repository.trips_by_id["trip-1"] = trip()
|
| 270 |
handlers = make_handlers(
|
|
|
|
| 272 |
whatsapp=FakeWhatsApp(fail=True),
|
| 273 |
)
|
| 274 |
|
| 275 |
+
result = await handlers.select_trip({"trip_id": "trip-1", "requested_seats": 1})
|
| 276 |
|
| 277 |
assert result.ok is True
|
| 278 |
assert result.data["status"] == "pending"
|
|
|
|
| 281 |
|
| 282 |
|
| 283 |
@pytest.mark.asyncio
|
| 284 |
+
async def test_select_trip_rejects_insufficient_seats():
|
| 285 |
repository = FakeRepository()
|
| 286 |
repository.trips_by_id["trip-1"] = trip(available_seats=1)
|
| 287 |
handlers = make_handlers(repository=repository)
|
| 288 |
|
| 289 |
+
result = await handlers.select_trip({"trip_id": "trip-1", "requested_seats": 2})
|
| 290 |
|
| 291 |
assert result.ok is False
|
| 292 |
assert result.error == "Not enough available seats"
|
| 293 |
+
assert repository.trip_selections == []
|
| 294 |
|
| 295 |
|
| 296 |
@pytest.mark.asyncio
|