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:
co-authored by
Claude Sonnet 5
parent
b430cf32f2
commit
ef1e3d7f9d
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user