Files
praxis/tests/test_evidence_extractor_integration.py
T
Praxis CI 813bd586d6 docs(milestone): merge v0.3-mastery-scoring → main
v0.3 milestone merged to main. Mastery scoring + competency rubrics +
verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered.
Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres).

---ci---
project: praxis
phase: 2
milestone: v0.3
status: complete
milestone_complete: true
milestone_merged_to_main: true
---/ci---
2026-08-04 00:14:59 +00:00

163 lines
6.3 KiB
Python

"""SLICE-03 TASK-03-05 — evidence extractor integration test (mocked LLM)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock
import pytest
from server.mastery.evidence_extractor import Evidence, extract_evidence
from server.mastery.mastery_score import compute_scenario_score
from server.mastery.rubric_loader import clear_cache, load_rubric
from server.mastery.rubric_scorer import score
_RUBRICS_DIR = Path(__file__).resolve().parent.parent / "rubrics"
def _turns() -> list[dict]:
return [
{"role": "customer", "content": "My order arrived cracked and I'm furious."},
{
"role": "learner",
"content": (
"I'm really sorry the bowl arrived cracked — that's genuinely "
"frustrating. I can refund the full amount to your original card "
"within 3 business days, or send a replacement first class tomorrow. "
"Which would you prefer?"
),
},
{"role": "customer", "content": "Just refund it."},
{
"role": "learner",
"content": (
"Of course — I've issued a full refund of $42.99 to your Visa ending "
"4421. You'll see it in 2-3 business days. Is there anything else?"
),
},
]
def _canned_good() -> str:
t1 = _turns()[1]["content"]
t2 = _turns()[3]["content"]
return json.dumps(
[
{"criterion_id": "empathy", "quote": t1, "signals": ["named_emotion_in_own_words", "acknowledged_specific"]},
{"criterion_id": "resolution", "quote": t1, "signals": ["concrete_method", "concrete_amount_or_channel", "concrete_next_step"]},
{"criterion_id": "de_escalation", "quote": t1, "signals": ["explicit_acknowledge_reframe_offer"]},
{"criterion_id": "professionalism", "quote": t2, "signals": ["plain_language", "in_role_throughout", "no_prohibited_advice"]},
]
)
def _canned_bad() -> str:
return json.dumps(
[
{"criterion_id": "empathy", "quote": "I apologize for the inconvenience, dear customer.", "signals": ["named_emotion_in_own_words"]},
{"criterion_id": "resolution", "quote": "I will issue a refund shortly.", "signals": ["concrete_method"]},
]
)
def _canned_malformed() -> str:
return "not json at all {["
def _make_llm(raws: list[str]) -> AsyncMock:
llm = AsyncMock()
llm.chat_full = AsyncMock(side_effect=[(r, {"model": "test"}) for r in raws])
return llm
def _rubric():
clear_cache()
return load_rubric("customer_service", rubrics_dir=_RUBRICS_DIR)
@pytest.mark.asyncio
async def test_end_to_end_extraction_to_scoring_deterministic():
rubric = _rubric()
llm = _make_llm([_canned_good(), _canned_good()])
res1 = await extract_evidence(_turns(), rubric.criterion_ids(), llm)
res2 = await extract_evidence(_turns(), rubric.criterion_ids(), llm)
assert not res1.scoring_inconclusive and not res2.scoring_inconclusive
cs1 = score(res1.evidence, rubric)
cs2 = score(res2.evidence, rubric)
assert [s.model_dump() for s in cs1] == [s.model_dump() for s in cs2]
ss = compute_scenario_score(cs1, rubric)
assert ss.passed is True
assert ss.weighted_mean >= 3.0
@pytest.mark.asyncio
async def test_json_schema_validation_rejects_malformed_then_recovers():
rubric = _rubric()
llm = _make_llm([_canned_malformed(), _canned_good()])
res = await extract_evidence(_turns(), rubric.criterion_ids(), llm)
assert not res.scoring_inconclusive
assert res.attempts == 2
assert {e.criterion_id for e in res.evidence} == {"empathy", "resolution", "de_escalation", "professionalism"}
@pytest.mark.asyncio
async def test_unknown_criterion_id_rejected():
rubric = _rubric()
raw = json.dumps(
[{"criterion_id": "nope", "quote": _turns()[1]["content"], "signals": ["x"]}]
)
llm = _make_llm([raw, _canned_good()])
res = await extract_evidence(_turns(), rubric.criterion_ids(), llm)
assert not res.scoring_inconclusive
assert all(e.criterion_id != "nope" for e in res.evidence)
@pytest.mark.asyncio
async def test_inconclusive_when_bad_quotes_twice():
rubric = _rubric()
llm = _make_llm([_canned_bad(), _canned_bad(), _canned_bad()])
res = await extract_evidence(_turns(), rubric.criterion_ids(), llm, max_attempts=2)
assert res.scoring_inconclusive is True
assert res.evidence == []
assert res.attempts == 3
@pytest.mark.asyncio
async def test_inconclusive_result_does_not_score_to_zero_scenario():
rubric = _rubric()
llm = _make_llm([_canned_bad(), _canned_bad(), _canned_bad()])
res = await extract_evidence(_turns(), rubric.criterion_ids(), llm, max_attempts=2)
assert res.scoring_inconclusive
# callers must NOT compute a scenario score from inconclusive evidence;
# verify that scoring empty evidence yields a level-1 fail, which the
# session_recorder MUST skip (the contract is: inconclusive → no score).
empty_scores = score(res.evidence, rubric)
ss = compute_scenario_score(empty_scores, rubric)
assert ss.passed is False
# The integration contract: scoring_inconclusive short-circuits upstream
# before compute_scenario_score is ever called. This test documents that
# empty-evidence scoring is NOT what inconclusive means — inconclusive is
# a distinct branch that yields no scenario score at all.
@pytest.mark.asyncio
async def test_quote_fuzzy_match_against_transcript():
rubric = _rubric()
t1 = _turns()[1]["content"]
near = t1.replace("", "-").rstrip(".")
raw = json.dumps(
[
{"criterion_id": "empathy", "quote": near, "signals": ["named_emotion_in_own_words", "acknowledged_specific"]},
{"criterion_id": "resolution", "quote": near, "signals": ["concrete_method", "concrete_amount_or_channel", "concrete_next_step"]},
{"criterion_id": "de_escalation", "quote": near, "signals": ["explicit_acknowledge_reframe_offer"]},
{"criterion_id": "professionalism", "quote": _turns()[3]["content"], "signals": ["plain_language", "in_role_throughout", "no_prohibited_advice"]},
]
)
llm = _make_llm([raw])
res = await extract_evidence(_turns(), rubric.criterion_ids(), llm)
assert not res.scoring_inconclusive
assert res.attempts == 1