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

137
tests/test_learner.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Call-flow learner tests.
Exploration discoveries become a linked CallFlow, survive with the
persisted call record, and the learn_call_flow MCP tool turns them
into a stored flow (refining on subsequent calls).
"""
import pytest
from fastmcp import Client
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import db.database as dbmod
import services.llm_client as llm_mod
from core.call_manager import CallManager
from core.event_bus import EventBus
from db.database import Base
from mcp_server.server import create_mcp_server
from models.call import CallStatus
from models.call_flow import ActionType
from services import call_persistence as store
from services.call_flow_learner import CallFlowLearner
DISCOVERIES = [
{"timestamp": 1.0, "audio_type": "ringing", "confidence": 0.9,
"transcript": "", "action_taken": None},
{"timestamp": 4.0, "audio_type": "ivr_prompt", "confidence": 0.8,
"transcript": "press 1 for english press 2 for french",
"action_taken": {"dtmf": "1"}},
{"timestamp": 8.0, "audio_type": "ivr_prompt", "confidence": 0.8,
"transcript": "press 1 for billing press 2 for support press 0 for an agent",
"action_taken": {"dtmf": "0"}},
{"timestamp": 12.0, "audio_type": "music", "confidence": 0.9,
"transcript": "", "action_taken": None},
{"timestamp": 200.0, "audio_type": "live_human", "confidence": 0.85,
"transcript": "thank you for holding, how can I help",
"action_taken": None},
]
class TestBuildFlow:
async def test_discoveries_become_linked_steps(self):
learner = CallFlowLearner(llm_client=None)
flow = await learner.build_flow(
phone_number="+18005551234",
discovered_steps=DISCOVERIES,
intent="dispute a charge",
)
# ringing is skipped; menus/hold/human map to actions in order
assert [s.action for s in flow.steps] == [
ActionType.DTMF, ActionType.DTMF, ActionType.HOLD, ActionType.TRANSFER,
]
assert [s.action_value for s in flow.steps[:2]] == ["1", "0"]
assert [s.next_step for s in flow.steps[:-1]] == [s.id for s in flow.steps[1:]]
assert "auto-learned" in flow.tags
assert flow.phone_number == "+18005551234"
class TestExplorationPersistence:
async def test_exploration_steps_survive_with_the_record(self, mem_db):
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
call = await cm.create_call("+18005551234", intent="dispute a charge")
call.exploration_steps.extend(DISCOVERIES)
await cm.end_call(call.id, CallStatus.COMPLETED)
async with mem_db() as session:
record = await store.get_record(session, call.id)
assert record.metadata_["exploration_steps"] == DISCOVERIES
@pytest.fixture
async def mem_db(monkeypatch):
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield factory
await engine.dispose()
@pytest.fixture
def no_llm(monkeypatch):
"""learn_call_flow must work without an LLM (labels stay heuristic)."""
monkeypatch.setattr(llm_mod, "_shared_client", None)
monkeypatch.setattr(llm_mod, "_shared_failed", True)
class TestLearnCallFlowTool:
async def _completed_exploration_call(self, number: str) -> str:
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
call = await cm.create_call(number, intent="dispute a charge")
call.exploration_steps.extend(DISCOVERIES)
await cm.end_call(call.id, CallStatus.COMPLETED)
return call.id
async def test_learns_then_refines(self, mem_db, no_llm):
call_id = await self._completed_exploration_call("+18005551234")
mcp = create_mcp_server(lambda: None)
async with Client(mcp) as client:
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
assert "Learned new flow" in result.content[0].text
async with mem_db() as session:
row = await store.get_flow_by_number(session, "+18005551234")
assert row is not None
assert len(row.steps) == 4
assert "auto-learned" in row.tags
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
assert "Refined existing flow" in result.content[0].text
async def test_call_without_exploration_data(self, mem_db, no_llm):
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
call = await cm.create_call("+15550001111")
await cm.end_call(call.id, CallStatus.COMPLETED)
mcp = create_mcp_server(lambda: None)
async with Client(mcp) as client:
result = await client.call_tool("learn_call_flow", {"call_id": call.id})
assert "no exploration data" in result.content[0].text
async def test_unknown_call(self, mem_db, no_llm):
mcp = create_mcp_server(lambda: None)
async with Client(mcp) as client:
result = await client.call_tool(
"learn_call_flow", {"call_id": "call_nope"}
)
assert "No record found" in result.content[0].text

View File

@@ -25,6 +25,7 @@ EXPECTED_TOOLS = {
"get_call_recording",
"get_call_summary",
"search_call_history",
"learn_call_flow",
"list_devices",
"gateway_status",
}

67
tests/test_websocket.py Normal file
View File

@@ -0,0 +1,67 @@
"""
WebSocket event-stream tests.
The socket is refused (4401) without the bearer token, and an
authorized client immediately receives the synthetic trunk-status
event followed by the replayed recent history.
"""
import asyncio
import pytest
from pydantic import SecretStr
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import main
from config import Settings, get_settings
from core.gateway import AIPSTNGateway
from models.events import EventType, GatewayEvent
@pytest.fixture
def ws_app(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
gateway = AIPSTNGateway(settings=Settings())
main.app.state.gateway = gateway
yield gateway
del main.app.state.gateway
def _publish(gateway, call_id: str) -> None:
asyncio.run(gateway.event_bus.publish(GatewayEvent(
type=EventType.CALL_INITIATED,
call_id=call_id,
data={},
message=f"call {call_id}",
)))
class TestEventStream:
def test_refused_without_token(self, ws_app):
client = TestClient(main.app)
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/events"):
pass
assert exc.value.code == 4401
def test_trunk_status_then_replayed_history(self, ws_app):
_publish(ws_app, "call_ws1")
_publish(ws_app, "call_ws2")
client = TestClient(main.app)
with client.websocket_connect("/ws/events?token=tok") as ws:
first = ws.receive_json()
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
replayed = [ws.receive_json() for _ in range(2)]
assert [m["call_id"] for m in replayed] == ["call_ws1", "call_ws2"]
def test_per_call_stream_filters(self, ws_app):
client = TestClient(main.app)
with client.websocket_connect(
"/ws/calls/call_target/events?token=tok"
) as ws:
_publish(ws_app, "call_other")
_publish(ws_app, "call_target")
msg = ws.receive_json()
assert msg["call_id"] == "call_target"