Stop Whisper hallucinations and dedupe recordings across nodes
Two independent sources of garbage in the AI pipeline, both visible in the 2026-08-16 correlation dump. 1. Hallucinated transcripts. The Whisper prompt opened with an enumerated run of ten-codes: 10-4, 10-23, 10-20, 10-97 and so on. Whisper treats prompt text as preceding transcript, so on noisy or silent audio it continued the series, emitting transcripts that count upward from 10-4 to 10-99. The existing no_speech_prob filter could not catch these: the model is highly confident in text it invented by continuing a pattern. The prompt no longer contains a series to extend, and _is_degenerate() rejects the three shapes this failure takes: ascending ten-code runs, one phrase looping, and near-identical segments across a whole recording. Verified against 13 transcripts from production: all four known hallucinations rejected, all nine real ones kept, including terse traffic containing legitimate codes. 2. Duplicate recordings. node-002 and node-PI-2 both cover TG 9048 and both uploaded the same transmissions, ~1.1s apart. Nine pairs appeared in one dump. Each was transcribed, billed and correlated twice, and the resulting incident listed two units where there was one. Canonical selection is by earliest started_at, tie-broken on call_id, NOT by upload order: upload order varies with encode time and network latency, so it would make the authoritative recording non-deterministic. Call documents are created from MQTT call_start before uploads arrive, so both nodes independently reach the same verdict. The loser keeps its audio (it may be the cleaner capture) but is excluded from STT, correlation, the re-correlation sweep and the orphan debug view. Also fixes _sync_transcribe returning a bare None when OPENAI_API_KEY is missing, where the caller unpacks two values. A missing key surfaced as a misleading "Transcription failed" instead of the real warning. Adds tests/test_dedup.py (15 cases). dedup.py reaches Firestore through an injected callable so it stays importable without firebase-admin present.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Unit tests for cross-node duplicate detection.
|
||||
|
||||
Fixture timings come from real production data: node-002 and node-PI-2 both
|
||||
recorded TG 9048 on 2026-08-16, starting ~1.1s apart.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.internal.dedup import _parse_dt, _is_canonical, find_duplicate_of
|
||||
|
||||
BASE = datetime(2026, 8, 16, 19, 31, 46, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _query_returning(*calls):
|
||||
"""Stand-in for fstore.collection_where."""
|
||||
async def _q(_collection, _conditions):
|
||||
return list(calls)
|
||||
return _q
|
||||
|
||||
|
||||
def _query_raising(exc):
|
||||
async def _q(_collection, _conditions):
|
||||
raise exc
|
||||
return _q
|
||||
|
||||
|
||||
def _call(call_id, node_id, offset_seconds=0.0, talkgroup_id=9048, **extra):
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"node_id": node_id,
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": talkgroup_id,
|
||||
"started_at": BASE + timedelta(seconds=offset_seconds),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timestamp parsing — Firestore returns three different shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_dt_accepts_aware_datetime():
|
||||
assert _parse_dt(BASE) == BASE
|
||||
|
||||
|
||||
def test_parse_dt_assumes_utc_for_naive_datetime():
|
||||
naive = datetime(2026, 8, 16, 19, 31, 46)
|
||||
assert _parse_dt(naive) == BASE
|
||||
|
||||
|
||||
def test_parse_dt_accepts_iso_string_with_z():
|
||||
assert _parse_dt("2026-08-16T19:31:46Z") == BASE
|
||||
|
||||
|
||||
def test_parse_dt_returns_none_for_junk():
|
||||
assert _parse_dt("not a date") is None
|
||||
assert _parse_dt(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_earlier_start_wins():
|
||||
early = _call("a", "node-002", 0.0)
|
||||
late = _call("b", "node-PI-2", 1.1)
|
||||
assert _is_canonical(early, [late]) is True
|
||||
assert _is_canonical(late, [early]) is False
|
||||
|
||||
|
||||
def test_identical_starts_break_tie_on_call_id():
|
||||
first = _call("aaa", "node-002", 0.0)
|
||||
second = _call("bbb", "node-PI-2", 0.0)
|
||||
assert _is_canonical(first, [second]) is True
|
||||
assert _is_canonical(second, [first]) is False
|
||||
|
||||
|
||||
def test_both_nodes_reach_the_same_verdict():
|
||||
"""The whole point: the decision must not depend on upload order."""
|
||||
a = _call("a", "node-002", 0.0)
|
||||
b = _call("b", "node-PI-2", 1.1)
|
||||
verdicts = [_is_canonical(a, [b]), _is_canonical(b, [a])]
|
||||
assert verdicts.count(True) == 1, "exactly one recording must be canonical"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_duplicate_of
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_canonical_id_for_later_recording():
|
||||
canonical = _call("canon", "node-002", 0.0)
|
||||
later = _call("later", "node-PI-2", 1.1)
|
||||
q = _query_returning(canonical, later)
|
||||
assert await find_duplicate_of(later, query=q) == "canon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_the_canonical_recording():
|
||||
canonical = _call("canon", "node-002", 0.0)
|
||||
later = _call("later", "node-PI-2", 1.1)
|
||||
q = _query_returning(canonical, later)
|
||||
assert await find_duplicate_of(canonical, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_node_is_never_a_duplicate():
|
||||
"""Back-to-back transmissions from one node are real, separate calls."""
|
||||
first = _call("a", "node-002", 0.0)
|
||||
second = _call("b", "node-002", 2.0)
|
||||
q = _query_returning(first, second)
|
||||
assert await find_duplicate_of(second, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_talkgroup_is_not_a_duplicate():
|
||||
other_tg = _call("a", "node-002", 0.0, talkgroup_id=9600)
|
||||
mine = _call("b", "node-PI-2", 1.0, talkgroup_id=9048)
|
||||
q = _query_returning(other_tg, mine)
|
||||
assert await find_duplicate_of(mine, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_never_chains_onto_another_duplicate():
|
||||
"""A third node must point at the original, not at a duplicate of it."""
|
||||
canonical = _call("canon", "node-002", 0.0)
|
||||
already_dupe = _call("dupe", "node-PI-2", 0.5, duplicate_of="canon")
|
||||
third = _call("third", "node-003", 1.0)
|
||||
q = _query_returning(canonical, already_dupe, third)
|
||||
assert await find_duplicate_of(third, query=q) == "canon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_returns_none():
|
||||
lonely = _call("only", "node-002", 0.0)
|
||||
q = _query_returning(lonely)
|
||||
assert await find_duplicate_of(lonely, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_identifiers_skip_the_check():
|
||||
called = False
|
||||
|
||||
async def _q(_collection, _conditions):
|
||||
nonlocal called
|
||||
called = True
|
||||
return []
|
||||
|
||||
incomplete = {"call_id": "x", "node_id": "node-002", "started_at": BASE}
|
||||
assert await find_duplicate_of(incomplete, query=_q) is None
|
||||
assert called is False, "must bail out before querying"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_failure_never_blocks_the_upload():
|
||||
call = _call("a", "node-002", 0.0)
|
||||
q = _query_raising(RuntimeError("firestore down"))
|
||||
assert await find_duplicate_of(call, query=q) is None
|
||||
Reference in New Issue
Block a user