Initial commit: avc-phone-ai codebase + CLAUDE.md
This commit is contained in:
101
extract.py
Normal file
101
extract.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""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
|
||||
|
||||
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\n'
|
||||
' "callback_number": string or null (digits the caller gave to be called back)\n'
|
||||
' "location": string or null (which office/city)\n'
|
||||
' "reason": string or null (e.g. eye exam, broken glasses)\n'
|
||||
' "preferred_time": string or null (day/time in the caller\'s words)\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]
|
||||
|
||||
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": _EXTRACT_INSTRUCTIONS},
|
||||
{"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
|
||||
|
||||
# Prefer the verified Twilio caller-ID over a number pulled from the transcript —
|
||||
# the model sometimes invents/echoes a phone number. Keep a genuinely different
|
||||
# spoken number as a note for staff.
|
||||
spoken = (data.get("callback_number") or "").strip()
|
||||
callback = caller_number or spoken or None
|
||||
reason = data.get("reason")
|
||||
if spoken and caller_number and re.sub(r"\D", "", spoken) != re.sub(r"\D", "", caller_number):
|
||||
reason = f"{reason or ''} (caller mentioned alternate number: {spoken})".strip()
|
||||
|
||||
record = {
|
||||
"call_sid": call_sid,
|
||||
"patient_name": data.get("patient_name"),
|
||||
"callback_number": callback,
|
||||
"location": data.get("location"),
|
||||
"reason": reason,
|
||||
"preferred_time": data.get("preferred_time"),
|
||||
"source": "post_call_extraction",
|
||||
}
|
||||
where = persist_appointment(record)
|
||||
logger.info(f"Post-call appointment saved ({where}): {record['patient_name']} / {record['location']}")
|
||||
return record
|
||||
Reference in New Issue
Block a user