Files
gogo-telefon/gogo/agent/tools.py
Senad Uka 0d0838e6e2 M2: agent logic in text mode
- 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>
2026-07-11 09:53:08 +02:00

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."}