Extraction: model in config, chatter gate, geocode cache (server-26#298, server-26#104, server-26#88)
EXTRACTION_MODEL / EMBEDDING_MODEL replace the gpt-4o-mini and text-embedding-3-small literals. gate_chatter (classify_chatter with roll_call narrowed to whole-transcript roll calls) skips correction, extraction, embedding and geocoding for housekeeping, writes skip_reason=non_event_chatter and still runs the free clearance/plate backstops; upload.py's no-scene fallback admits it like transcript_too_short. Ships OFF (CHATTER_GATE_ENABLED =false): a gated call has no units, so it takes the no-fit-check thin attach, and #127 requires a clean live shadow window first. In-process TTL cache (24h, negatives 1h, 5000 entries, LRU) in front of the Geocoding API, keyed by address+bounds; holds coordinates only, inside the Maps 30-day lat/lng caching limit; errors and key/quota statuses not cached. #104: the no-scene fallback passes the call doc's severity. #88: resolved/reassignment parsed by _llm_bool, not bool(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d485eae41a
commit
240bbee5a4
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
server-26#298 — the chatter pre-filter gates every paid call.
|
||||
|
||||
Radio housekeeping (roll call, a bare "Post 4, 10-8, 10-4") used to go through
|
||||
transcript correction, scene extraction, an embedding per scene and a geocode,
|
||||
only to come back as a thin routine scene. Now it pays for none of them; the
|
||||
free rule paths (clearance parse, self-initiated/plate backstop) still run.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import intelligence, transcription
|
||||
from app.internal.chatter_classifier import gate_chatter
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _gate_on():
|
||||
"""The gate ships OFF (shadow) until a live window clears it (#127);
|
||||
these tests exercise it switched on."""
|
||||
with patch.object(settings, "chatter_gate_enabled", True):
|
||||
yield
|
||||
|
||||
|
||||
class _NoPaidCalls:
|
||||
"""Fail the test if any paid extraction/embedding/geocode call happens."""
|
||||
|
||||
def __enter__(self):
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("a paid AI call was made for chatter")
|
||||
|
||||
async def _aboom(*a, **k):
|
||||
raise AssertionError("a paid geocode was made for chatter")
|
||||
|
||||
self.ps = [
|
||||
patch.object(intelligence, "_sync_extract", _boom),
|
||||
patch.object(intelligence, "_sync_embed", _boom),
|
||||
patch.object(intelligence, "_geocode_lookup", _aboom),
|
||||
]
|
||||
for p in self.ps:
|
||||
p.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
for p in self.ps:
|
||||
p.stop()
|
||||
|
||||
|
||||
async def _extract(transcript, talkgroup_name="PD Dispatch"):
|
||||
saved: dict = {}
|
||||
|
||||
async def _doc_set(coll, doc_id, data, merge=True):
|
||||
saved.update(data)
|
||||
|
||||
with patch.object(intelligence.fstore, "doc_get_cached", AsyncMock(return_value=None)), \
|
||||
patch.object(intelligence.fstore, "doc_set", _doc_set):
|
||||
scenes = await intelligence.extract_scenes("call-1", transcript, talkgroup_name)
|
||||
return scenes, saved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_acknowledgement_over_five_words_skips_extraction():
|
||||
with _NoPaidCalls():
|
||||
scenes, saved = await _extract("10-4, 10-4 Central, 98. 10-4, 98.")
|
||||
assert scenes == []
|
||||
assert saved["skip_reason"] == "non_event_chatter"
|
||||
assert saved["severity"] == "routine"
|
||||
assert saved["chatter_classifier_verdict"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whole_roll_call_skips_extraction():
|
||||
roll = ("Car 100, roll call. Henry 1. Henry 1. Sam 1. Sam 1. 45 Baker. 45 Baker. "
|
||||
"11 Adam. Adam. 11 Baker. Baker. Staff 1. 1. Staff 2.")
|
||||
with _NoPaidCalls():
|
||||
scenes, saved = await _extract(roll)
|
||||
assert scenes == [] and saved["skip_reason"] == "non_event_chatter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roll_call_mentioned_on_the_way_to_a_real_job_still_extracts():
|
||||
"""The #127 review's false-positive shape must still reach the model."""
|
||||
text = "Delayed for roll call, then respond to a structure fire at 12 Main Street"
|
||||
assert gate_chatter(text) == (False, None)
|
||||
with patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
|
||||
await _extract(text)
|
||||
ext.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plate_read_chatter_still_becomes_a_stop_for_free():
|
||||
text = "Lincoln Charlie Robert 7-4-0-7, Lincoln Charlie Robert 7-4-0-7."
|
||||
with _NoPaidCalls():
|
||||
scenes, saved = await _extract(text)
|
||||
assert len(scenes) == 1 and scenes[0]["tags"] == ["traffic-stop"]
|
||||
assert saved["severity"] == "minor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_off_restores_shadow_mode():
|
||||
with patch.object(settings, "chatter_gate_enabled", False), \
|
||||
patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
|
||||
_, saved = await _extract("10-4, 10-4 Central, 98. 10-4, 98.")
|
||||
ext.assert_called_once()
|
||||
assert saved["chatter_classifier_verdict"] is True, "the shadow verdict is still recorded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_event_is_not_gated():
|
||||
with patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
|
||||
await _extract("Shots fired, officer down, requesting backup immediately at the school.")
|
||||
ext.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcription_skips_correction_for_chatter():
|
||||
flags = {"transcript_correction_enabled": True}
|
||||
with patch.object(settings, "openai_api_key", "k"), \
|
||||
patch.object(settings, "groq_api_key", None), \
|
||||
patch.object(transcription, "_download", lambda uri: "/nonexistent.flac"), \
|
||||
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
|
||||
"text": "Post 4, 10-8. 10-4. Copy, 98.", "segments": [],
|
||||
"degenerate": False, "duration": 2.0, "confidence": None}), \
|
||||
patch.object(transcription.fstore, "doc_set", AsyncMock()), \
|
||||
patch.object(transcription, "ai_health") as health, \
|
||||
patch.object(transcription.transcript_correction, "correct", AsyncMock()) as correct, \
|
||||
patch("app.internal.feature_flags.resolve_flags",
|
||||
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
|
||||
health.report_healthy = AsyncMock()
|
||||
text, _ = await transcription.transcribe_call("call-1", "gs://b/c.flac", "PD Dispatch")
|
||||
assert text == "Post 4, 10-8. 10-4. Copy, 98."
|
||||
correct.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatter_still_reaches_the_free_thin_correlation_path():
|
||||
"""non_event_chatter joins transcript_too_short in upload.py's no-scene
|
||||
fallback (rules-only for a thin call — no LLM)."""
|
||||
from app.routers import upload
|
||||
|
||||
with patch.object(upload, "fstore") as fs, \
|
||||
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr:
|
||||
fs.doc_get = AsyncMock(return_value={"skip_reason": "non_event_chatter", "severity": "routine"})
|
||||
fs.doc_set = AsyncMock()
|
||||
await upload._extract_and_correlate(
|
||||
call_id="c1", node_id="n1", system_id="s1", talkgroup_id=1,
|
||||
talkgroup_name="PD Dispatch", transcript="10-4 Central, 98.", scenes=[],
|
||||
)
|
||||
corr.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_scene_fallback_passes_the_call_doc_severity():
|
||||
"""server-26#104: a reprocessed call whose doc says 'major' must not be
|
||||
re-correlated as 'routine' (thin, no fit check)."""
|
||||
from app.routers import upload
|
||||
|
||||
with patch.object(upload, "fstore") as fs, \
|
||||
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr:
|
||||
fs.doc_get = AsyncMock(return_value={"severity": "major"})
|
||||
fs.doc_set = AsyncMock()
|
||||
await upload._extract_and_correlate(
|
||||
call_id="c1", node_id="n1", system_id="s1", talkgroup_id=1,
|
||||
talkgroup_name="PD Dispatch", transcript="whatever", scenes=[],
|
||||
)
|
||||
assert corr.await_args.kwargs["severity"] == "major"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_off_still_corrects_chatter():
|
||||
"""With the gate off, transcription must not skip correction either."""
|
||||
flags = {"transcript_correction_enabled": True}
|
||||
with patch.object(settings, "chatter_gate_enabled", False), \
|
||||
patch.object(settings, "openai_api_key", "k"), \
|
||||
patch.object(settings, "groq_api_key", None), \
|
||||
patch.object(transcription, "_download", lambda uri: "/nonexistent.flac"), \
|
||||
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
|
||||
"text": "Post 4, 10-8. 10-4. Copy, 98.", "segments": [],
|
||||
"degenerate": False, "duration": 2.0, "confidence": None}), \
|
||||
patch.object(transcription.fstore, "doc_set", AsyncMock()), \
|
||||
patch.object(transcription, "ai_health") as health, \
|
||||
patch.object(transcription.transcript_correction, "correct",
|
||||
AsyncMock(return_value=(None, None, False))) as correct, \
|
||||
patch("app.internal.feature_flags.resolve_flags",
|
||||
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
|
||||
health.report_healthy = AsyncMock()
|
||||
await transcription.transcribe_call("call-1", "gs://b/c.flac", "PD Dispatch")
|
||||
correct.assert_awaited_once()
|
||||
|
||||
|
||||
def test_gate_ships_off():
|
||||
from app.config import Settings
|
||||
assert Settings.model_fields["chatter_gate_enabled"].default is False
|
||||
Reference in New Issue
Block a user