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>
This commit is contained in:
tocmo0nlord
2026-06-29 14:46:23 +00:00
parent 2f7e2629fe
commit 97e109ed89
5 changed files with 90 additions and 37 deletions

View File

@@ -86,9 +86,11 @@ Trade-off: half-duplex — the caller can't barge in mid-utterance (fine for sho
**Post-call extraction (`extract.py`)** — single JSON-mode completion after call ends.
Correctly uses `format: json`, uses verified Twilio caller-ID instead of trusting model
output, falls back to JSONL if Odoo is unreachable. Keep it.
**Lead-quality gate:** a lead is only written if a NAME or a LOCATION was captured — a bare
reason (e.g. "check on my eyes") with no name and no office is skipped (not worth a worklist
card). Captures full name, phone (confirmed/alternate), insurance, reason, and resolved date.
**Classifies `request_type`:** `appointment` (booking), `callback` (a non-booking request staff
must handle off-phone → "📞 Callback request" lead), or `none` (just a question / nothing).
**Lead-quality gate:** appointment needs a NAME or LOCATION; callback needs a NAME or a request
note — otherwise skipped (no near-empty cards). Appointments capture full name, phone
(confirmed/alternate), insurance, reason, resolved date; callbacks capture name + what they need.
**Odoo integration (`odoo_client.py`)** — already uses `ODOO_API_KEY` for XML-RPC auth,
not password. Correct pattern. No changes.
@@ -276,8 +278,19 @@ AB_MODEL_B=
## Call Workflow
AVA runs a directed script (system prompt in `bot.py`) — warm but direct, one short turn at a
time, leading the call rather than waiting on the caller. Fixed order:
time, leading the call rather than waiting on the caller.
**Intent first — three kinds of call:**
- **Question** answerable from the practice facts → just answer it.
- **Can't-do request** (existing order/purchase, frames/lenses/prescription/lab status, billing,
account lookup, reach a person) → do NOT run the booking steps. Say it can't be looked up, take
the caller's name + a one-line note of what they need, confirm the callback number, and promise
a staff callback. Captured as a **callback note** lead in Odoo ("📞 Callback request — …"), NOT
an appointment. (Added after a caller asking if their frames were ready got railroaded into the
booking script and hung up.)
- **Booking** → the ordered steps below.
Booking steps (fixed order):
1. **Reason first** — find out what they're calling about (visit reason, or just a question → answer it).
2. **Location** — ask city/area, confirm the matching office (don't offer others — see office rule).
3. **Caller info** — full name (ask last name if only a first is given), then **address the caller

12
bot.py
View File

@@ -144,7 +144,17 @@ SYSTEM_PROMPT = (
"Your job is to answer questions and take appointment requests. Be warm but DIRECT and "
"efficient: when the caller greets you, get to the point and lead the call by asking "
"questions. Never re-ask for something they already told you, and keep each turn to one "
"short question or statement. Work through the call in THIS order:\n"
"short question or statement.\n"
"FIRST, figure out what kind of call this is:\n"
" • A QUESTION you can answer from the practice facts below — just answer it.\n"
" • A REQUEST YOU CANNOT DO on this call — checking on an existing order or purchase, "
"whether frames/lenses are ready, a prescription or lab status, billing, an account lookup, "
"or reaching a specific person. For these, do NOT push the appointment steps. Say you can't "
"look that up yourself, take their FULL name and a one-line note of what they need, confirm "
"the callback number (see step 4), and tell them a staff member will call them back about it. "
"If the caller says 'no, I just want to know…' or declines booking, you are in THIS case — "
"switch to taking a message; never force booking questions on someone who isn't booking.\n"
" • A BOOKING (they want to schedule a visit) — work through these steps in order:\n"
" 1. REASON FIRST — find out what they are calling about (the reason for the visit, or "
"their question). If it is only a question, answer it.\n"
" 2. LOCATION — ask which city or area is most convenient, then confirm the matching "

View File

@@ -17,18 +17,23 @@ 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"
"for an optometry practice. Classify what the caller wanted and extract the details.\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'
' "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)\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"
' "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, '
@@ -76,16 +81,22 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
logger.exception("Appointment extraction call failed")
return None
if not data.get("wants_appointment"):
logger.info("Post-call extraction: no appointment requested")
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
# Lead-quality gate: a usable lead needs a NAME or a LOCATION (so staff can act on it) —
# a bare reason like "check on my eyes" with no name and no office is not worth a card.
name = (data.get("patient_name") or "").strip()
location = (data.get("location") or "").strip()
if not name and not location:
logger.info("Post-call extraction: appointment intent but no name/location captured — skipping card")
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
@@ -114,18 +125,19 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
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"),
"location": data.get("location") if kind == "appointment" else None,
"reason": reason,
"insurance": (data.get("insurance") or "").strip() or None,
"preferred_time": preferred,
"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 appointment saved ({where}): {record['patient_name']} / "
f"Post-call {kind} saved ({where}): {record['patient_name']} / "
f"{record['location']} / reason={record['reason']} / ins={record['insurance']} / "
f"when={record['preferred_time']}"
)

View File

@@ -59,20 +59,32 @@ def _find_or_create_partner(uid, models, name, phone):
def create_appointment_request(patient_name, callback_number, reason, preferred_time,
insurance=None, call_sid=None):
"""Create the request in Odoo. Returns (model, record_id) or raises OdooError."""
insurance=None, call_sid=None, kind="appointment"):
"""Create the request in Odoo. `kind` is "appointment" or "callback" (a message for staff
to call the caller back about something we can't do on the phone). Returns (model, id)."""
uid, models = _connect()
summary = f"📞 Phone appt — {patient_name or 'caller'}" + (f": {reason}" if reason else "")
if kind == "callback":
summary = f"📞 Callback request — {patient_name or 'caller'}" + (f": {reason}" if reason else "")
header = "<b>Captured by the AVC phone agent</b> — CALL THE PATIENT BACK about this request."
rows = [
("Name", patient_name),
("Callback", callback_number),
("What they need", reason),
("Twilio call SID", call_sid),
]
else:
summary = f"📞 Phone appt — {patient_name or 'caller'}" + (f": {reason}" if reason else "")
header = "<b>Captured by the AVC phone agent</b> (UNCONFIRMED — call patient to finalize)."
rows = [
("Name", patient_name),
("Callback", callback_number),
("Reason", reason),
("Insurance (log only — staff to verify coverage)", insurance),
("Preferred time (patient's words)", preferred_time),
("Twilio call SID", call_sid),
]
# description is an Odoo HTML field — build with <br/> so it renders in the UI.
rows = [
("Name", patient_name),
("Callback", callback_number),
("Reason", reason),
("Insurance (log only — staff to verify coverage)", insurance),
("Preferred time (patient's words)", preferred_time),
("Twilio call SID", call_sid),
]
note = "<p><b>Captured by the AVC phone agent</b> (UNCONFIRMED — call patient to finalize).</p><p>" + \
note = f"<p>{header}</p><p>" + \
"<br/>".join(f"{escape(k)}: {escape(str(v)) if v else ''}" for k, v in rows) + "</p>"
if ODOO_TARGET == "calendar":

