Add consensus correlator: rules + Gemini LLM with smart tiebreaker
Refactor incident_correlator.py to a decision/commit split (preview_correlation / apply_correlation) so the rules engine and LLM can both produce decisions before anything is written to Firestore. Add llm_correlator.py: cheap Gemini Flash first-pass + Gemini Pro tiebreaker. Wire _correlate_with_consensus in upload.py — rules-only fallback when key is absent or call is thin; agreed/tiebreak consensus written to corr_debug.
This commit is contained in:
@@ -83,6 +83,65 @@ def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
async def _correlate_with_consensus(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
system_id: Optional[str],
|
||||
talkgroup_id: Optional[int],
|
||||
talkgroup_name: Optional[str],
|
||||
tags: list[str],
|
||||
incident_type: Optional[str],
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
units: Optional[list] = None,
|
||||
vehicles: Optional[list] = None,
|
||||
cleared_units: Optional[list] = None,
|
||||
reassignment: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
||||
If they agree the rules decision is committed directly.
|
||||
If they disagree a smarter tiebreaker LLM makes the final call.
|
||||
|
||||
Falls back to rules-only when GEMINI_API_KEY is absent, the call is
|
||||
content-free (thin), or any LLM call fails.
|
||||
"""
|
||||
from app.internal import incident_correlator, llm_correlator
|
||||
|
||||
preview = await incident_correlator.preview_correlation(
|
||||
call_id=call_id, node_id=node_id, system_id=system_id,
|
||||
talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||
cleared_units=cleared_units, reassignment=reassignment,
|
||||
)
|
||||
ctx = preview["ctx"]
|
||||
rules_decision = preview["decision"]
|
||||
|
||||
llm_decision = await llm_correlator.decide(call_id, ctx)
|
||||
|
||||
if llm_decision is None:
|
||||
# LLM unavailable, skipped (thin call), or errored — rules wins.
|
||||
rules_decision["corr_debug"]["corr_consensus"] = "rules_only"
|
||||
return await incident_correlator.apply_correlation(preview)
|
||||
|
||||
if llm_correlator.decisions_agree(rules_decision, llm_decision):
|
||||
rules_decision["corr_debug"]["corr_consensus"] = "agreed"
|
||||
rules_decision["corr_debug"]["corr_llm_reasoning"] = llm_decision.get("reasoning", "")
|
||||
return await incident_correlator.apply_correlation(preview)
|
||||
|
||||
# Disagree — escalate to the smarter tiebreaker.
|
||||
logger.info(
|
||||
f"Consensus disagreement for call {call_id}: "
|
||||
f"rules={rules_decision['action']} vs llm={llm_decision['action']} — tiebreak"
|
||||
)
|
||||
final = await llm_correlator.tiebreak(rules_decision, llm_decision, ctx)
|
||||
final["corr_debug"]["corr_consensus"] = "tiebreak"
|
||||
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
|
||||
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
|
||||
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
|
||||
|
||||
|
||||
async def _run_extraction_pipeline(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
@@ -114,7 +173,7 @@ async def _run_extraction_pipeline(
|
||||
# overlap so the new scene doesn't chain into the unit's previous incident.
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
corr_units = [] if is_reassignment else scene.get("units")
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
@@ -217,7 +276,7 @@ async def _run_intelligence_pipeline(
|
||||
all_tags.extend(scene["tags"])
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
corr_units = [] if is_reassignment else scene.get("units")
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
@@ -246,7 +305,7 @@ async def _run_intelligence_pipeline(
|
||||
if not scenes:
|
||||
_call_doc = await fstore.doc_get("calls", call_id)
|
||||
if not (_call_doc or {}).get("skip_reason"):
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
|
||||
Reference in New Issue
Block a user