correlator/admin: capture per-scene incident_type + severity (#139)

_call_is_substanceless's "type" veto reads ctx["incident_type"] at decision
time, but that value was never persisted per-scene — only the last-scene-wins
flat field, which #138's window-4 dump analysis couldn't tell apart from
cross-scene contamination without re-guessing from a live dump. Adds
incident_type/severity to _apply_and_log's per-scene write and to admin.py's
_scene_summary allowlist (the debug-dump reader has its own field allowlist,
separate from the write side — silently would not have surfaced otherwise).

Sandboxed pytest: 380 -> 381.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
This commit is contained in:
Logan Cusano
2026-09-14 00:09:26 -04:00
co-authored by Claude Sonnet 5
parent 454fe7e81c
commit 7717fcccdd
3 changed files with 66 additions and 3 deletions
@@ -1458,9 +1458,17 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
updates = dict(corr_debug) updates = dict(corr_debug)
updates["scenes"] = { updates["scenes"] = {
str(scene_index): { str(scene_index): {
"transcript": ctx.get("scene_transcript"), "transcript": ctx.get("scene_transcript"),
"incident_id": incident_id, "incident_id": incident_id,
"corr_debug": corr_debug, "corr_debug": corr_debug,
# server-26#139: this scene's OWN extracted incident_type/
# severity, as read by _call_is_substanceless's ctx at
# decision time — not the call doc's flat top-level field,
# which is last-scene-wins (server-26#96) and was the reason
# #138's "type" veto couldn't be told apart from cross-scene
# contamination without re-guessing from a live dump.
"incident_type": ctx.get("incident_type"),
"severity": ctx.get("call_severity"),
} }
} }
try: try:
+5
View File
@@ -109,6 +109,11 @@ async def debug_correlation(
"scene_index": scene_index, "scene_index": scene_index,
"transcript": scene.get("transcript"), "transcript": scene.get("transcript"),
"incident_id": scene.get("incident_id"), "incident_id": scene.get("incident_id"),
# server-26#139: this scene's OWN incident_type/severity, as seen
# by _call_is_substanceless at decision time — not the call doc's
# flat top-level field, which is last-scene-wins (server-26#96).
"incident_type": scene.get("incident_type"),
"severity": scene.get("severity"),
"corr_path": corr_debug.get("corr_path"), "corr_path": corr_debug.get("corr_path"),
"corr_incident_idle_min": corr_debug.get("corr_incident_idle_min"), "corr_incident_idle_min": corr_debug.get("corr_incident_idle_min"),
"corr_distance_km": corr_debug.get("corr_distance_km"), "corr_distance_km": corr_debug.get("corr_distance_km"),
@@ -123,10 +123,60 @@ async def test_single_scene_call_still_gets_a_scenes_map_equivalent_to_flat_fiel
"transcript": "10-4", "transcript": "10-4",
"incident_id": None, "incident_id": None,
"corr_debug": {"corr_path": "fast/thin", "corr_consensus": "rules_only"}, "corr_debug": {"corr_path": "fast/thin", "corr_consensus": "rules_only"},
"incident_type": None,
"severity": None,
} }
} }
@pytest.mark.asyncio
async def test_scene_entry_captures_its_own_incident_type_not_a_sibling_scenes():
"""
server-26#139: _call_is_substanceless's "type" veto reads ctx["incident_type"]
at decision time, but that value was never persisted per-scene — only the
last-scene-wins flat field, which #138's dump analysis couldn't
distinguish from cross-scene contamination. Each scene's own scenes.<n>
entry must carry its own incident_type/severity, distinct from any other
scene on the same call.
"""
docs: dict[tuple, dict] = {}
async def fake_doc_set(collection, doc_id, data, merge=True):
docs.setdefault((collection, doc_id), {})
_merge(docs[(collection, doc_id)], data)
decision0 = {
"action": "orphan", "matched_incident": None, "incident_type": None,
"corr_debug": {"corr_path": "new", "corr_consensus": "tiebreak", "corr_gate_veto": "type"},
}
ctx0 = {
"call_id": "call-5", "scene_index": 0, "scene_transcript": "10-4, clear",
"incident_type": "traffic-stop", "call_severity": "routine",
}
decision1 = {
"action": "orphan", "matched_incident": None, "incident_type": None,
"corr_debug": {"corr_path": "new", "corr_consensus": "agreed"},
}
ctx1 = {
"call_id": "call-5", "scene_index": 1, "scene_transcript": "roll call",
"incident_type": None, "call_severity": "routine",
}
with patch.object(incident_correlator, "fstore") as mock_fstore:
mock_fstore.doc_set = fake_doc_set
await incident_correlator._apply_and_log(decision0, ctx0)
await incident_correlator._apply_and_log(decision1, ctx1)
scenes = docs[("calls", "call-5")]["scenes"]
assert scenes["0"]["incident_type"] == "traffic-stop"
assert scenes["0"]["severity"] == "routine"
assert scenes["1"]["incident_type"] is None
# _apply_and_log never writes incident_type/severity to the flat
# top-level fields at all (only corr_debug's own keys go flat) — the
# per-scene entry is the only place either value is ever persisted.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_corr_debug_writes_nothing_same_as_before(): async def test_empty_corr_debug_writes_nothing_same_as_before():
"""Preserve the pre-#96 short-circuit: no corr_debug means no write at """Preserve the pre-#96 short-circuit: no corr_debug means no write at