View File

@@ -101,19 +101,25 @@ def persist_appointment(record: dict) -> str:
JSONL fallback so a request is never lost. Returns where it landed. Used by both
the post-call extraction and the (optional) in-call tool."""
record.setdefault("ts", datetime.now(timezone.utc).isoformat())
kind = record.get("kind", "appointment")
if os.environ.get("ODOO_USER") and os.environ.get("ODOO_API_KEY"):
try:
from odoo_client import create_appointment_request
if kind == "callback":
reason = record.get("reason") or "callback request"
else:
reason = f"[{record.get('location') or 'location TBD'}] {record.get('reason') or ''}".strip()
model, rec_id = create_appointment_request(
patient_name=record.get("patient_name"),
callback_number=record.get("callback_number"),
reason=f"[{record.get('location') or 'location TBD'}] {record.get('reason') or ''}".strip(),
reason=reason,
preferred_time=record.get("preferred_time"),
insurance=record.get("insurance"),
call_sid=record.get("call_sid"),
kind=kind,
)
logger.info(f"Appointment -> Odoo {model} id={rec_id}: {record.get('patient_name')}")
logger.info(f"{kind.capitalize()} -> Odoo {model} id={rec_id}: {record.get('patient_name')}")
return f"odoo:{model}:{rec_id}"
except Exception as e:
logger.warning(f"Odoo write failed ({e!r}); falling back to local log")
@@ -121,7 +127,7 @@ def persist_appointment(record: dict) -> str:
with open(REQUESTS_LOG, "a") as fh:
fh.write(json.dumps(record) + "\n")
logger.info(f"Appointment -> JSONL: {record.get('patient_name')}")
logger.info(f"{kind.capitalize()} -> JSONL: {record.get('patient_name')}")
return "jsonl"