fbd6602814
---ci--- phase: 0 milestone: v0.1 status: complete ---/ci---
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
"""Tests for interruptibility (TASK-03-05) + branch classifier (TASK-03-06)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from server.interruptibility import pipeline_allows_interruptions
|
|
from server.scenarios.classifier import (
|
|
classify_branch,
|
|
classify_branch_sync_heuristic,
|
|
_parse_branch,
|
|
)
|
|
from server.scenarios.loader import load
|
|
|
|
|
|
# ─── TASK-03-05: interruptibility ────────────────────────────────────────────
|
|
|
|
|
|
def test_pipeline_allows_interruptions_flag():
|
|
"""The pipeline task must be configured with allow_interruptions=True (D-008)."""
|
|
task = SimpleNamespace(params=SimpleNamespace(allow_interruptions=True))
|
|
assert pipeline_allows_interruptions(task) is True
|
|
|
|
|
|
def test_pipeline_allows_interruptions_false_flag():
|
|
task = SimpleNamespace(params=SimpleNamespace(allow_interruptions=False))
|
|
assert pipeline_allows_interruptions(task) is False
|
|
|
|
|
|
def test_pipeline_allows_interruptions_missing_params():
|
|
task = SimpleNamespace()
|
|
assert pipeline_allows_interruptions(task) is False
|
|
|
|
|
|
# ─── TASK-03-06: branch classifier ───────────────────────────────────────────
|
|
|
|
|
|
def test_heuristic_classifier_accept():
|
|
"""A scripted empathetic transcript → accept_resolution."""
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
turns = [
|
|
"I'm really sorry you're frustrated. I can offer a full refund right now.",
|
|
"Let me confirm the next steps for you.",
|
|
]
|
|
branch_id = classify_branch_sync_heuristic(scenario, turns)
|
|
assert branch_id == "accept_resolution"
|
|
|
|
|
|
def test_heuristic_classifier_escalate():
|
|
"""A scripted defensive transcript → escalate."""
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
turns = [
|
|
"Well, our policy says we don't do refunds after 30 days.",
|
|
"That's just how it works, I can't help you.",
|
|
]
|
|
branch_id = classify_branch_sync_heuristic(scenario, turns)
|
|
assert branch_id == "escalate"
|
|
|
|
|
|
def test_parse_branch_valid_json():
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
text = '{"branch_id": "accept_resolution", "reason": "empathy shown"}'
|
|
bid, reason = _parse_branch(text, scenario)
|
|
assert bid == "accept_resolution"
|
|
assert "empathy" in reason
|
|
|
|
|
|
def test_parse_branch_code_fenced_json():
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
text = '```json\n{"branch_id": "escalate", "reason": "defensive"}\n```'
|
|
bid, reason = _parse_branch(text, scenario)
|
|
assert bid == "escalate"
|
|
|
|
|
|
def test_parse_branch_unknown_id_falls_back():
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
text = '{"branch_id": "not_real", "reason": "x"}'
|
|
bid, reason = _parse_branch(text, scenario)
|
|
# Falls back to the first branch.
|
|
assert bid in scenario.branch_ids()
|
|
assert "fallback" in reason
|
|
|
|
|
|
def test_parse_branch_malformed_json_scans_for_id():
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
text = "The learner matches the escalate branch."
|
|
bid, reason = _parse_branch(text, scenario)
|
|
assert bid == "escalate"
|
|
assert "fallback" in reason
|
|
|
|
|
|
class _FakeLLM:
|
|
"""A fake LLMProvider for the classifier test (no real API call)."""
|
|
|
|
debrief_model = "deepseek-v4-flash:cloud"
|
|
roleplay_model = "gemma4:cloud"
|
|
|
|
async def chat_full(self, messages, *, model=None, no_think=False):
|
|
return (
|
|
'{"branch_id": "accept_resolution", "reason": "empathy + concrete_resolution"}',
|
|
{"output_tokens": 10},
|
|
)
|
|
|
|
|
|
def test_classify_branch_with_fake_llm():
|
|
"""The async classifier returns the LLM's branch verdict (R7, offline)."""
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
turns = ["I'm sorry, I can offer a refund."]
|
|
bid, reason = asyncio.run(classify_branch(_FakeLLM(), scenario, turns))
|
|
assert bid == "accept_resolution"
|
|
assert "empathy" in reason
|
|
|
|
|
|
def test_classifier_runs_offline_from_voice_loop():
|
|
"""D-P1-05: the classifier is a one-shot end-of-session call, not per-turn."""
|
|
# This is a structural assertion: classify_branch takes the full turns list,
|
|
# not a single turn — confirming it runs at session end, not on the
|
|
# latency-critical voice path.
|
|
scenario = load("customer_service_refund_ca_v01")
|
|
turns = ["turn 1", "turn 2", "turn 3"]
|
|
bid = classify_branch_sync_heuristic(scenario, turns)
|
|
assert bid in scenario.branch_ids() |