diff --git a/CLAUDE.md b/CLAUDE.md index 4fc24fb..21a279e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -553,7 +553,13 @@ Beyond the three reverted changes, the following hardening is live (see git hist - [ ] Real-time calendar availability check (`odoo/calendar.py`) - [ ] Whisper large-v3 post-call transcription (`recording/transcriber.py`) -- [ ] Recording + transcript attached to Odoo lead chatter +- [x] Recording + transcript attached to Odoo lead chatter (2026-07-04, pulled forward): + `odoo_client.attach_call_artifacts` posts the transcript as a chatter note + .txt + attachment and attaches the stereo WAV; wired post-call in `bot.py` (both the + extraction path and the in-call tool path stamp `artifacts["odoo_ref"]`). Transcript + is the live in-call Whisper `medium` text (`extract.format_transcript`), not + large-v3 — the large-v3 re-transcription remains a Phase 3 item. Verified e2e + against db1 (create → attach → verify → delete, left clean). - [ ] Staff review flow confirmed in Odoo **Gate:** Staff receives, reviews, and confirms a lead end-to-end diff --git a/bot.py b/bot.py index bcf724c..45ebae4 100644 --- a/bot.py +++ b/bot.py @@ -650,12 +650,17 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru # can stamp the verified caller-ID + call_sid onto the lead (the model never supplies a # phone number — we don't ask for one). With tools on, this writes the Odoo lead IN-CALL, # so the post-call extraction is skipped below to avoid a duplicate. + # Where the call's artifacts land, filled in as the call runs: the saved recording + # path (audiobuffer handler) and the Odoo ref of the created lead ("odoo::"), + # so the WAV + transcript can be attached to the lead after the call. + artifacts = {"recording": None, "odoo_ref": None} + if ENABLE_TOOLS: async def _record_appointment(params): args = params.arguments or {} if do_capture: from practice import persist_appointment - persist_appointment({ + artifacts["odoo_ref"] = persist_appointment({ "call_sid": call_sid, "patient_name": args.get("patient_name"), "callback_number": caller_number, # verified caller-ID, not model-supplied @@ -807,6 +812,7 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(audio) + artifacts["recording"] = path logger.info(f"Saved call recording: {path} " f"({len(audio)} bytes, {num_channels}ch @ {sample_rate}Hz)") @@ -840,13 +846,42 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru try: from extract import extract_and_record - await extract_and_record( + rec = await extract_and_record( context.messages, OLLAMA_URL, OLLAMA_MODEL, call_sid=call_sid, caller_number=caller_number, ) + if rec: + artifacts["odoo_ref"] = rec.get("persisted_to") except Exception: logger.exception("Post-call appointment extraction failed") + # Attach the call recording + transcript to the Odoo lead (Phase 3: staff review a + # lead with the audio and transcript right on it). Only when a lead actually landed + # in Odoo; JSONL-fallback leads get artifacts when re-imported... they stay on disk. + if do_capture and (artifacts["odoo_ref"] or "").startswith("odoo:"): + try: + from extract import format_transcript + from odoo_client import attach_call_artifacts + + _, odoo_model, odoo_id = artifacts["odoo_ref"].split(":") + # The recording is written by an async handler after stop_recording — + # give it a moment if it hasn't landed yet. + for _ in range(10): + if not RECORD_CALLS or artifacts["recording"]: + break + await asyncio.sleep(0.5) + await asyncio.to_thread( + attach_call_artifacts, + odoo_model, int(odoo_id), + wav_path=artifacts["recording"], + transcript=format_transcript(context.messages, agent_name=AGENT_NAME), + call_sid=call_sid, + ) + logger.info(f"Attached recording+transcript to {artifacts['odoo_ref']} " + f"(wav={artifacts['recording']})") + except Exception: + logger.exception("Attaching call artifacts to Odoo failed (lead itself is safe)") + async def run_call(websocket, serializer: TwilioFrameSerializer, caller_number=None, call_sid=None): """Phone entrypoint: wrap the Twilio Media Stream in a transport, run the shared agent.""" diff --git a/extract.py b/extract.py index 878319a..7befe96 100644 --- a/extract.py +++ b/extract.py @@ -42,6 +42,20 @@ _EXTRACT_INSTRUCTIONS = ( ) +def format_transcript(messages, agent_name="AVA") -> str: + """Plain-text transcript of the call (Caller/agent turns) for chatter/attachments.""" + lines = [] + for m in messages: + if m.get("role") not in ("user", "assistant"): + continue + content = m.get("content") + if not isinstance(content, str) or not content.strip(): + continue + who = "Caller" if m["role"] == "user" else agent_name + lines.append(f"{who}: {content.strip()}") + return "\n".join(lines) + + 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.""" @@ -136,6 +150,7 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_ "source": "post_call_extraction", } where = persist_appointment(record) + record["persisted_to"] = where # "odoo::" | "jsonl" — used to attach artifacts logger.info( f"Post-call {kind} saved ({where}): {record['patient_name']} / " f"{record['location']} / reason={record['reason']} / ins={record['insurance']} / " diff --git a/odoo_client.py b/odoo_client.py index 02bf605..1ca9c62 100644 --- a/odoo_client.py +++ b/odoo_client.py @@ -145,3 +145,40 @@ def create_appointment_request(patient_name, callback_number, reason, preferred_ vals["user_id"] = ODOO_USER_ID rec = _exec(uid, models, "crm.lead", "create", vals) return ("crm.lead", rec) + + +def attach_call_artifacts(model, rec_id, wav_path=None, transcript=None, call_sid=None): + """Attach the call recording (WAV) and post the transcript to a lead's chatter. + + Called post-call for the record created by create_appointment_request. The transcript + goes in as a chatter note (readable in place) AND as a .txt attachment (downloadable); + the WAV is attached as-is. Any failure raises — the caller logs and moves on (the lead + itself is already safe).""" + import base64 + + uid, models = _connect() + sid = call_sid or "call" + + if transcript: + body = "📼 Call transcript
" + "
".join( + escape(line) for line in transcript.splitlines() if line.strip() + ) + _exec(uid, models, model, "message_post", [rec_id], body=body) + _exec(uid, models, "ir.attachment", "create", [{ + "name": f"transcript_{sid}.txt", + "datas": base64.b64encode(transcript.encode()).decode(), + "res_model": model, + "res_id": rec_id, + "mimetype": "text/plain", + }]) + + if wav_path and os.path.isfile(wav_path): + with open(wav_path, "rb") as fh: + b64 = base64.b64encode(fh.read()).decode() + _exec(uid, models, "ir.attachment", "create", [{ + "name": os.path.basename(wav_path), + "datas": b64, + "res_model": model, + "res_id": rec_id, + "mimetype": "audio/wav", + }])