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>
This commit is contained in:
312
gogo/agent/tools.py
Normal file
312
gogo/agent/tools.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""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."}
|
||||
Reference in New Issue
Block a user