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:
@@ -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
|
||||
|
||||
39
bot.py
39
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:<model>:<id>"),
|
||||
# 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."""
|
||||
|
||||
15
extract.py
15
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:<model>:<id>" | "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']} / "
|
||||
|
||||
@@ -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 = "<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",
|
||||
}])
|
||||
|
||||
Reference in New Issue
Block a user