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:
21
CLAUDE.md
21
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
|
||||
|
||||
12
bot.py
12
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 "
|
||||
|
||||
46
extract.py
46
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']}"
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
|
||||
12
practice.py
12
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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user