- LLMProvider interface: AnthropicLLM + ScriptedLLM test fake - System prompt composition from super-admin template + structured tenant data only (§6.3); auto-generated greetings with recording disclosure, out-of-hours variant, chat variant - Agent tools (§6.5): get_salon_info, check_availability (≤8 slots to model, agent offers ≤3), submit_booking_request (caller-ID fallback, hallucinated service-id guard), take_message - Conversation loop with tool dispatch, round-limit guard, transcript capture, outcome classification (request_created/message_taken/info_only/abandoned) - Terminal playground (python -m gogo.agent.cli) + demo seed (python -m gogo.seed) - Deterministic loop tests (ScriptedLLM) + Appendix A golden-scenario acceptance tests against a real LLM (skipped without an API key) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""Agent tools (§6.5) — identical for voice and chat.
|
|
|
|
Definitions follow the Anthropic tool-use schema; ToolExecutor binds them to a
|
|
tenant/session and records side effects (created requests, messages) so the
|
|
caller can classify the conversation outcome (§10 call history).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import date, datetime, timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from gogo.domain import Slot
|
|
from gogo.hours import DEFAULT_WORKING_HOURS, hours_summary_bs
|
|
from gogo.i18n import fmt_price, fmt_slot
|
|
from gogo.models import MessageForOwner, Service, Tenant
|
|
from gogo.proposals import engine as proposal_engine
|
|
from gogo.scheduling.base import get_provider
|
|
|
|
log = logging.getLogger("gogo.agent.tools")
|
|
|
|
MAX_SLOTS_TO_LLM = 8 # enough context to match preferences; agent offers ≤3
|
|
|
|
TOOL_DEFINITIONS: list[dict] = [
|
|
{
|
|
"name": "get_salon_info",
|
|
"description": (
|
|
"Working hours, address and the full services list (with ids, durations, "
|
|
"prices and notes) of the salon."
|
|
),
|
|
"input_schema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
},
|
|
{
|
|
"name": "check_availability",
|
|
"description": (
|
|
"Real free slots for a service in a date range. Returns at most "
|
|
f"{MAX_SLOTS_TO_LLM} slots; offer the caller AT MOST 3 that best match "
|
|
"their stated preference. Dates are ISO YYYY-MM-DD, inclusive."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"service_id": {"type": "string", "description": "id from the services table"},
|
|
"date_from": {"type": "string", "description": "ISO date, first day to check"},
|
|
"date_to": {"type": "string", "description": "ISO date, last day to check"},
|
|
"home_visit": {"type": "boolean", "default": False},
|
|
},
|
|
"required": ["service_id", "date_from", "date_to"],
|
|
},
|
|
},
|
|
{
|
|
"name": "submit_booking_request",
|
|
"description": (
|
|
"Create the booking request (proposal) for the salon owner. Call this once "
|
|
"you have: the service, the client's name, a contact phone number and "
|
|
"either concrete slots the client accepted (max 3, ordered by preference) "
|
|
"or a free-text time preference (e.g. for home visits). This does NOT "
|
|
"confirm the appointment — the salon will contact the client."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"service_id": {
|
|
"type": "string",
|
|
"description": "id from the services table; omit if no clear match",
|
|
},
|
|
"service_name_raw": {
|
|
"type": "string",
|
|
"description": "the service in the client's own words",
|
|
},
|
|
"client_name": {"type": "string"},
|
|
"client_phone": {"type": "string"},
|
|
"slots": {
|
|
"type": "array",
|
|
"maxItems": 3,
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"start": {"type": "string", "description": "ISO 8601 with offset"},
|
|
"end": {"type": "string", "description": "ISO 8601 with offset"},
|
|
},
|
|
"required": ["start", "end"],
|
|
},
|
|
"description": "accepted slots from check_availability, best first",
|
|
},
|
|
"time_preference_text": {
|
|
"type": "string",
|
|
"description": "client's time preference in words, when no exact slot",
|
|
},
|
|
"home_visit": {"type": "boolean", "default": False},
|
|
"address": {"type": "string", "description": "address/area for home visits"},
|
|
"summary": {
|
|
"type": "string",
|
|
"description": (
|
|
"short Bosnian summary of what the client wants / their problem, "
|
|
"for the owner"
|
|
),
|
|
},
|
|
},
|
|
"required": ["client_name", "client_phone", "summary"],
|
|
},
|
|
},
|
|
{
|
|
"name": "take_message",
|
|
"description": (
|
|
"Leave a message for the salon owner (cancellations, reschedules, questions "
|
|
"you cannot answer, unintelligible calls). The owner will call the client back."
|
|
),
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {"type": "string", "description": "the message, in Bosnian"},
|
|
"client_name": {"type": "string"},
|
|
"phone": {"type": "string", "description": "callback number"},
|
|
},
|
|
"required": ["text"],
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
class ToolExecutor:
|
|
"""Executes tool calls for one conversation."""
|
|
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
tenant: Tenant,
|
|
*,
|
|
source: str, # "voice" | "chat"
|
|
caller_phone: str = "",
|
|
call_id: uuid.UUID | None = None,
|
|
chat_session_id: uuid.UUID | None = None,
|
|
):
|
|
self.session = session
|
|
self.tenant = tenant
|
|
self.source = source
|
|
self.caller_phone = caller_phone
|
|
self.call_id = call_id
|
|
self.chat_session_id = chat_session_id
|
|
# side effects for outcome classification
|
|
self.created_request_id: uuid.UUID | None = None
|
|
self.took_message: bool = False
|
|
self.tool_calls: list[tuple[str, dict]] = [] # (name, input) log for the trace
|
|
|
|
async def execute(self, name: str, tool_input: dict) -> str:
|
|
"""Run one tool; always returns a JSON string for the tool_result block."""
|
|
self.tool_calls.append((name, tool_input))
|
|
try:
|
|
handler = {
|
|
"get_salon_info": self._get_salon_info,
|
|
"check_availability": self._check_availability,
|
|
"submit_booking_request": self._submit_booking_request,
|
|
"take_message": self._take_message,
|
|
}.get(name)
|
|
if handler is None:
|
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
result = await handler(tool_input)
|
|
return json.dumps(result, ensure_ascii=False)
|
|
except Exception as e: # noqa: BLE001 — the model must get a usable error
|
|
log.exception("tool %s failed (tenant %s)", name, self.tenant.slug)
|
|
return json.dumps(
|
|
{"error": "internal_error", "message": f"Tool failed: {type(e).__name__}"},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
async def _get_salon_info(self, _: dict) -> dict:
|
|
services = (
|
|
(
|
|
await self.session.execute(
|
|
select(Service).where(
|
|
Service.tenant_id == self.tenant.id, Service.active.is_(True)
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return {
|
|
"name": self.tenant.name,
|
|
"address": self.tenant.address,
|
|
"city": self.tenant.city,
|
|
"working_hours": hours_summary_bs(self.tenant.working_hours or DEFAULT_WORKING_HOURS),
|
|
"services": [
|
|
{
|
|
"id": str(s.id),
|
|
"name": s.name,
|
|
"duration_min": s.duration_min,
|
|
"price": fmt_price(s.price_min, s.price_max),
|
|
"home_visit": s.home_visit,
|
|
"note": s.agent_note,
|
|
}
|
|
for s in services
|
|
],
|
|
"notes": self.tenant.agent_notes,
|
|
}
|
|
|
|
async def _check_availability(self, tool_input: dict) -> dict:
|
|
service_id = str(tool_input["service_id"])
|
|
try:
|
|
date_from = date.fromisoformat(str(tool_input["date_from"]))
|
|
date_to = date.fromisoformat(str(tool_input["date_to"]))
|
|
except ValueError:
|
|
return {"error": "bad_date", "message": "Dates must be ISO YYYY-MM-DD"}
|
|
if date_to < date_from:
|
|
date_from, date_to = date_to, date_from
|
|
# guard against runaway ranges — provider horizon also applies
|
|
if (date_to - date_from) > timedelta(days=31):
|
|
date_to = date_from + timedelta(days=31)
|
|
|
|
provider = await get_provider(self.session, self.tenant)
|
|
slots = await provider.get_availability(
|
|
service_id, date_from, date_to, home_visit=bool(tool_input.get("home_visit"))
|
|
)
|
|
return {
|
|
"slots": [
|
|
{
|
|
"start": s.start.isoformat(),
|
|
"end": s.end.isoformat(),
|
|
"label": fmt_slot(s.start, self.tenant.timezone),
|
|
**({"staff_name": s.staff_name} if s.staff_name else {}),
|
|
}
|
|
for s in slots[:MAX_SLOTS_TO_LLM]
|
|
],
|
|
"note": (
|
|
"No free slots in this range — offer the client to leave a preference "
|
|
"or check other days."
|
|
if not slots
|
|
else "Offer the client AT MOST 3 of these that match their preference."
|
|
),
|
|
}
|
|
|
|
async def _submit_booking_request(self, tool_input: dict) -> dict:
|
|
slots: list[Slot] = []
|
|
for s in (tool_input.get("slots") or [])[:3]:
|
|
try:
|
|
slots.append(
|
|
Slot(
|
|
start=datetime.fromisoformat(s["start"]),
|
|
end=datetime.fromisoformat(s["end"]),
|
|
)
|
|
)
|
|
except (KeyError, ValueError):
|
|
continue
|
|
service_id = tool_input.get("service_id") or None
|
|
if service_id:
|
|
# tolerate hallucinated ids: verify it belongs to this tenant
|
|
try:
|
|
svc = (
|
|
await self.session.execute(
|
|
select(Service).where(
|
|
Service.id == uuid.UUID(str(service_id)),
|
|
Service.tenant_id == self.tenant.id,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
except ValueError:
|
|
svc = None
|
|
if svc is None:
|
|
service_id = None
|
|
|
|
client_phone = (tool_input.get("client_phone") or "").strip() or self.caller_phone
|
|
if not client_phone:
|
|
return {
|
|
"error": "missing_phone",
|
|
"message": "A contact phone number is required before submitting.",
|
|
}
|
|
|
|
req, result = await proposal_engine.create_booking_request(
|
|
self.session,
|
|
self.tenant,
|
|
source=self.source,
|
|
client_name=str(tool_input.get("client_name", "")).strip(),
|
|
client_phone=client_phone,
|
|
service_id=str(service_id) if service_id else None,
|
|
service_name_raw=str(tool_input.get("service_name_raw", "")),
|
|
slots=slots,
|
|
time_preference_text=str(tool_input.get("time_preference_text", "")),
|
|
home_visit=bool(tool_input.get("home_visit")),
|
|
address=(tool_input.get("address") or None),
|
|
summary=str(tool_input.get("summary", "")),
|
|
call_id=self.call_id,
|
|
chat_session_id=self.chat_session_id,
|
|
)
|
|
self.created_request_id = req.id
|
|
return {
|
|
"ok": True,
|
|
"request_id": str(req.id),
|
|
"delivered": result.ok,
|
|
"note": (
|
|
"Request forwarded to the salon. Tell the client the salon will contact "
|
|
"them to confirm — do NOT present the appointment as booked."
|
|
),
|
|
}
|
|
|
|
async def _take_message(self, tool_input: dict) -> dict:
|
|
msg = MessageForOwner(
|
|
tenant_id=self.tenant.id,
|
|
client_name=str(tool_input.get("client_name", "")),
|
|
client_phone=str(tool_input.get("phone", "") or self.caller_phone),
|
|
text=str(tool_input["text"]),
|
|
source=self.source,
|
|
)
|
|
self.session.add(msg)
|
|
await self.session.flush()
|
|
self.took_message = True
|
|
return {"ok": True, "note": "Message saved for the owner."}
|