- Reason extraction missed symptom-style reasons: a caller said "I'm actually
blind" and the lead logged reason=None (it caught "disintegrated eyes" before
but not this). Broadened the extractor's reason rule to capture the eye
problem/symptom as the reason, not just visit types. Verified 3/3 -> "vision
loss / blindness".
- server.py: move the LLM warmup/pin (keep_alive=-1) from the deprecated
on_event("startup") to a lifespan handler — silences the FastAPI deprecation
warning; model still shows ollama ps UNTIL=Forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.9 KiB
Python
133 lines
5.9 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. Extract any APPOINTMENT REQUEST the caller made.\n"
|
|
"Respond with ONLY a JSON object with these keys:\n"
|
|
' "wants_appointment": boolean — true only if the caller asked to book/schedule a visit\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)\n'
|
|
' "reason": string or null — WHY they want to be seen: the visit type OR the eye '
|
|
"problem/symptom they describe. Capture symptoms too (e.g. \"annual eye exam\", \"blurry "
|
|
'vision", "vision loss / blindness", "eye pain", "broken glasses", "red eye"). If they '
|
|
"describe any eye or vision problem, that IS the reason.\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
|
|
|
|
if not data.get("wants_appointment"):
|
|
logger.info("Post-call extraction: no appointment requested")
|
|
return None
|
|
|
|
# Don't create near-empty cards from quick hang-ups: require at least a name or a
|
|
# reason. A bare location + caller-ID isn't enough to be worth a worklist card.
|
|
name = (data.get("patient_name") or "").strip()
|
|
reason_raw = (data.get("reason") or "").strip()
|
|
if not name and not reason_raw:
|
|
logger.info("Post-call extraction: appointment intent but no name/reason captured — 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,
|
|
"patient_name": data.get("patient_name"),
|
|
"callback_number": callback,
|
|
"phone_confirmed": phone_ok,
|
|
"location": data.get("location"),
|
|
"reason": reason,
|
|
"insurance": (data.get("insurance") or "").strip() or None,
|
|
"preferred_time": preferred,
|
|
"source": "post_call_extraction",
|
|
}
|
|
where = persist_appointment(record)
|
|
logger.info(
|
|
f"Post-call appointment saved ({where}): {record['patient_name']} / "
|
|
f"{record['location']} / reason={record['reason']} / ins={record['insurance']} / "
|
|
f"when={record['preferred_time']}"
|
|
)
|
|
return record
|