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.
|
**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
|
Correctly uses `format: json`, uses verified Twilio caller-ID instead of trusting model
|
||||||
output, falls back to JSONL if Odoo is unreachable. Keep it.
|
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
|
**Classifies `request_type`:** `appointment` (booking), `callback` (a non-booking request staff
|
||||||
reason (e.g. "check on my eyes") with no name and no office is skipped (not worth a worklist
|
must handle off-phone → "📞 Callback request" lead), or `none` (just a question / nothing).
|
||||||
card). Captures full name, phone (confirmed/alternate), insurance, reason, and resolved date.
|
**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,
|
**Odoo integration (`odoo_client.py`)** — already uses `ODOO_API_KEY` for XML-RPC auth,
|
||||||
not password. Correct pattern. No changes.
|
not password. Correct pattern. No changes.
|
||||||
@@ -276,8 +278,19 @@ AB_MODEL_B=
|
|||||||
## Call Workflow
|
## Call Workflow
|
||||||
|
|
||||||
AVA runs a directed script (system prompt in `bot.py`) — warm but direct, one short turn at a
|
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).
|
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).
|
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
|
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 "
|
"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 "
|
"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 "
|
"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 "
|
" 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"
|
"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 "
|
" 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 = (
|
_EXTRACT_INSTRUCTIONS = (
|
||||||
"You are reviewing a phone-call transcript between a caller and the receptionist "
|
"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"
|
"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'
|
' "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 '
|
' "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"
|
"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'
|
' "alternate_number": string or null — a different callback number the caller gave, digits only\n'
|
||||||
' "location": string or null (which office/city)\n'
|
' "location": string or null (which office/city — appointments only)\n'
|
||||||
' "reason": string or null — WHY they want to be seen: the visit type OR the eye '
|
' "reason": string or null — for an APPOINTMENT, why they want to be seen: the visit type '
|
||||||
"problem/symptom they describe. Capture symptoms too (e.g. \"annual eye exam\", \"blurry "
|
"OR the eye problem/symptom they describe (e.g. \"annual eye exam\", \"blurry vision\", "
|
||||||
'vision", "vision loss / blindness", "eye pain", "broken glasses", "red eye"). If they '
|
'"vision loss", "eye pain", "broken glasses"). For a CALLBACK, a one-line note of what they '
|
||||||
"describe any eye or vision problem, that IS the reason.\n"
|
'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'
|
' "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'
|
' "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, '
|
' "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")
|
logger.exception("Appointment extraction call failed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not data.get("wants_appointment"):
|
kind = (data.get("request_type") or "none").strip().lower()
|
||||||
logger.info("Post-call extraction: no appointment requested")
|
if kind not in ("appointment", "callback"):
|
||||||
|
logger.info("Post-call extraction: no actionable request")
|
||||||
return None
|
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()
|
name = (data.get("patient_name") or "").strip()
|
||||||
location = (data.get("location") or "").strip()
|
location = (data.get("location") or "").strip()
|
||||||
if not name and not location:
|
reason_raw = (data.get("reason") or "").strip()
|
||||||
logger.info("Post-call extraction: appointment intent but no name/location captured — skipping card")
|
# 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
|
return None
|
||||||
|
|
||||||
# Callback number: default to the verified Twilio caller-ID. If the caller explicitly
|
# 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 = {
|
record = {
|
||||||
"call_sid": call_sid,
|
"call_sid": call_sid,
|
||||||
|
"kind": kind, # "appointment" | "callback"
|
||||||
"patient_name": data.get("patient_name"),
|
"patient_name": data.get("patient_name"),
|
||||||
"callback_number": callback,
|
"callback_number": callback,
|
||||||
"phone_confirmed": phone_ok,
|
"phone_confirmed": phone_ok,
|
||||||
"location": data.get("location"),
|
"location": data.get("location") if kind == "appointment" else None,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"insurance": (data.get("insurance") or "").strip() or None,
|
"insurance": (data.get("insurance") or "").strip() or None if kind == "appointment" else None,
|
||||||
"preferred_time": preferred,
|
"preferred_time": preferred if kind == "appointment" else None,
|
||||||
"source": "post_call_extraction",
|
"source": "post_call_extraction",
|
||||||
}
|
}
|
||||||
where = persist_appointment(record)
|
where = persist_appointment(record)
|
||||||
logger.info(
|
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"{record['location']} / reason={record['reason']} / ins={record['insurance']} / "
|
||||||
f"when={record['preferred_time']}"
|
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,
|
def create_appointment_request(patient_name, callback_number, reason, preferred_time,
|
||||||
insurance=None, call_sid=None):
|
insurance=None, call_sid=None, kind="appointment"):
|
||||||
"""Create the request in Odoo. Returns (model, record_id) or raises OdooError."""
|
"""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()
|
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.
|
# description is an Odoo HTML field — build with <br/> so it renders in the UI.
|
||||||
rows = [
|
note = f"<p>{header}</p><p>" + \
|
||||||
("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>" + \
|
|
||||||
"<br/>".join(f"{escape(k)}: {escape(str(v)) if v else '—'}" for k, v in rows) + "</p>"
|
"<br/>".join(f"{escape(k)}: {escape(str(v)) if v else '—'}" for k, v in rows) + "</p>"
|
||||||
|
|
||||||
if ODOO_TARGET == "calendar":
|
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
|
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."""
|
the post-call extraction and the (optional) in-call tool."""
|
||||||
record.setdefault("ts", datetime.now(timezone.utc).isoformat())
|
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"):
|
if os.environ.get("ODOO_USER") and os.environ.get("ODOO_API_KEY"):
|
||||||
try:
|
try:
|
||||||
from odoo_client import create_appointment_request
|
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(
|
model, rec_id = create_appointment_request(
|
||||||
patient_name=record.get("patient_name"),
|
patient_name=record.get("patient_name"),
|
||||||
callback_number=record.get("callback_number"),
|
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"),
|
preferred_time=record.get("preferred_time"),
|
||||||
insurance=record.get("insurance"),
|
insurance=record.get("insurance"),
|
||||||
call_sid=record.get("call_sid"),
|
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}"
|
return f"odoo:{model}:{rec_id}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Odoo write failed ({e!r}); falling back to local log")
|
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:
|
with open(REQUESTS_LOG, "a") as fh:
|
||||||
fh.write(json.dumps(record) + "\n")
|
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"
|
return "jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user