diff --git a/CLAUDE.md b/CLAUDE.md
index c5fe49d..6621d36 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/bot.py b/bot.py
index 31266f3..45db6ec 100644
--- a/bot.py
+++ b/bot.py
@@ -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 "
diff --git a/extract.py b/extract.py
index 09c7db1..878319a 100644
--- a/extract.py
+++ b/extract.py
@@ -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']}"
)
diff --git a/odoo_client.py b/odoo_client.py
index 44ec2a9..6842610 100644
--- a/odoo_client.py
+++ b/odoo_client.py
@@ -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 = "Captured by the AVC phone agent — 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 = "Captured by the AVC phone agent (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
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 = "
Captured by the AVC phone agent (UNCONFIRMED — call patient to finalize).
" + \ + note = f"
{header}
" + \
"
".join(f"{escape(k)}: {escape(str(v)) if v else '—'}" for k, v in rows) + "