Reviewer report: pressing Approve left the "needs check" badge in place and
appeared to navigate to a new page.
Root cause was a split brain, not a single bug. Approve writes the
voice_status.json ledger, but speaker_confidence read the voice_status key
embedded in speakers.json. That inline copy is stale - the web layer
overwrites it from the ledger on read (_load_speakers), so the page showed
the right value while the assessment computed from the old one. In Last
Dwarf, "Advisor" was 'pending' in speakers.json and 'approved' in the
ledger, so the reviewer's click had saved and still left it flagged.
- assess_speakers() takes the ledger as an explicit argument, authoritative
over the inline key; load_speaker_assessments() reads voice_status.json.
- New SpeakerAssessment.reviewed flag, surfaced in the serialized view.
- Approval clears the badge but keeps the reasons visible: the underlying
facts (role-only name, one utterance) stay inspectable on hover rather
than being silently discarded.
- Corrupt/ non-dict ledgers degrade to {} - this module never raises.
- Approve swapped #speakers-list with innerHTML, replacing the whole
reviewer list with a single <tr>; now swaps only its own row via
outerHTML on #speaker-row-N.
- The review panel explained only rename/merge and never mentioned Approve,
so the process was unclear; it now lists the three outcomes.
16 new tests. Suite: 681 passed, 8 pre-existing failures unchanged.
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""Tests for the reviewer-facing behaviour of speaker identification confidence.
|
|
|
|
The specific regression: a reviewer pressed Approve on a speaker, the badge
|
|
stayed, and the button looked broken. ``speaker_confidence`` never read
|
|
``voice_status``, so no amount of approving could clear it. Its own docstring
|
|
claimed an approved voice was a positive signal, so this restores the stated
|
|
behaviour rather than adding a new one.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from umbraaudio.analysis.speaker_confidence import (
|
|
assess_speakers,
|
|
load_speaker_assessments,
|
|
)
|
|
|
|
CLEAR = "clear"
|
|
NEEDS_REVIEW = "needs_review"
|
|
|
|
# A speaker the heuristic will always doubt: a role-only name, no dialogue.
|
|
ROLE_ONLY = {
|
|
"primary_name": "Advisor",
|
|
"demographics": {},
|
|
"vocal_traits": [],
|
|
"description": "Created from unresolved name token 'Advisor'",
|
|
"aliases": ["Advisor"],
|
|
}
|
|
|
|
|
|
def _stats(**over) -> dict:
|
|
base = {
|
|
"Advisor": {"utterances": 1, "needs_review": 0, "mean_confidence": 0.5}
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def test_role_only_speaker_is_flagged_before_approval() -> None:
|
|
out = assess_speakers([dict(ROLE_ONLY)], _stats())
|
|
assert out[0].status == NEEDS_REVIEW
|
|
assert out[0].reviewed is False
|
|
|
|
|
|
def test_approval_clears_the_badge() -> None:
|
|
"""The bug: Approve recorded a decision the assessment ignored."""
|
|
approved = dict(ROLE_ONLY, voice_status="approved")
|
|
out = assess_speakers([approved], _stats())
|
|
assert out[0].status == CLEAR
|
|
assert out[0].reviewed is True
|
|
|
|
|
|
def test_approval_keeps_the_reasons_visible() -> None:
|
|
"""Clearing the badge must not discard the underlying facts."""
|
|
approved = dict(ROLE_ONLY, voice_status="approved")
|
|
out = assess_speakers([approved], _stats())
|
|
assert out[0].reasons, "reasons should survive an approval"
|
|
assert any("utterance" in r for r in out[0].reasons)
|
|
|
|
|
|
def test_approval_is_matched_case_insensitively() -> None:
|
|
approved = dict(ROLE_ONLY, voice_status="Approved")
|
|
assert assess_speakers([approved], _stats())[0].status == CLEAR
|
|
|
|
|
|
def test_pending_voice_status_does_not_clear() -> None:
|
|
pending = dict(ROLE_ONLY, voice_status="pending")
|
|
assert assess_speakers([pending], _stats())[0].status == NEEDS_REVIEW
|
|
|
|
|
|
def test_rejected_voice_status_does_not_clear() -> None:
|
|
rejected = dict(ROLE_ONLY, voice_status="rejected")
|
|
assert assess_speakers([rejected], _stats())[0].status == NEEDS_REVIEW
|
|
|
|
|
|
def test_unreviewed_speaker_is_unaffected() -> None:
|
|
"""Sanity: speakers without a voice_status behave exactly as before."""
|
|
out = assess_speakers([dict(ROLE_ONLY)], _stats())
|
|
assert out[0].status == NEEDS_REVIEW
|
|
|
|
|
|
def test_already_clear_speaker_stays_clear_after_approval() -> None:
|
|
solid = {
|
|
"primary_name": "Adric",
|
|
"demographics": {"gender": "male"},
|
|
"vocal_traits": ["measured"],
|
|
"description": "The palace factor",
|
|
"aliases": [],
|
|
}
|
|
stats = {"Adric": {"utterances": 400, "needs_review": 0, "mean_confidence": 0.9}}
|
|
out = assess_speakers([dict(solid, voice_status="approved")], stats)
|
|
assert out[0].status == CLEAR
|
|
assert out[0].reviewed is True
|
|
|
|
|
|
# ------------------------------------------------------------- via the loader
|
|
|
|
|
|
def test_load_speaker_assessments_exposes_reviewed(tmp_path: Path) -> None:
|
|
(tmp_path / "speakers.json").write_text(
|
|
json.dumps({"speakers": [dict(ROLE_ONLY, voice_status="approved")]})
|
|
)
|
|
got = load_speaker_assessments(tmp_path)
|
|
assert got["Advisor"].reviewed is True
|
|
assert got["Advisor"].status == CLEAR
|
|
|
|
|
|
def test_missing_speakers_file_returns_empty(tmp_path: Path) -> None:
|
|
assert load_speaker_assessments(tmp_path) == {}
|
|
|
|
|
|
def test_ledger_overrides_stale_speakers_json(tmp_path: Path) -> None:
|
|
"""The exact "Advisor" regression: two sources of truth disagreed.
|
|
|
|
The Approve button writes ``voice_status.json``. The speaker records in
|
|
``speakers.json`` kept a stale inline ``voice_status`` that the web layer
|
|
overwrites on read. Reading the stale copy left an approved speaker flagged
|
|
even though the reviewer had accepted it.
|
|
"""
|
|
(tmp_path / "speakers.json").write_text(
|
|
json.dumps({"speakers": [dict(ROLE_ONLY, voice_status="pending")]})
|
|
)
|
|
(tmp_path / "voice_status.json").write_text(json.dumps({"Advisor": "approved"}))
|
|
|
|
got = load_speaker_assessments(tmp_path)["Advisor"]
|
|
assert got.status == CLEAR, "ledger approval must win over the stale inline copy"
|
|
assert got.reviewed is True
|
|
|
|
|
|
def test_ledger_pending_does_not_clear(tmp_path: Path) -> None:
|
|
(tmp_path / "speakers.json").write_text(
|
|
json.dumps({"speakers": [dict(ROLE_ONLY, voice_status="approved")]})
|
|
)
|
|
(tmp_path / "voice_status.json").write_text(json.dumps({"Advisor": "pending"}))
|
|
assert load_speaker_assessments(tmp_path)["Advisor"].status == NEEDS_REVIEW
|
|
|
|
|
|
def test_corrupt_ledger_does_not_break_the_page(tmp_path: Path) -> None:
|
|
(tmp_path / "speakers.json").write_text(json.dumps({"speakers": [ROLE_ONLY]}))
|
|
(tmp_path / "voice_status.json").write_text("{not json")
|
|
got = load_speaker_assessments(tmp_path)
|
|
assert got["Advisor"].status == NEEDS_REVIEW
|
|
|
|
|
|
def test_ledger_that_is_a_list_is_ignored(tmp_path: Path) -> None:
|
|
(tmp_path / "speakers.json").write_text(json.dumps({"speakers": [ROLE_ONLY]}))
|
|
(tmp_path / "voice_status.json").write_text('["approved"]')
|
|
assert load_speaker_assessments(tmp_path)["Advisor"].status == NEEDS_REVIEW
|
|
|
|
|
|
def test_assess_speakers_accepts_ledger_directly() -> None:
|
|
out = assess_speakers([dict(ROLE_ONLY)], _stats(), {"Advisor": "approved"})
|
|
assert out[0].status == CLEAR
|
|
|
|
|
|
def test_absent_ledger_key_falls_back_to_inline() -> None:
|
|
"""No ledger entry for this speaker: the inline value still applies."""
|
|
out = assess_speakers(
|
|
[dict(ROLE_ONLY, voice_status="approved")], _stats(), {"Someone Else": "approved"}
|
|
)
|
|
assert out[0].status == CLEAR
|