From 7189ba03e4c44ef8479c7ad7f7efce5c1026306f Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 7 Sep 2026 00:12:56 -0400 Subject: [PATCH] =?UTF-8?q?correlator:=20address=20#102=20review=20?= =?UTF-8?q?=E2=80=94=200-based=20segment=20labels,=20never-empty=20slice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drb-correlation-review on the prior commit flagged two ways the per-scene transcript could silently fall back to the whole-call text: 1. _build_transcript_block numbered transmissions "1." while the prompt says "0-based indices" — a model echoing the labels it saw returned 1-based indices, shifting every scene's slice by one. Labels are now "0." to match the documented contract (also fixes the same latent skew in _build_scene_embed_text / #80). 2. An empty join (bad / out-of-range / non-int indices) hit `transcript or call_doc.get(...)` in _build_context and fell back to the whole-call transcript — re-opening the leak exactly when indices are wrong. The slice now falls back to this call's own whole transcript *before* _build_context sees it, so it is never "". Non-int and negative indices are rejected rather than raising. Slice logic extracted to `_scene_transcript_text` with a dedicated test file (4 cases: subset, corrected-wins, no-indices fallback, bad-indices fallback). Call-doc fallback kept (sweep / no-scene path) per the review. Also restored the `-> ` spacing lost in the prior commit's kwarg edit. Full c2-core suite green: 300 passed (sandboxed venv). Still DO NOT MERGE until the measurement window closes. Co-Authored-By: Claude Sonnet 5 --- .../app/internal/incident_correlator.py | 4 +- drb-c2-core/app/internal/intelligence.py | 55 ++++++++++++++----- drb-c2-core/tests/test_scene_transcript.py | 40 ++++++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 drb-c2-core/tests/test_scene_transcript.py diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 30c8808..f94c2bf 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -671,7 +671,7 @@ async def correlate_call( embedding: Optional[list] = None, severity: Optional[str] = None, transcript: Optional[str] = None, -) ->Optional[str]: +) -> Optional[str]: """ Link call_id to an existing incident or create a new one. Thin wrapper: builds context → runs rules decision → commits. @@ -712,7 +712,7 @@ async def preview_correlation( embedding: Optional[list] = None, severity: Optional[str] = None, transcript: Optional[str] = None, -) ->dict: +) -> dict: """ Run the rules engine and return the decision WITHOUT committing to Firestore. diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index b1e737d..d62a0e1 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -337,18 +337,9 @@ async def extract_scenes( ) embedding = await asyncio.to_thread(_sync_embed, scene_text) - # This scene's own words, unprefixed — corrected text if we have it, - # else the raw segments this scene owns, else (single-scene) the whole - # transcript. The correlator's LLM tier reads this per scene instead of - # the call doc's whole-call transcript (server-26#102). - if transcript_corrected: - scene_transcript = transcript_corrected - elif segments and segment_indices: - scene_transcript = " ".join( - segments[i]["text"] for i in segment_indices if i < len(segments) - ) - else: - scene_transcript = transcript + scene_transcript = _scene_transcript_text( + transcript, segments, segment_indices, transcript_corrected + ) processed.append({ "tags": tags, @@ -585,11 +576,49 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]: def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str: """Format transcript as numbered transmissions if segments are available.""" if segments and len(segments) > 1: - lines = [f"{i+1}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)] + # 0-based labels, matching the prompt's "0-based indices into the + # numbered transmissions" — the model echoes these back as + # `segment_indices`, which _build_scene_embed_text and the per-scene + # `transcript` (server-26#102) then slice with directly. + lines = [f"{i}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)] return f"Transmissions ({len(segments)}):\n" + "\n".join(lines) return f"Transcript:\n{transcript}" +def _scene_transcript_text( + transcript: str, + segments: Optional[list[dict]], + segment_indices: Optional[list[int]], + transcript_corrected: Optional[str], +) -> str: + """ + This scene's own words, unprefixed — the segments it owns, joined. + + server-26#102: the correlator's LLM tier reads this per scene instead of + the call doc's whole-call transcript, so on a multi-scene call scene N is + no longer judged against scenes 1..N-1's text. + + Never returns "". Anything that would leave the slice empty — no + `segment_indices` (a single-segment call is never numbered by + `_build_transcript_block`), or indices that are out of range / not ints — + falls back to the whole-call transcript, which for a single-scene call is + the same text and for a mis-sliced multi-scene call is at least this + call's own words. `_sync_extract`'s prompt documents 0-based indices and + `_build_transcript_block` numbers to match, so no base normalisation here. + """ + if transcript_corrected: + return transcript_corrected + if segments and segment_indices: + joined = " ".join( + segments[i]["text"] + for i in segment_indices + if isinstance(i, int) and 0 <= i < len(segments) + ) + if joined: + return joined + return transcript + + def _build_scene_embed_text( transcript: str, segments: Optional[list[dict]], diff --git a/drb-c2-core/tests/test_scene_transcript.py b/drb-c2-core/tests/test_scene_transcript.py new file mode 100644 index 0000000..5a42bd8 --- /dev/null +++ b/drb-c2-core/tests/test_scene_transcript.py @@ -0,0 +1,40 @@ +""" +server-26#102 — a scene is correlated on its OWN transcript, not the whole call. + +_scene_transcript_text slices the segments a scene owns. It must never return +"" (an empty slice would let incident_correlator._build_context fall back to +the call doc's whole-call transcript, re-opening the leak in exactly the case +— bad indices — where it matters). +""" +from app.internal.intelligence import _scene_transcript_text + +SEGS = [ + {"text": "structure fire, 12 Main"}, + {"text": "engine 4 responding"}, + {"text": "traffic stop, plate ABC"}, + {"text": "one occupant"}, +] +WHOLE = "structure fire, 12 Main engine 4 responding traffic stop, plate ABC one occupant" + + +def test_scene_owns_a_subset_of_segments(): + assert _scene_transcript_text(WHOLE, SEGS, [0, 1], None) == "structure fire, 12 Main engine 4 responding" + assert _scene_transcript_text(WHOLE, SEGS, [2, 3], None) == "traffic stop, plate ABC one occupant" + + +def test_corrected_text_wins_when_present(): + assert _scene_transcript_text(WHOLE, SEGS, [0], "cleaned up text") == "cleaned up text" + + +def test_no_segment_indices_falls_back_to_whole_call(): + # single-segment calls are never numbered by _build_transcript_block → null indices + assert _scene_transcript_text(WHOLE, SEGS, None, None) == WHOLE + assert _scene_transcript_text(WHOLE, None, [0, 1], None) == WHOLE + + +def test_out_of_range_or_nonint_indices_fall_back_never_empty(): + assert _scene_transcript_text(WHOLE, SEGS, [9, 10], None) == WHOLE # all out of range + assert _scene_transcript_text(WHOLE, SEGS, ["1", "2"], None) == WHOLE # 1-based strings, rejected + assert _scene_transcript_text(WHOLE, SEGS, [-1], None) == WHOLE # negative + # partial validity: keep what's in range + assert _scene_transcript_text(WHOLE, SEGS, [3, 99], None) == "one occupant"