""" 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