test_triage_eval.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """Tests for the triage calibration report. Pure, hardware-free."""
  2. import json
  3. from types import SimpleNamespace
  4. from stt_daemon.assistant.triage import TriageAnalyzer
  5. from stt_daemon.triage_eval import (
  6. analyze_states,
  7. format_report,
  8. load_records,
  9. replay_states,
  10. suggest_thresholds,
  11. )
  12. def _analyzer(tmp_path, questions):
  13. path = tmp_path / "questions.json"
  14. path.write_text(json.dumps(questions), encoding="utf-8")
  15. return TriageAnalyzer(
  16. SimpleNamespace(
  17. questions_file=str(path),
  18. state_char_budget=1200,
  19. confidence_field="answer_confidence",
  20. escalate_threshold=0.6,
  21. choice_min_confidence=0.85,
  22. )
  23. )
  24. def _questions():
  25. return {
  26. "blocker": {"type": "noul", "instructions": "Something blocking was raised."},
  27. "decision_status": {
  28. "type": "choice",
  29. "instructions": "Where is the decision?",
  30. "criteria": {"none": "nothing", "made": "settled"},
  31. },
  32. }
  33. def _record(text, speaker="Efren Yevale", status="matched"):
  34. return {"text": text, "speaker": speaker, "speaker_status": status}
  35. def _write_session(tmp_path, records):
  36. session = tmp_path / "2026-01-01-00-00-00"
  37. session.mkdir()
  38. lines = [json.dumps(record) for record in records]
  39. (session / "transcripts.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
  40. return session
  41. def test_load_records_skips_missing_bad_and_empty(tmp_path):
  42. session = _write_session(tmp_path, [_record("hola")])
  43. with open(session / "transcripts.jsonl", "a", encoding="utf-8") as handle:
  44. handle.write("not json\n")
  45. handle.write(json.dumps({"text": " "}) + "\n")
  46. records = load_records(session)
  47. assert [r["text"] for r in records] == ["hola"]
  48. assert load_records(tmp_path / "absent") == []
  49. def test_replay_states_labels_speaker_and_honours_stride(tmp_path):
  50. analyzer = _analyzer(tmp_path, _questions())
  51. records = [_record(f"line {i}") for i in range(5)]
  52. states = replay_states(records, analyzer, window=2, stride=2)
  53. assert states == [
  54. "- Efren: line 0\n- Efren: line 1",
  55. "- Efren: line 2\n- Efren: line 3",
  56. "- Efren: line 4",
  57. ]
  58. def test_replay_states_marks_unmatched_as_unknown(tmp_path):
  59. analyzer = _analyzer(tmp_path, _questions())
  60. states = replay_states([_record("hola", status="unknown")], analyzer, window=4)
  61. assert states == ["- Unknown: hola"]
  62. def test_analyze_states_reports_fire_rates_and_choice_labels(tmp_path):
  63. analyzer = _analyzer(tmp_path, _questions())
  64. payloads = iter(
  65. [
  66. {
  67. "blocker": {"noul": 0.9, "confidence": 0.9, "answer_confidence": 0.9},
  68. "decision_status": {
  69. "choice": "made",
  70. "confidence": 0.9,
  71. "answer_confidence": 0.9,
  72. },
  73. },
  74. {
  75. "blocker": {"noul": 0.1, "confidence": 0.9, "answer_confidence": 0.9},
  76. "decision_status": {
  77. "choice": "none",
  78. "confidence": 0.9,
  79. "answer_confidence": 0.9,
  80. },
  81. },
  82. ]
  83. )
  84. reports = analyze_states(
  85. ["s1", "s2"], analyzer, lambda state: next(payloads), thresholds=(0.5, 0.8)
  86. )
  87. by_qid = {report.qid: report for report in reports}
  88. assert by_qid["blocker"].windows == 2
  89. assert by_qid["blocker"].fires[0.5] == 1
  90. assert by_qid["blocker"].fires[0.8] == 1
  91. assert round(by_qid["blocker"].mean_p, 3) == 0.5
  92. assert by_qid["decision_status"].labels == {"made": 1, "none": 1}
  93. def test_analyze_states_survives_a_failing_predictor(tmp_path):
  94. analyzer = _analyzer(tmp_path, _questions())
  95. def boom(state):
  96. raise RuntimeError("model gone")
  97. assert analyze_states(["s1"], analyzer, boom) == []
  98. def test_format_report_prints_fire_rates_and_suggestions(tmp_path):
  99. analyzer = _analyzer(tmp_path, _questions())
  100. payload = {
  101. "blocker": {"noul": 0.70, "confidence": 0.70, "answer_confidence": 0.70},
  102. }
  103. reports = analyze_states(["s1", "s2"], analyzer, lambda state: payload, thresholds=(0.5, 0.9))
  104. text = format_report(reports, 2, thresholds=(0.5, 0.9))
  105. assert "windows replayed: 2" in text
  106. assert "fires@0.50" in text
  107. assert "100%" in text
  108. assert "suggested per-question thresholds" in text
  109. assert "blocker 0.90" in text
  110. def test_suggest_thresholds_picks_the_least_restrictive_cut_that_is_rare(tmp_path):
  111. payloads = iter(
  112. [
  113. {"q": {"noul": p, "confidence": p, "answer_confidence": p}}
  114. for p in (0.95, 0.55, 0.55, 0.55)
  115. ]
  116. )
  117. reports = analyze_states(
  118. ["s1", "s2", "s3", "s4"],
  119. _analyzer(tmp_path, {"q": {"type": "noul", "instructions": "x"}}),
  120. lambda state: next(payloads),
  121. thresholds=(0.5, 0.7, 0.9),
  122. )
  123. assert suggest_thresholds(reports, (0.5, 0.7, 0.9), target=0.25) == {"q": 0.7}