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"