Three live measurement windows and two consensus-layer fixes (#125, #126) converged on one decision (CORRELATION_REVIEW_0907b.md, _0912.md): stop iterating the correlator's consensus layer, the actual lever is upstream — a classifier in scene extraction that recognizes radio housekeeping (roll call, bare 10-4/10-8/98 acknowledgements, unit check-ins) before it ever becomes a scene for the correlator to judge. Adds app/internal/chatter_classifier.py: a pure classify_chatter(transcript) function recognizing two shapes drawn from hand-labeled examples in the review docs, cross-referenced against the real dumps — not invented regexes. Deliberately conservative: anything that doesn't cleanly reduce to a known shape returns (False, None) and the existing pipeline runs unchanged. SHADOW MODE ONLY. intelligence.extract_scenes computes the verdict next to the existing _is_garbage_transcript / transcript_too_short gates and writes chatter_classifier_verdict / chatter_classifier_reason onto the call doc, but does not skip extraction. admin.py's correlation-debug _call_summary surfaces both fields, same pattern as corr_gate_veto (#115/#126), so the next live window can measure the real-world false-positive rate before anything is wired to actually skip extraction. TODO(server-26#127) marks the call site. Backtest against all three existing dumps (1002 calls): 154 flagged, 0 false positives (no flagged call carries tags, coords, non-routine severity, or matches any review-doc-named dangerous-to-drop transcript — the major extinguishing-fire call, geocoded calls, pursuit updates, the Pelham Station subject check, the property-retrieval call, all individually verified). tests/test_chatter_classifier.py: real transcripts from the dumps/review docs in both directions. Sandboxed pytest 332 -> 364, green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
145 lines
7.4 KiB
Python
145 lines
7.4 KiB
Python
"""
|
|
server-26#127 — upstream dispatch-vs-chatter classifier, shadow mode.
|
|
|
|
Fixtures are real transcripts, not invented ones: pulled from
|
|
`corr_dump_9-7_0437am.json`, `corr_dump_9-7_pm.json`, `corr_dump_9-12.json`
|
|
and the hand-labeled examples in `CORRELATION_REVIEW_0907b.md` /
|
|
`CORRELATION_REVIEW_0912.md`. The "must classify False" set specifically
|
|
includes every transcript those review docs flagged as dangerous to drop —
|
|
a false positive here is a real event silently losing its scene once this
|
|
classifier ever goes live, which is a much worse failure than a missed
|
|
chatter call staying in the existing (already-working) pipeline.
|
|
"""
|
|
import pytest
|
|
|
|
from app.internal.chatter_classifier import classify_chatter
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Must classify as chatter
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
CHATTER_EXAMPLES = [
|
|
# Bare acknowledgements / unit check-ins (CORRELATION_REVIEW_0907b.md)
|
|
("114 Paul.\n114 Paul, Metro Central.\n10-4.", "bare_acknowledgement"),
|
|
("Affirmative, in charge of 10-8. 10-8, 10-4.", "bare_acknowledgement"),
|
|
("6-8, you can show me 98. 10-4.", "bare_acknowledgement"),
|
|
("10-4, 10-4 Central, 98. 10-4, 98.", "bare_acknowledgement"),
|
|
("7 for Post 1 and 2, 98. Affirm.", "bare_acknowledgement"),
|
|
("11-Victor to Central. 11-Victor. 72-Holland, 1-5. Central.", "bare_acknowledgement"),
|
|
# Roll call (CORRELATION_REVIEW_0907b.md / _0912.md)
|
|
("Post 4, Ossining. And to volunteer patrol, stand by for roll call.", "roll_call"),
|
|
("Headquarters to all cars, stand by for roll call.", "roll_call"),
|
|
("All Troop NYC Patrols, stand by for roll call.", "roll_call"),
|
|
(
|
|
"Car 100, roll call.\nHenry 1.\nHenry 1.\nSam 1.\nSam 1.\n45 Baker.\n"
|
|
"45 Baker.\n11 Adam.\nAdam.\n11 Baker.\nBaker.\nStaff 1.\n1.\nStaff 2.",
|
|
"roll_call",
|
|
),
|
|
(
|
|
"Headquarters, all cars on a roll call. Baker 1? Baker 1. Henry 1? "
|
|
"Henry 1. Sam 2? Sam 2. 11 Adam? 11. 11 Baker? 11 Baker.",
|
|
"roll_call",
|
|
),
|
|
("Because all cars came out for roll call.", "roll_call"),
|
|
("10-1. KL Cars, that concludes roll call, time is 3-31.", "roll_call"),
|
|
# Minimal single-word / bare-code transmissions (orphan pool, all 3 dumps)
|
|
("10-4.", "bare_acknowledgement"),
|
|
("Roger.", "bare_acknowledgement"),
|
|
("Clear.", "bare_acknowledgement"),
|
|
("Affirmative.", "bare_acknowledgement"),
|
|
("Received.", "bare_acknowledgement"),
|
|
("10-8, clear. 10-4.", "bare_acknowledgement"),
|
|
("Post 4, 10-8. 10-4.", "bare_acknowledgement"),
|
|
("Central to 6 Henry.", "bare_acknowledgement"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("transcript,expected_reason", CHATTER_EXAMPLES)
|
|
def test_classifies_chatter(transcript, expected_reason):
|
|
is_chatter, reason = classify_chatter(transcript)
|
|
assert is_chatter is True
|
|
assert reason == expected_reason
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Must NOT classify as chatter — real events, including every transcript the
|
|
# review docs specifically named as dangerous to drop.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
REAL_EVENT_EXAMPLES = [
|
|
# The major "extinguishing fire" call (severity=major, tags=[extinguishing-fire])
|
|
("Dispatch, this is 7-4, extinguishing fire.", "extinguishing_fire"),
|
|
# Geocoded 911-hangup call (has location_coords)
|
|
(
|
|
"7, Charlie. Charlie, check and advise, we've got a call for service "
|
|
"coming over, it's going to be a 9-1-1 hangout, no voice contact. "
|
|
"Looks like it was an automated message saying it's the Doral Hat Company.",
|
|
"geocoded_911_hangup",
|
|
),
|
|
# Pursuit updates (severity=major, tags include pursuit / low-speed-pursuit)
|
|
("I'm aware of that one. It's a low-speed pursuit. It's refusing to pull over.", "low_speed_pursuit"),
|
|
(
|
|
"1. Headquarters to 5-charlie. I'm going to say the last thing to anyone.\n"
|
|
"2. Info, Sgt. Repeat.\n"
|
|
"3. The SP is on a pursuit southbound on I-684. It's approaching the airport.\n"
|
|
"4. Okay, thank you.\n5. 23-59.",
|
|
"pursuit_i684",
|
|
),
|
|
# "6 Alpha ... Pelham Station" subject check (CORRELATION_REVIEW_0907b.md's
|
|
# own "genuinely distinct events" list) — looks like a bare check-in but
|
|
# dispatches a unit to a specific location.
|
|
(
|
|
"6 Alpha, this is Central. 7 Alpha here.\n"
|
|
"6 Alpha, can you show me on scene at Pelham Station? Stand by.",
|
|
"pelham_station_subject_check",
|
|
),
|
|
# Property-retrieval call (tags=[property-retrieval])
|
|
(
|
|
"Property was retrieved with a 911. Can I get a phone number? 10-4. "
|
|
"Phone number is 214792. 214792.",
|
|
"property_retrieval",
|
|
),
|
|
# Subject check south of Maronex Station (tags=[subject-check])
|
|
(
|
|
"Proceed. Show me on a subject south of Maronex Station. Can I get a "
|
|
"15 check by New York client ID?",
|
|
"maronex_subject_check",
|
|
),
|
|
# Trespassing at milepost 13.7 (tags=[trespassing])
|
|
(
|
|
"Can you just 10-5 that job? You came over real muffled.\n"
|
|
"10-4, there's going to be a trespass on the tracks.\n"
|
|
"Train 8755 reports two juveniles, one male, one female, both wearing "
|
|
"white shirts, track three side, at milepost 13.7.",
|
|
"trespass_milepost_13_7",
|
|
),
|
|
# MVA (severity=moderate, tags=[traffic-accident])
|
|
("1. Train patrol 9.\n2. MVA 4, how close is it?\n3. 10-4.", "mva"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("transcript,label", REAL_EVENT_EXAMPLES, ids=[l for _, l in REAL_EVENT_EXAMPLES])
|
|
def test_does_not_classify_real_events_as_chatter(transcript, label):
|
|
is_chatter, reason = classify_chatter(transcript)
|
|
assert is_chatter is False, f"{label}: false positive, reason={reason!r}"
|
|
assert reason is None
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Edge cases
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def test_empty_transcript_not_chatter():
|
|
assert classify_chatter("") == (False, None)
|
|
assert classify_chatter(None) == (False, None)
|
|
assert classify_chatter(" ") == (False, None)
|
|
|
|
|
|
def test_unrecognized_content_defaults_to_not_chatter():
|
|
# Anything containing real descriptive words the classifier doesn't
|
|
# recognize must fall through to (False, None), not guess.
|
|
is_chatter, reason = classify_chatter("Shots fired, officer down, requesting backup immediately.")
|
|
assert is_chatter is False
|
|
assert reason is None
|