suppress LLM text reply after search_trips sends trip cards
Browse files- Add suppress_llm_reply flag to ToolResult (not serialized to LLM)
- search_trips handler now sends a 'choose a card' prompt and sets
suppress_llm_reply=True when trips are found
- Orchestrator returns empty string immediately when flag is set,
avoiding a second LLM call
- Guard conversation service against sending empty replies
- Update system prompt and tool schema to reflect new behavior
- app/ai/orchestrator.py +7 -5
- app/ai/tool_schemas.py +2 -2
- app/models/domain.py +1 -0
- app/services/conversation_service.py +3 -0
- app/tools/handlers.py +25 -9
- prompts/system_passenger.md +1 -1
app/ai/orchestrator.py
CHANGED
|
@@ -7,7 +7,7 @@ from app.ai.providers import (
|
|
| 7 |
InvalidToolCallGenerationError,
|
| 8 |
RetryableProviderError,
|
| 9 |
)
|
| 10 |
-
from app.models.domain import AIProviderResponse, ToolCall
|
| 11 |
from app.tools.registry import ToolRegistry
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
|
@@ -93,12 +93,14 @@ class AIOrchestrator:
|
|
| 93 |
logger.warning("++++++++"+str(tool_call)+ "&&&"+ str(registry))
|
| 94 |
result = await _execute_tool_call(registry, tool_call)
|
| 95 |
logger.warning("+++++++++++++"+str(result))
|
|
|
|
|
|
|
| 96 |
working_messages.append(
|
| 97 |
{
|
| 98 |
"role": "tool",
|
| 99 |
"tool_call_id": tool_call.id,
|
| 100 |
"name": tool_call.name,
|
| 101 |
-
"content": json.dumps(result, ensure_ascii=False),
|
| 102 |
}
|
| 103 |
)
|
| 104 |
|
|
@@ -125,12 +127,12 @@ def _assistant_tool_message(response: AIProviderResponse) -> dict[str, Any]:
|
|
| 125 |
}
|
| 126 |
|
| 127 |
|
| 128 |
-
async def _execute_tool_call(registry: ToolRegistry, tool_call: ToolCall) ->
|
| 129 |
try:
|
| 130 |
arguments = json.loads(tool_call.arguments or "{}")
|
| 131 |
if not isinstance(arguments, dict):
|
| 132 |
raise ValueError("Tool arguments must be a JSON object")
|
| 133 |
except (json.JSONDecodeError, ValueError) as exc:
|
| 134 |
-
return
|
| 135 |
|
| 136 |
-
return
|
|
|
|
| 7 |
InvalidToolCallGenerationError,
|
| 8 |
RetryableProviderError,
|
| 9 |
)
|
| 10 |
+
from app.models.domain import AIProviderResponse, ToolCall, ToolResult
|
| 11 |
from app.tools.registry import ToolRegistry
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
|
|
|
| 93 |
logger.warning("++++++++"+str(tool_call)+ "&&&"+ str(registry))
|
| 94 |
result = await _execute_tool_call(registry, tool_call)
|
| 95 |
logger.warning("+++++++++++++"+str(result))
|
| 96 |
+
if result.suppress_llm_reply:
|
| 97 |
+
return ""
|
| 98 |
working_messages.append(
|
| 99 |
{
|
| 100 |
"role": "tool",
|
| 101 |
"tool_call_id": tool_call.id,
|
| 102 |
"name": tool_call.name,
|
| 103 |
+
"content": json.dumps(result.to_payload(), ensure_ascii=False),
|
| 104 |
}
|
| 105 |
)
|
| 106 |
|
|
|
|
| 127 |
}
|
| 128 |
|
| 129 |
|
| 130 |
+
async def _execute_tool_call(registry: ToolRegistry, tool_call: ToolCall) -> ToolResult:
|
| 131 |
try:
|
| 132 |
arguments = json.loads(tool_call.arguments or "{}")
|
| 133 |
if not isinstance(arguments, dict):
|
| 134 |
raise ValueError("Tool arguments must be a JSON object")
|
| 135 |
except (json.JSONDecodeError, ValueError) as exc:
|
| 136 |
+
return ToolResult(ok=False, data={}, error=f"Invalid tool arguments: {exc}")
|
| 137 |
|
| 138 |
+
return await registry.execute(tool_call.name, arguments)
|
app/ai/tool_schemas.py
CHANGED
|
@@ -37,8 +37,8 @@ _SEARCH_TRIPS = {
|
|
| 37 |
"Use when the customer asks for travel options. "
|
| 38 |
"You can search with only departure or only destination"
|
| 39 |
" — the other will match any location. "
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
),
|
| 43 |
"parameters": {
|
| 44 |
"type": "object",
|
|
|
|
| 37 |
"Use when the customer asks for travel options. "
|
| 38 |
"You can search with only departure or only destination"
|
| 39 |
" — the other will match any location. "
|
| 40 |
+
"Matching trips are sent as WhatsApp cards and user is prompted to pick one. "
|
| 41 |
+
"Do NOT add follow-up text — response is handled automatically."
|
| 42 |
),
|
| 43 |
"parameters": {
|
| 44 |
"type": "object",
|
app/models/domain.py
CHANGED
|
@@ -39,6 +39,7 @@ class ToolResult:
|
|
| 39 |
ok: bool
|
| 40 |
data: dict[str, Any]
|
| 41 |
error: str | None = None
|
|
|
|
| 42 |
|
| 43 |
def to_payload(self) -> dict[str, Any]:
|
| 44 |
payload: dict[str, Any] = {"ok": self.ok, "data": self.data}
|
|
|
|
| 39 |
ok: bool
|
| 40 |
data: dict[str, Any]
|
| 41 |
error: str | None = None
|
| 42 |
+
suppress_llm_reply: bool = False
|
| 43 |
|
| 44 |
def to_payload(self) -> dict[str, Any]:
|
| 45 |
payload: dict[str, Any] = {"ok": self.ok, "data": self.data}
|
app/services/conversation_service.py
CHANGED
|
@@ -115,6 +115,9 @@ class ConversationService:
|
|
| 115 |
registry=registry,
|
| 116 |
)
|
| 117 |
|
|
|
|
|
|
|
|
|
|
| 118 |
await self.whatsapp.send_text(inbound.remoteJid, reply)
|
| 119 |
|
| 120 |
await self.repository.create_message(
|
|
|
|
| 115 |
registry=registry,
|
| 116 |
)
|
| 117 |
|
| 118 |
+
if not reply:
|
| 119 |
+
return reply
|
| 120 |
+
|
| 121 |
await self.whatsapp.send_text(inbound.remoteJid, reply)
|
| 122 |
|
| 123 |
await self.repository.create_message(
|
app/tools/handlers.py
CHANGED
|
@@ -182,19 +182,35 @@ class FalsaToolHandlers:
|
|
| 182 |
except WhatsAppClientError:
|
| 183 |
logger.warning("Failed to send trip card for trip %s", trip_id)
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
return ToolResult(
|
| 186 |
ok=True,
|
| 187 |
data={
|
| 188 |
-
"count":
|
| 189 |
-
"matches":
|
| 190 |
"alternate_alert": alternate_alert,
|
| 191 |
-
"sent_as_messages":
|
| 192 |
-
"note":
|
| 193 |
-
"No active matching trips were found."
|
| 194 |
-
if not top_trips
|
| 195 |
-
else "Trips were sent as separate WhatsApp messages. "
|
| 196 |
-
"Ask the user to reply to a trip card to select it."
|
| 197 |
-
),
|
| 198 |
},
|
| 199 |
)
|
| 200 |
|
|
|
|
| 182 |
except WhatsAppClientError:
|
| 183 |
logger.warning("Failed to send trip card for trip %s", trip_id)
|
| 184 |
|
| 185 |
+
prompt = "يرجى الرد على إحدى بطاقات الرحلات أعلاه لاختيار رحلتك"
|
| 186 |
+
await self.whatsapp.send_text(self.remoteJid, prompt)
|
| 187 |
+
await self.repository.create_message(
|
| 188 |
+
customer_id=str(self.customer["id"]),
|
| 189 |
+
sender_type="assistant",
|
| 190 |
+
message=prompt,
|
| 191 |
+
metadata={"type": "trip_selection_prompt"},
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
return ToolResult(
|
| 195 |
+
ok=True,
|
| 196 |
+
data={
|
| 197 |
+
"count": len(top_trips),
|
| 198 |
+
"matches": top_trips,
|
| 199 |
+
"alternate_alert": alternate_alert,
|
| 200 |
+
"sent_as_messages": True,
|
| 201 |
+
"note": "Cards sent. No text reply needed.",
|
| 202 |
+
},
|
| 203 |
+
suppress_llm_reply=True,
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
return ToolResult(
|
| 207 |
ok=True,
|
| 208 |
data={
|
| 209 |
+
"count": 0,
|
| 210 |
+
"matches": [],
|
| 211 |
"alternate_alert": alternate_alert,
|
| 212 |
+
"sent_as_messages": False,
|
| 213 |
+
"note": "No active matching trips were found.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
},
|
| 215 |
)
|
| 216 |
|
prompts/system_passenger.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
- search_trips as soon as the user mentions a departure or destination. You do not need all details — the tool accepts partial info. Only ask a follow-up if neither departure nor destination was mentioned.
|
| 2 |
-
- When search_trips
|
| 3 |
- When the user replies to a trip card, immediately call create_booking_lead — trip_id is auto-detected and seats default to 1. Do not ask for seat count or other details.
|
| 4 |
- Booking leads are pending — seats not reserved.
|
| 5 |
- After successful create_booking_lead, tell the passenger the driver's phone number from driver_phone so they can contact the driver directly.
|
|
|
|
| 1 |
- search_trips as soon as the user mentions a departure or destination. You do not need all details — the tool accepts partial info. Only ask a follow-up if neither departure nor destination was mentioned.
|
| 2 |
+
- When search_trips finds matches, trip cards are sent to the user automatically along with a prompt to choose one. Do NOT add any text after calling search_trips — the tool will handle the response.
|
| 3 |
- When the user replies to a trip card, immediately call create_booking_lead — trip_id is auto-detected and seats default to 1. Do not ask for seat count or other details.
|
| 4 |
- Booking leads are pending — seats not reserved.
|
| 5 |
- After successful create_booking_lead, tell the passenger the driver's phone number from driver_phone so they can contact the driver directly.
|