From 97e109ed89f5cdf95bc51fa65698b00d7afa2eb0 Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Mon, 29 Jun 2026 14:46:23 +0000 Subject: [PATCH] Handle non-booking requests: take a message, log a callback note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 21 +++++++++++++++++---- bot.py | 12 +++++++++++- extract.py | 46 +++++++++++++++++++++++++++++----------------- odoo_client.py | 36 ++++++++++++++++++++++++------------ practice.py | 12 +++++++++--- 5 files changed, 90 insertions(+), 37 deletions(-) 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) + "

" if ODOO_TARGET == "calendar": diff --git a/practice.py b/practice.py index 31a6f9d..97b7876 100644 --- a/practice.py +++ b/practice.py @@ -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"