Files
avc-phone-ai/extract.py
tocmo0nlord 97e109ed89 Handle non-booking requests: take a message, log a callback note
A caller asking if their already-purchased frames were ready got railroaded
through the booking script and hung up. AVA had no path for requests it can't do
on the phone.

- Prompt: classify intent first — question (answer it), can't-do request (take
  name + a one-line note, confirm callback number, promise a staff callback;
  never force booking questions), or booking (the ordered steps).
- extract.py: request_type = appointment | callback | none. Callback gate needs
  a name or a request note. Records kind.
- practice.py / odoo_client.py: callbacks write a "📞 Callback request" lead
  (name, callback number, what they need) instead of an appointment card.

Verified the classifier: frames-status -> callback, booking -> appointment,
pure question -> none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:46:23 +00:00

145 lines
6.8 KiB
Python

"""Post-call appointment extraction.
Instead of unreliable in-call tool-calling (which made llama3.1:8b speak raw JSON),
we let the agent gather appointment details conversationally, then run ONE structured
extraction over the finished transcript and write it to Odoo. Reliable because it's a
single JSON-mode completion, not mid-conversation tool emission.
"""
import json
import re
from datetime import datetime
import httpx
from loguru import logger
from practice import persist_appointment
_EXTRACT_INSTRUCTIONS = (
"You are reviewing a phone-call transcript between a caller and the receptionist "
"for an optometry practice. Classify what the caller wanted and extract the details.\n"
"Respond with ONLY a JSON object with these keys:\n"
' "request_type": one of "appointment", "callback", "none":\n'
' - "appointment": the caller asked to book/schedule a visit\n'
' - "callback": the caller wants something staff must handle off the phone — checking '
"an existing order or purchase, whether frames/lenses are ready, a prescription or lab "
"status, billing, an account lookup, or to reach a specific person\n"
' - "none": just a question that was answered, or nothing actionable\n'
' "patient_name": string or null — the caller\'s FULL name (first and last) if given\n'
' "phone_ok": boolean or null — true if the caller confirmed their caller-ID number is the '
"best one, false if they said to use a different number, null if it never came up\n"
' "alternate_number": string or null — a different callback number the caller gave, digits only\n'
' "location": string or null (which office/city — appointments only)\n'
' "reason": string or null — for an APPOINTMENT, why they want to be seen: the visit type '
"OR the eye problem/symptom they describe (e.g. \"annual eye exam\", \"blurry vision\", "
'"vision loss", "eye pain", "broken glasses"). For a CALLBACK, a one-line note of what they '
'need (e.g. "checking if frames ordered last week are ready", "question about a bill").\n'
' "insurance": string or null — the insurance plan the caller named, exactly as they said it\n'
' "preferred_time": string or null — the day/time in the caller\'s own words\n'
' "resolved_date": string or null — the actual calendar date the caller means as YYYY-MM-DD, '
"computed from TODAY given below; null if it cannot be determined\n"
"Use null for anything not clearly stated. Do not invent values."
)
async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_number=None):
"""Extract an appointment from the transcript and persist it. Returns the record
dict if one was saved, else None."""
# Build a plain transcript from the conversation (skip the system prompt).
turns = [
f"{m['role']}: {m['content']}"
for m in messages
if m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip()
]
if not any(m.get("role") == "user" for m in messages):
return None # nobody said anything
transcript = "\n".join(turns)
base = ollama_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
today = datetime.now().strftime("%A, %B %d, %Y").replace(" 0", " ")
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{base}/api/chat",
json={
"model": model,
"format": "json",
"stream": False,
"options": {"temperature": 0},
"messages": [
{"role": "system", "content": f"{_EXTRACT_INSTRUCTIONS}\n\nTODAY is {today}."},
{"role": "user", "content": f"Transcript:\n{transcript}"},
],
},
)
r.raise_for_status()
data = json.loads(r.json()["message"]["content"])
except Exception:
logger.exception("Appointment extraction call failed")
return None
kind = (data.get("request_type") or "none").strip().lower()
if kind not in ("appointment", "callback"):
logger.info("Post-call extraction: no actionable request")
return None
name = (data.get("patient_name") or "").strip()
location = (data.get("location") or "").strip()
reason_raw = (data.get("reason") or "").strip()
# Lead-quality gate so staff get an actionable card:
# - appointment: need a NAME or a LOCATION
# - callback: need a NAME or a clear note of what they want
if kind == "appointment" and not name and not location:
logger.info("Post-call extraction: appointment intent but no name/location — skipping card")
return None
if kind == "callback" and not name and not reason_raw:
logger.info("Post-call extraction: callback intent but no name/request note — skipping card")
return None
# Callback number: default to the verified Twilio caller-ID. If the caller explicitly
# declined it (phone_ok == False) and gave a different number, use the one they gave.
alt = (data.get("alternate_number") or "").strip()
phone_ok = data.get("phone_ok")
if phone_ok is False and alt:
callback = alt
else:
callback = caller_number or alt or None
# If they gave a different number but didn't clearly decline the caller-ID, keep it as a note.
note_alt = (
alt and caller_number and callback != alt
and re.sub(r"\D", "", alt) != re.sub(r"\D", "", caller_number)
)
# Preferred time = caller's words, annotated with the resolved calendar date if we got one.
preferred = (data.get("preferred_time") or "").strip() or None
resolved = (data.get("resolved_date") or "").strip() or None
if resolved:
preferred = f"{preferred} (resolved: {resolved})" if preferred else resolved
reason = data.get("reason")
if note_alt:
reason = f"{reason or ''} (caller also mentioned number: {alt})".strip()
record = {
"call_sid": call_sid,
"kind": kind, # "appointment" | "callback"
"patient_name": data.get("patient_name"),
"callback_number": callback,
"phone_confirmed": phone_ok,
"location": data.get("location") if kind == "appointment" else None,
"reason": reason,
"insurance": (data.get("insurance") or "").strip() or None if kind == "appointment" else None,
"preferred_time": preferred if kind == "appointment" else None,
"source": "post_call_extraction",
}
where = persist_appointment(record)
logger.info(
f"Post-call {kind} saved ({where}): {record['patient_name']} / "
f"{record['location']} / reason={record['reason']} / ins={record['insurance']} / "
f"when={record['preferred_time']}"
)
return record