Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd636af8f | ||
|
|
e27f8f6636 | ||
|
|
f23026b9ab | ||
|
|
7717fcccdd |
@@ -1461,6 +1461,21 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
"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.
|
||||||
|
# NOTE: unlike incident_type, call_severity is already
|
||||||
|
# coerced to "routine" when extraction emitted nothing
|
||||||
|
# (ctx build: `severity or "routine"`) — a scene reading
|
||||||
|
# "routine" here doesn't distinguish "extraction said
|
||||||
|
# routine" from "extraction said nothing". Don't split a
|
||||||
|
# severity veto the way #138 splits the type veto without
|
||||||
|
# accounting for that.
|
||||||
|
"incident_type": ctx.get("incident_type"),
|
||||||
|
"severity": ctx.get("call_severity"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,7 +32,17 @@ from app.config import settings
|
|||||||
# intelligence_started_at at all (pre-#131 call doc, or the marker write
|
# intelligence_started_at at all (pre-#131 call doc, or the marker write
|
||||||
# itself failed) is NOT held back by this — absence isn't evidence of an
|
# itself failed) is NOT held back by this — absence isn't evidence of an
|
||||||
# in-flight pipeline, and #131's own bug predates this field existing.
|
# in-flight pipeline, and #131's own bug predates this field existing.
|
||||||
MIN_MINUTES_SINCE_PIPELINE_START = 5
|
#
|
||||||
|
# 15, not 5: neither OpenAI's Whisper client nor Gemini's call in
|
||||||
|
# llm_correlator.py sets a request timeout (server-26#153), so a hung call can
|
||||||
|
# run well past a few minutes on SDK-default retries, and this constant is a
|
||||||
|
# guess against that unbounded tail, not a measured bound. Raising it costs
|
||||||
|
# nothing on the recovery side: a call that finished processing (linked OR
|
||||||
|
# genuinely orphaned) always has corr_path set (_apply_and_log writes it even
|
||||||
|
# on the orphan action), so it's already excluded by the
|
||||||
|
# `not c.get("corr_path")` filter below and never reaches this check at all —
|
||||||
|
# this constant only ever delays calls that are still actually running.
|
||||||
|
MIN_MINUTES_SINCE_PIPELINE_START = 15
|
||||||
|
|
||||||
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
|
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
|
||||||
MAX_SWEEP_ATTEMPTS = 3
|
MAX_SWEEP_ATTEMPTS = 3
|
||||||
|
|||||||
@@ -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,66 @@ 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. Pins _apply_and_log's write
|
||||||
|
side: each scene's own scenes.<n> entry carries its own incident_type/
|
||||||
|
severity, distinct from any other scene on the same call. Does NOT cover
|
||||||
|
whether the ctx handed to _call_is_substanceless is the same object that
|
||||||
|
reaches here — that linkage is pinned by test_consensus_gate.py and
|
||||||
|
test_incident_identity.py, not this file.
|
||||||
|
"""
|
||||||
|
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": "moderate",
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
doc = docs[("calls", "call-5")]
|
||||||
|
scenes = doc["scenes"]
|
||||||
|
assert scenes["0"]["incident_type"] == "traffic-stop"
|
||||||
|
assert scenes["0"]["severity"] == "routine"
|
||||||
|
assert scenes["1"]["incident_type"] is None
|
||||||
|
assert scenes["1"]["severity"] == "moderate"
|
||||||
|
# _apply_and_log only ever flat-merges corr_debug's own keys (:1460) — a
|
||||||
|
# future corr_debug["incident_type"] would silently clobber
|
||||||
|
# intelligence.py's flat field, so this is asserted, not just commented.
|
||||||
|
assert "incident_type" not in doc
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
Reference in New Issue
Block a user