Files
praxis/tests/test_classifier.py
T
Praxis CI 9108b07de1 feat(P01-03-05,P01-03-06): interruptibility harness + LLM-as-judge branch classifier
server/interruptibility.py — programmatic check that the pipeline task is
configured with allow_interruptions=True (D-008 abort-and-yield). The manual
test (speaking during AI speech cuts it off) is documented in
docs/latency-report.md; the automated test confirms the flag is set.

server/scenarios/classifier.py — classify_branch() uses
deepseek-v4-flash:cloud no-think (D-020) as LLM-as-judge to classify the
learner's turn transcripts into accept_resolution or escalate at session end,
OFFLINE from the voice loop (D-P1-05, G-002 post-hoc classification). Parses
the LLM's JSON response leniently (code fences, unknown-id fallback, malformed
JSON scan). classify_branch_sync_heuristic() is a rule-based fallback for
tests/e2e when no API key is present. 11 tests pass (interruptibility flag
on/off/missing; classifier accept/escalate scripted transcripts; JSON parse
valid/code-fenced/unknown-id/malformed; fake-LLM async classify; offline
structural assertion).

---ci---
phase: 1
milestone: v0.1
plan: 03
task: 03-05,03-06
status: execute
persona: backend-engineer
requirements:
  covered: [REQ-VOICE-04, REQ-SCEN-01]
---/ci---
2026-08-01 13:13:28 +00:00

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()