correlator: LLM tier reads the scene's transcript, not the whole call (server-26#102)

The last leg of the #80/#95 scene-context leak. llm_correlator._call_block
read call_doc's whole-call transcript for every scene, so on a multi-scene
call every scene's cheap-tier and tiebreaker decision was made against text
that also contained the other scenes.

- intelligence.py: each processed[] scene now carries its own "transcript" —
  transcript_corrected, else this scene's segments joined, else (single scene)
  the whole transcript.
- _build_context / preview_correlation / correlate_call: take a `transcript`
  param; _build_context resolves ctx["scene_transcript"] from it, falling
  back to the call doc (sweep, single-scene, tests) — the fallback is kept
  here, unlike embedding/severity, because a scene always has real text.
- upload.py: both scene loops pass scene["transcript"].
- llm_correlator._call_block: reads ctx["scene_transcript"] (call-doc
  fallback retained for test-built ctx).
- recorrelation_sweep: passes the call doc's text explicitly.
- +1 regression test. Full c2-core suite green (296 passed, sandboxed venv).

NOT for merge until the running correlation measurement window closes and its
dump is analysed — deploying a correlator change mid-window would mix old and
new behaviour in the sample.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-07 00:06:37 -04:00
co-authored by Claude Sonnet 5
parent b430cf32f2
commit ef1e3d7f9d
6 changed files with 75 additions and 7 deletions
@@ -670,7 +670,8 @@ async def correlate_call(
reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
) -> Optional[str]:
transcript: Optional[str] = None,
) ->Optional[str]:
"""
Link call_id to an existing incident or create a new one.
Thin wrapper: builds context → runs rules decision → commits.
@@ -686,7 +687,7 @@ async def correlate_call(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity,
embedding=embedding, severity=severity, transcript=transcript,
)
decision = _run_decision(ctx)
return await _apply_and_log(decision, ctx)
@@ -710,7 +711,8 @@ async def preview_correlation(
reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
) -> dict:
transcript: Optional[str] = None,
) ->dict:
"""
Run the rules engine and return the decision WITHOUT committing to Firestore.
@@ -730,7 +732,7 @@ async def preview_correlation(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity,
embedding=embedding, severity=severity, transcript=transcript,
)
decision = _run_decision(ctx)
return {"decision": decision, "ctx": ctx}
@@ -765,6 +767,7 @@ async def _build_context(
create_if_new: bool,
embedding: Optional[list] = None,
severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> dict:
now = reference_time or datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours)
@@ -804,6 +807,13 @@ async def _build_context(
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
call_severity = severity or "routine"
# The transcript the LLM correlation tier reasons over. Prefer the SCENE's
# own words (server-26#102) — passed by upload.py's scene loop — and fall
# back to the call doc only when no scene text was supplied (the
# recorrelation sweep, and single-scene calls where the two are identical).
# Without this, every non-primary scene of a multi-scene call was judged by
# the LLM against a transcript containing the OTHER scenes.
scene_transcript = transcript or call_doc.get("transcript_corrected") or call_doc.get("transcript")
# A string that is not a place is not a location anywhere downstream — not
# in the fit tests, not in the thin-call test, not in the LLM prompt, and
# not on the incident. Its coordinates go with it: coords are geocoded
@@ -826,6 +836,7 @@ async def _build_context(
return {
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
"call_doc": call_doc, "call_embedding": call_embedding,
"scene_transcript": scene_transcript,
"call_units": call_units, "call_vehicles": call_vehicles,
"call_cleared": call_cleared, "call_severity": call_severity,
"coords": coords, "is_thin_call": is_thin_call, "now": now,
+15 -1
View File
@@ -172,7 +172,7 @@ async def extract_scenes(
Each scene dict contains:
tags, incident_type, location, location_coords, resolved,
severity, vehicles, units, transcript_corrected,
severity, vehicles, units, transcript, transcript_corrected,
segment_indices, embedding
Side-effect: updates calls/{call_id} in Firestore with merged tags,
@@ -337,6 +337,19 @@ 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
processed.append({
"tags": tags,
"incident_type": incident_type,
@@ -348,6 +361,7 @@ async def extract_scenes(
"severity": severity,
"resolved": resolved,
"reassignment": reassignment,
"transcript": scene_transcript,
"transcript_corrected": transcript_corrected,
"segment_indices": segment_indices,
"embedding": embedding,
+7 -1
View File
@@ -61,7 +61,13 @@ def _inc_summary(inc: dict, now: datetime) -> str:
def _call_block(ctx: dict) -> str:
lines = []
call_doc = ctx["call_doc"]
transcript = call_doc.get("transcript_corrected") or call_doc.get("transcript")
# The SCENE's own transcript, resolved in _build_context (server-26#102).
# Falls back to the call doc for a ctx built without a scene (tests, sweep).
transcript = (
ctx.get("scene_transcript")
or call_doc.get("transcript_corrected")
or call_doc.get("transcript")
)
if transcript:
lines.append(f"Transcript: {transcript[:700]}")
if ctx["tags"]:
@@ -108,6 +108,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
cleared_units = call.get("cleared_units") or [],
embedding = call.get("embedding"),
severity = call.get("severity"),
transcript = call.get("transcript_corrected") or call.get("transcript"),
reference_time = started_at, # anchor window to when the call happened
create_if_new = False, # never create — link-only
)