Attach call recording + transcript to the Odoo lead (Phase 3 item)

odoo_client.attach_call_artifacts posts the transcript to the lead's chatter
(readable in place) and attaches transcript_<sid>.txt + the stereo call WAV.
bot.py wires it post-call: extract_and_record now returns the Odoo ref
(persisted_to), the in-call tool path stamps it too, the audiobuffer handler
records the saved WAV path, and the attach runs in a thread after the lead
lands (brief wait for the async WAV write; failures only log — the lead is
already safe). Verified e2e against db1: create -> attach -> verify -> delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-07-04 17:03:00 +00:00
parent 1e7b7844a4
commit bbfff482a3
4 changed files with 96 additions and 3 deletions

View File

@@ -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`) - [ ] Real-time calendar availability check (`odoo/calendar.py`)
- [ ] Whisper large-v3 post-call transcription (`recording/transcriber.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 - [ ] Staff review flow confirmed in Odoo
**Gate:** Staff receives, reviews, and confirms a lead end-to-end **Gate:** Staff receives, reviews, and confirms a lead end-to-end

39
bot.py
View File

@@ -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 # 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, # 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. # 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:<model>:<id>"),
# so the WAV + transcript can be attached to the lead after the call.
artifacts = {"recording": None, "odoo_ref": None}
if ENABLE_TOOLS: if ENABLE_TOOLS:
async def _record_appointment(params): async def _record_appointment(params):
args = params.arguments or {} args = params.arguments or {}
if do_capture: if do_capture:
from practice import persist_appointment from practice import persist_appointment
persist_appointment({ artifacts["odoo_ref"] = persist_appointment({
"call_sid": call_sid, "call_sid": call_sid,
"patient_name": args.get("patient_name"), "patient_name": args.get("patient_name"),
"callback_number": caller_number, # verified caller-ID, not model-supplied "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.setsampwidth(2)
wf.setframerate(sample_rate) wf.setframerate(sample_rate)
wf.writeframes(audio) wf.writeframes(audio)
artifacts["recording"] = path
logger.info(f"Saved call recording: {path} " logger.info(f"Saved call recording: {path} "
f"({len(audio)} bytes, {num_channels}ch @ {sample_rate}Hz)") 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: try:
from extract import extract_and_record from extract import extract_and_record
await extract_and_record( rec = await extract_and_record(
context.messages, OLLAMA_URL, OLLAMA_MODEL, context.messages, OLLAMA_URL, OLLAMA_MODEL,
call_sid=call_sid, caller_number=caller_number, call_sid=call_sid, caller_number=caller_number,
) )
if rec:
artifacts["odoo_ref"] = rec.get("persisted_to")
except Exception: except Exception:
logger.exception("Post-call appointment extraction failed") 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): 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.""" """Phone entrypoint: wrap the Twilio Media Stream in a transport, run the shared agent."""

View File

@@ -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): 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 """Extract an appointment from the transcript and persist it. Returns the record
dict if one was saved, else None.""" 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", "source": "post_call_extraction",
} }
where = persist_appointment(record) where = persist_appointment(record)
record["persisted_to"] = where # "odoo:<model>:<id>" | "jsonl" — used to attach artifacts
logger.info( logger.info(
f"Post-call {kind} 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']} / "

View File

@@ -145,3 +145,40 @@ def create_appointment_request(patient_name, callback_number, reason, preferred_
vals["user_id"] = ODOO_USER_ID vals["user_id"] = ODOO_USER_ID
rec = _exec(uid, models, "crm.lead", "create", vals) rec = _exec(uid, models, "crm.lead", "create", vals)
return ("crm.lead", rec) 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 = "<b>📼 Call transcript</b><br/>" + "<br/>".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",
}])