Stage 6: learn_call_flow rebuilt on the learner, docs truth sweep

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>
This commit is contained in:
2026-07-10 13:45:35 -04:00
parent f7a11f2f20
commit ff7ea8623a
13 changed files with 444 additions and 107 deletions

View File

@@ -264,6 +264,70 @@ def create_mcp_server(
except Exception as e:
return f"Error creating call flow: {e}"
@mcp.tool()
async def learn_call_flow(call_id: str, company_name: str = "") -> str:
"""
Build (or refine) a reusable IVR call flow from a completed
hold-slayer exploration call.
Exploration calls record every IVR prompt heard and DTMF sent;
this turns those discoveries into a stored call flow so the next
call to that number navigates directly instead of exploring.
If a flow already exists for the number, the discoveries refine
it (timeouts averaged, usage counters updated).
Args:
call_id: A completed call that ran in exploration mode
company_name: Optional company name for labeling a new flow
"""
from db.database import session_scope
from services import call_persistence as store
from services.call_flow_learner import CallFlowLearner
from services.llm_client import get_llm
try:
async with session_scope() as session:
record = await store.get_record(session, call_id)
if not record:
return f"No record found for call {call_id}."
steps = (record.metadata_ or {}).get("exploration_steps") or []
if not steps:
return (
f"Call {call_id} has no exploration data to learn from. "
"Only hold-slayer calls without a stored flow record "
"IVR discoveries."
)
learner = CallFlowLearner(llm_client=get_llm())
existing = await store.get_flow_by_number(
session, record.remote_number
)
if existing:
flow = await learner.merge_discoveries(
store.flow_to_model(existing), steps, intent=record.intent
)
await store.update_flow_from_model(session, existing, flow)
return (
f"Refined existing flow '{existing.name}' for "
f"{record.remote_number} from {len(steps)} discoveries "
f"({len(flow.steps)} steps, used {flow.times_used}x)."
)
flow = await learner.build_flow(
phone_number=record.remote_number,
discovered_steps=steps,
intent=record.intent,
company_name=company_name or None,
)
await store.save_learned_flow(session, flow)
return (
f"Learned new flow '{flow.name}' with {len(flow.steps)} "
f"steps from {len(steps)} discoveries (ID: {flow.id})."
)
except Exception as e:
return f"Error learning call flow: {e}"
@mcp.tool()
async def send_dtmf(call_id: str, digits: str) -> str:
"""