correlator: LLM tier reads the scene transcript, not the whole call (#102) #112

Merged
logan merged 2 commits from fix/llm-correlator-scene-transcript into main 2026-09-07 04:40:35 -04:00
3 changed files with 84 additions and 15 deletions
Showing only changes of commit 7189ba03e4 - Show all commits
@@ -671,7 +671,7 @@ async def correlate_call(
embedding: Optional[list] = None, embedding: Optional[list] = None,
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None, transcript: Optional[str] = None,
) ->Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
Thin wrapper: builds context → runs rules decision → commits. Thin wrapper: builds context → runs rules decision → commits.
@@ -712,7 +712,7 @@ async def preview_correlation(
embedding: Optional[list] = None, embedding: Optional[list] = None,
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None, transcript: Optional[str] = None,
) ->dict: ) -> dict:
""" """
Run the rules engine and return the decision WITHOUT committing to Firestore. Run the rules engine and return the decision WITHOUT committing to Firestore.
+42 -13
View File
@@ -337,18 +337,9 @@ async def extract_scenes(
) )
embedding = await asyncio.to_thread(_sync_embed, scene_text) embedding = await asyncio.to_thread(_sync_embed, scene_text)
# This scene's own words, unprefixed — corrected text if we have it, scene_transcript = _scene_transcript_text(
# else the raw segments this scene owns, else (single-scene) the whole transcript, segments, segment_indices, transcript_corrected
# 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
processed.append({ processed.append({
"tags": tags, "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: def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
"""Format transcript as numbered transmissions if segments are available.""" """Format transcript as numbered transmissions if segments are available."""
if segments and len(segments) > 1: 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"Transmissions ({len(segments)}):\n" + "\n".join(lines)
return f"Transcript:\n{transcript}" 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( def _build_scene_embed_text(
transcript: str, transcript: str,
segments: Optional[list[dict]], segments: Optional[list[dict]],
@@ -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"