813bd586d6
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---
163 lines
6.3 KiB
Python
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 |