Quillwright / quillwright /api /tools_api.py
Aarya2004
Deploy: sync hosted Space to local app (chat, document capture, Modal backends, pages, mobile/QR)
47b2a99
Raw
History Blame Contribute Delete
5.79 kB
"""Quillwright tool endpoints for a voice agent (ElevenLabs Conversational AI).
ElevenLabs owns the phone call, the speech-to-text, the dialogue, and the (great) TTS.
Quillwright stays the source of truth: the agent calls these small JSON tools to forge
and refine an estimate, and every customer-facing number comes from a tool response —
never the agent's free speech (Facts-from-Tools, ADR-0004).
Each tool takes a ``session_id`` (the agent passes its conversation id) so refinement
turns edit the same estimate. State is in-process and demo-scoped, like the pairing store
and the Twilio call state — a durable backend is a swap behind this same interface.
Tools:
- ``forge(session_id, description)`` → itemized estimate + total from a job description
- ``edit(session_id, request)`` → add / remove / change a line (catalog-priced)
- ``lookup_price(item)`` → a single catalog price (read-only)
- ``text_estimate(session_id, to)`` → SMS the estimate PDF to the caller
"""
# session_id -> {"rows": list[dict], "job_title": str, "tax_rate": float}
_SESSIONS: dict[str, dict] = {}
_JOB_TITLE = "Phone estimate"
_TAX_RATE = 0.13
def reset_sessions() -> None:
"""Drop all voice-agent session state (tests)."""
_SESSIONS.clear()
def _session(session_id: str) -> dict:
return _SESSIONS.setdefault(
session_id, {"rows": [], "job_title": _JOB_TITLE, "tax_rate": _TAX_RATE}
)
def _rows_from_est(est: dict) -> list[dict]:
return [
{
"description": li["description"],
"quantity": li["quantity"],
"unit": li["unit"],
"rate": li["rate"],
}
for li in est["line_items"]
]
def _spoken_items(est: dict) -> list[dict]:
"""A compact, speech-friendly view of the lines (no internal fields)."""
return [
{"description": li["description"], "quantity": li["quantity"], "rate": li["rate"]}
for li in est["line_items"]
]
def forge(session_id: str, description: str) -> dict:
"""Forge an estimate from a spoken job description; store it on the session."""
from quillwright.api.estimate import forge_estimate
forged = forge_estimate(description or "", trade="hvac")
est = forged.get("estimate")
if est is None or not est.get("line_items"):
return {
"ok": False,
"message": "I couldn't build an estimate from that. "
"Try naming the parts and the labor.",
}
sess = _session(session_id)
sess["rows"] = _rows_from_est(est)
return {
"ok": True,
"items": _spoken_items(est),
"item_count": len(est["line_items"]),
"total": round(est["total"], 2),
}
def edit(session_id: str, request: str) -> dict:
"""Apply a spoken edit (add / remove / change) to the session's estimate. The catalog
owns every price (Facts-from-Tools); returns the assistant's reply + the new total."""
from quillwright.api.chat import chat_about_estimate
sess = _session(session_id)
out = chat_about_estimate(request or "", sess["rows"], tax_rate=sess["tax_rate"])
est = out["estimate"]
sess["rows"] = _rows_from_est(est)
return {
"ok": True,
"reply": out["reply"],
"items": _spoken_items(est),
"item_count": len(est["line_items"]),
"total": round(est["total"], 2),
}
def lookup_price(item: str) -> dict:
"""A single catalog price (read-only) so the agent can answer 'how much is X?'."""
from quillwright.api.estimate import CATALOG
hit = CATALOG.lookup(item or "")
if not hit:
return {"found": False, "item": item}
return {
"found": True,
"description": hit["description"],
"rate": hit["rate"],
"unit": hit["unit"],
}
def text_estimate(session_id: str, to: str, base_url: str | None = None, sms=None) -> dict:
"""SMS the estimate PDF to the caller. Reuses the S10 send path + tokenized PDF link.
``sms`` is injectable for tests; otherwise the real Twilio MMS provider is used."""
import os
from quillwright.api.estimate import save_estimate_record
from quillwright.api.pdf_links import public_pdf_url, register_pdf
from quillwright.api.recalc import recalc_estimate
from quillwright.api.send import _default_sms_provider, _render_pdf_bytes
sess = _session(session_id)
rows, job_title, tax_rate = sess["rows"], sess["job_title"], sess["tax_rate"]
if not rows:
return {"ok": False, "message": "There's no estimate to send yet."}
if not to:
return {"ok": False, "message": "I need a phone number to text it to."}
base = (base_url if base_url is not None else os.environ.get("FF_PUBLIC_BASE_URL", "")).rstrip(
"/"
)
est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
save_estimate_record(rows, job_title, tax_rate, thread=[]) # persist the draft (ADR-0013)
n = len(est["line_items"])
send = sms or _default_sms_provider
try:
pdf_bytes = _render_pdf_bytes(rows, job_title=job_title, tax_rate=tax_rate)
token = register_pdf(pdf_bytes)
media_url = public_pdf_url(token, base_url=base or "")
send(
recipient=to,
body=(
f"Your Quillwright estimate: {n} item{'s' if n != 1 else ''}, "
f"total ${est['total']:.2f}. AI-generated draft — review before accepting."
),
media_url=media_url,
summary="",
)
return {"ok": True, "sent": True, "total": round(est["total"], 2)}
except Exception as exc: # noqa: BLE001 — report a clean failure to the agent
return {"ok": False, "sent": False, "message": f"Couldn't text it: {exc}"}