The call-flow learner finally gets fed: exploration mode records its IVR discoveries on the call (ActiveCall.exploration_steps) instead of throwing them away, persistence stores them in the call record's metadata, and the rebuilt learn_call_flow MCP tool turns a completed exploration call into a stored flow via CallFlowLearner — correct constructor (llm_client from get_llm, heuristic labels when the LLM is unavailable), build for a new number, merge/refine when a flow already exists. save_learned_flow/update_flow_from_model keep the CallFlow↔row mapping in call_persistence. Test gaps closed: tests/test_learner.py (discoveries→linked steps, exploration persistence, learn-then-refine through the in-memory MCP client, no-data and unknown-call answers) and tests/test_websocket.py (4401 without token, trunk-status-then-replay on connect, per-call stream filtering). Docs aligned to code: README (15 tools incl. learn_call_flow, HTTP not SSE, Python 3.12+, PostgreSQL+Alembic — no SQLite fallback, media pipeline marked stub-mode until pjsua2 installed, Alembic and honest /health checked off); docs/mcp-server.md rewritten against the actual tool surface (hangup not end_call, real params, 3 real resources, /mcp/ streamable HTTP + bearer auth); architecture/development/ configuration drift fixed. pyproject: pruned never-imported deps (websockets, librosa, soundfile, python-multipart). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""
|
|
Call Persistence — the data-access layer for calls and call flows.
|
|
|
|
Holds the on-hangup persistence hook plus the query/write functions
|
|
that both the REST handlers and the MCP tools call, so the two
|
|
surfaces can't drift. Every function takes an AsyncSession; callers
|
|
own the transaction (get_db for REST, session_scope for MCP/services).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import desc, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.database import (
|
|
CallRecord,
|
|
RecordingRecord,
|
|
StoredCallFlow,
|
|
TranscriptChunk,
|
|
session_scope,
|
|
)
|
|
from db.database import Device as DeviceRow
|
|
from models.call import ActiveCall, CallStatus
|
|
from models.call_flow import CallFlow, CallFlowStep
|
|
from models.device import Device
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def flow_to_model(row: StoredCallFlow) -> CallFlow:
|
|
"""The one StoredCallFlow-row → CallFlow-model mapping."""
|
|
return CallFlow(
|
|
id=row.id,
|
|
name=row.name,
|
|
phone_number=row.phone_number,
|
|
description=row.description or "",
|
|
steps=[CallFlowStep(**s) for s in (row.steps or [])],
|
|
tags=row.tags or [],
|
|
notes=row.notes,
|
|
avg_hold_time=row.avg_hold_time,
|
|
success_rate=row.success_rate,
|
|
last_used=row.last_used,
|
|
times_used=row.times_used or 0,
|
|
)
|
|
|
|
|
|
def record_summary(row: CallRecord) -> dict:
|
|
"""The one CallRecord-row → list-item mapping."""
|
|
return {
|
|
"id": row.id,
|
|
"direction": row.direction,
|
|
"remote_number": row.remote_number,
|
|
"status": row.status,
|
|
"mode": row.mode,
|
|
"intent": row.intent,
|
|
"started_at": row.started_at.isoformat() if row.started_at else None,
|
|
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
|
|
"duration": row.duration,
|
|
"hold_time": row.hold_time,
|
|
"device_used": row.device_used,
|
|
"summary": row.summary,
|
|
}
|
|
|
|
|
|
def record_detail(row: CallRecord) -> dict:
|
|
"""Full CallRecord-row mapping, superset of record_summary."""
|
|
return record_summary(row) | {
|
|
"action_items": row.action_items,
|
|
"sentiment": row.sentiment,
|
|
"call_flow_id": row.call_flow_id,
|
|
"classification_timeline": row.classification_timeline,
|
|
}
|
|
|
|
|
|
def chunk_to_dict(row: TranscriptChunk) -> dict:
|
|
"""The one TranscriptChunk-row → dict mapping."""
|
|
return {
|
|
"seq": row.seq,
|
|
"t_offset_ms": row.t_offset_ms,
|
|
"speaker": row.speaker,
|
|
"text": row.text,
|
|
"confidence": row.confidence,
|
|
}
|
|
|
|
|
|
# ================================================================
|
|
# Call flows
|
|
# ================================================================
|
|
|
|
async def get_flow(session: AsyncSession, flow_id: str) -> StoredCallFlow | None:
|
|
result = await session.execute(
|
|
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_flow_by_number(
|
|
session: AsyncSession, phone_number: str
|
|
) -> StoredCallFlow | None:
|
|
result = await session.execute(
|
|
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_flows(session: AsyncSession) -> list[StoredCallFlow]:
|
|
result = await session.execute(select(StoredCallFlow))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def create_flow(
|
|
session: AsyncSession,
|
|
flow_id: str,
|
|
name: str,
|
|
phone_number: str,
|
|
steps: list[dict],
|
|
description: str | None = None,
|
|
tags: list[str] | None = None,
|
|
notes: str | None = None,
|
|
) -> StoredCallFlow:
|
|
row = StoredCallFlow(
|
|
id=flow_id,
|
|
name=name,
|
|
phone_number=phone_number,
|
|
description=description,
|
|
steps=steps,
|
|
tags=tags,
|
|
notes=notes,
|
|
last_verified=datetime.now(),
|
|
)
|
|
session.add(row)
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def save_learned_flow(session: AsyncSession, flow: CallFlow) -> StoredCallFlow:
|
|
"""The one CallFlow-model → row mapping (auto-learned flows)."""
|
|
row = StoredCallFlow(
|
|
id=flow.id,
|
|
name=flow.name,
|
|
phone_number=flow.phone_number,
|
|
description=flow.description,
|
|
steps=[s.model_dump(mode="json") for s in flow.steps],
|
|
tags=flow.tags,
|
|
notes=flow.notes,
|
|
times_used=flow.times_used,
|
|
last_used=flow.last_used,
|
|
last_verified=datetime.now(),
|
|
)
|
|
session.add(row)
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def update_flow_from_model(
|
|
session: AsyncSession, row: StoredCallFlow, flow: CallFlow
|
|
) -> None:
|
|
"""Write a refined CallFlow back onto its existing row."""
|
|
row.steps = [s.model_dump(mode="json") for s in flow.steps]
|
|
row.times_used = flow.times_used
|
|
row.last_used = flow.last_used
|
|
row.notes = flow.notes
|
|
await session.flush()
|
|
|
|
|
|
# ================================================================
|
|
# Devices
|
|
# ================================================================
|
|
|
|
async def create_device_row(session: AsyncSession, device: Device) -> None:
|
|
"""The one Device-model → row mapping."""
|
|
session.add(DeviceRow(
|
|
id=device.id,
|
|
name=device.name,
|
|
type=device.type.value,
|
|
sip_uri=device.sip_uri,
|
|
phone_number=device.phone_number,
|
|
priority=device.priority,
|
|
capabilities=device.capabilities,
|
|
is_online=device.is_online,
|
|
))
|
|
await session.flush()
|
|
|
|
|
|
async def update_device_row(
|
|
session: AsyncSession, device_id: str, values: dict
|
|
) -> None:
|
|
result = await session.execute(
|
|
select(DeviceRow).where(DeviceRow.id == device_id)
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
if row is None:
|
|
return
|
|
for key, value in values.items():
|
|
if key == "type" and value is not None:
|
|
value = value.value if hasattr(value, "value") else value
|
|
setattr(row, key, value)
|
|
|
|
|
|
async def delete_device_row(session: AsyncSession, device_id: str) -> None:
|
|
result = await session.execute(
|
|
select(DeviceRow).where(DeviceRow.id == device_id)
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
if row is not None:
|
|
await session.delete(row)
|
|
|
|
|
|
# ================================================================
|
|
# Call history / records
|
|
# ================================================================
|
|
|
|
async def get_record(session: AsyncSession, call_id: str) -> CallRecord | None:
|
|
result = await session.execute(
|
|
select(CallRecord).where(CallRecord.id == call_id)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def search_history(
|
|
session: AsyncSession,
|
|
number: str | None = None,
|
|
number_contains: str | None = None,
|
|
intent_contains: str | None = None,
|
|
status: str | None = None,
|
|
since: datetime | None = None,
|
|
until: datetime | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> list[CallRecord]:
|
|
stmt = select(CallRecord).order_by(desc(CallRecord.started_at))
|
|
if number:
|
|
stmt = stmt.where(CallRecord.remote_number == number)
|
|
if number_contains:
|
|
stmt = stmt.where(CallRecord.remote_number.contains(number_contains))
|
|
if intent_contains:
|
|
stmt = stmt.where(CallRecord.intent.icontains(intent_contains))
|
|
if status:
|
|
stmt = stmt.where(CallRecord.status == status)
|
|
if since:
|
|
stmt = stmt.where(CallRecord.started_at >= since)
|
|
if until:
|
|
stmt = stmt.where(CallRecord.started_at <= until)
|
|
result = await session.execute(stmt.offset(offset).limit(limit))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_transcript_chunks(
|
|
session: AsyncSession, call_id: str
|
|
) -> list[TranscriptChunk]:
|
|
result = await session.execute(
|
|
select(TranscriptChunk)
|
|
.where(TranscriptChunk.call_id == call_id)
|
|
.order_by(TranscriptChunk.seq)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def latest_recording(
|
|
session: AsyncSession, call_id: str
|
|
) -> RecordingRecord | None:
|
|
result = await session.execute(
|
|
select(RecordingRecord)
|
|
.where(RecordingRecord.call_id == call_id)
|
|
.order_by(desc(RecordingRecord.started_at))
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def persist_call_on_create(call: ActiveCall) -> None:
|
|
"""Insert an in_progress CallRecord the moment a call starts.
|
|
|
|
Wired into CallManager as its on_call_created hook — a crash
|
|
mid-call leaves this row behind instead of erasing the call from
|
|
history. persist_call_on_end updates it to the terminal state.
|
|
"""
|
|
await _with_retry(_insert_in_progress_record, call)
|
|
|
|
|
|
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
|
|
"""Finalize the CallRecord and write transcript chunks for `call`.
|
|
|
|
Wired into CallManager as its on_call_ended hook by the
|
|
composition root in main.py.
|
|
"""
|
|
await _with_retry(_finalize_call_record, call, final_status)
|
|
|
|
|
|
async def _with_retry(write, call: ActiveCall, *args) -> None:
|
|
"""Losing the row means the call never happened as far as history
|
|
is concerned, so the final failure logs at ERROR with identifiers."""
|
|
for attempt in range(3):
|
|
try:
|
|
await write(call, *args)
|
|
return
|
|
except Exception as e:
|
|
if attempt == 2:
|
|
logger.error(
|
|
f"Call record lost ({write.__name__}): id={call.id} "
|
|
f"number={call.remote_number}: {e}"
|
|
)
|
|
return
|
|
await asyncio.sleep(2**attempt)
|
|
|
|
|
|
async def _insert_in_progress_record(call: ActiveCall) -> None:
|
|
async with session_scope() as session:
|
|
session.add(CallRecord(
|
|
id=call.id,
|
|
direction=call.direction,
|
|
remote_number=call.remote_number,
|
|
status="in_progress",
|
|
mode=call.mode.value,
|
|
intent=call.intent,
|
|
started_at=call.started_at,
|
|
device_used=call.device,
|
|
call_flow_id=call.call_flow_id,
|
|
metadata_={"services": list(call.services)},
|
|
))
|
|
|
|
|
|
async def _finalize_call_record(call: ActiveCall, final_status: CallStatus) -> None:
|
|
async with session_scope() as session:
|
|
record = await get_record(session, call.id)
|
|
if record is None:
|
|
# The create-time insert failed (or predates the hook);
|
|
# write the whole row now instead.
|
|
record = CallRecord(id=call.id)
|
|
session.add(record)
|
|
|
|
record.direction = call.direction
|
|
record.remote_number = call.remote_number
|
|
record.status = final_status.value
|
|
record.mode = call.mode.value
|
|
record.intent = call.intent
|
|
record.started_at = call.started_at
|
|
record.ended_at = datetime.now()
|
|
record.duration = int(call.duration)
|
|
record.hold_time = int(call.hold_time)
|
|
record.device_used = call.device
|
|
record.call_flow_id = call.call_flow_id
|
|
record.classification_timeline = [
|
|
{
|
|
"timestamp": c.timestamp,
|
|
"audio_type": c.audio_type.value,
|
|
"confidence": c.confidence,
|
|
}
|
|
for c in call.classification_history
|
|
]
|
|
metadata = {"services": list(call.services)}
|
|
if call.exploration_steps:
|
|
metadata["exploration_steps"] = call.exploration_steps
|
|
record.metadata_ = metadata
|
|
|
|
# Each transcript entry gets its own row with a sequence number
|
|
# and real offset so the dashboard can render click-to-seek.
|
|
for seq, entry in enumerate(call.transcript_chunks):
|
|
session.add(TranscriptChunk(
|
|
id=f"tc_{uuid.uuid4().hex[:10]}",
|
|
call_id=call.id,
|
|
seq=seq,
|
|
t_offset_ms=entry.t_offset_ms,
|
|
speaker=entry.speaker,
|
|
text=entry.text,
|
|
))
|