"""Post-call appointment extraction. Instead of unreliable in-call tool-calling (which made llama3.1:8b speak raw JSON), we let the agent gather appointment details conversationally, then run ONE structured extraction over the finished transcript and write it to Odoo. Reliable because it's a single JSON-mode completion, not mid-conversation tool emission. """ import json import re from datetime import datetime import httpx from loguru import logger 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" "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' ' "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 (e.g. eye exam, broken glasses)\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, ' "computed from TODAY given below; null if it cannot be determined\n" "Use null for anything not clearly stated. Do not invent values." ) async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_number=None): """Extract an appointment from the transcript and persist it. Returns the record dict if one was saved, else None.""" # Build a plain transcript from the conversation (skip the system prompt). turns = [ f"{m['role']}: {m['content']}" for m in messages if m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip() ] if not any(m.get("role") == "user" for m in messages): return None # nobody said anything transcript = "\n".join(turns) base = ollama_url.rstrip("/") if base.endswith("/v1"): base = base[:-3] today = datetime.now().strftime("%A, %B %d, %Y").replace(" 0", " ") try: async with httpx.AsyncClient(timeout=30) as client: r = await client.post( f"{base}/api/chat", json={ "model": model, "format": "json", "stream": False, "options": {"temperature": 0}, "messages": [ {"role": "system", "content": f"{_EXTRACT_INSTRUCTIONS}\n\nTODAY is {today}."}, {"role": "user", "content": f"Transcript:\n{transcript}"}, ], }, ) r.raise_for_status() data = json.loads(r.json()["message"]["content"]) except Exception: logger.exception("Appointment extraction call failed") return None if not data.get("wants_appointment"): logger.info("Post-call extraction: no appointment requested") return None # Don't create near-empty cards from quick hang-ups: require at least a name or a # reason. A bare location + caller-ID isn't enough to be worth a worklist card. name = (data.get("patient_name") or "").strip() reason_raw = (data.get("reason") or "").strip() if not name and not reason_raw: logger.info("Post-call extraction: appointment intent but no name/reason captured — skipping card") return None # Callback number: default to the verified Twilio caller-ID. If the caller explicitly # declined it (phone_ok == False) and gave a different number, use the one they gave. alt = (data.get("alternate_number") or "").strip() phone_ok = data.get("phone_ok") if phone_ok is False and alt: callback = alt else: callback = caller_number or alt or None # If they gave a different number but didn't clearly decline the caller-ID, keep it as a note. note_alt = ( alt and caller_number and callback != alt and re.sub(r"\D", "", alt) != re.sub(r"\D", "", caller_number) ) # Preferred time = caller's words, annotated with the resolved calendar date if we got one. preferred = (data.get("preferred_time") or "").strip() or None resolved = (data.get("resolved_date") or "").strip() or None if resolved: preferred = f"{preferred} (resolved: {resolved})" if preferred else resolved reason = data.get("reason") if note_alt: reason = f"{reason or ''} (caller also mentioned number: {alt})".strip() record = { "call_sid": call_sid, "patient_name": data.get("patient_name"), "callback_number": callback, "phone_confirmed": phone_ok, "location": data.get("location"), "reason": reason, "insurance": (data.get("insurance") or "").strip() or None, "preferred_time": preferred, "source": "post_call_extraction", } where = persist_appointment(record) logger.info( f"Post-call appointment saved ({where}): {record['patient_name']} / " f"{record['location']} / ins={record['insurance']} / when={record['preferred_time']}" ) return record