""" Re-correlation sweep. Runs every summary_interval_minutes (same tick as the summarizer). Each pass finds calls that are: - recently ended (ended_at within the last recorrelation_scan_minutes) - still orphaned (incident_id is null) and re-runs the incident correlator against currently-active incidents, using the call's own started_at as the time anchor so the window is correct regardless of when the sweep fires. Never creates new incidents — link-only. Zero LLM tokens (uses pre-computed talkgroup strings, haversine math, and stored embeddings). """ import asyncio from datetime import datetime, timezone, timedelta from typing import Optional from app.internal.logger import logger from app.internal import firestore as fstore from app.config import settings async def recorrelation_loop() -> None: interval = settings.summary_interval_minutes * 60 logger.info( f"Re-correlation sweep started — " f"interval: {settings.summary_interval_minutes}m, " f"scan window: {settings.recorrelation_scan_minutes}m" ) while True: await asyncio.sleep(interval) try: await _run_sweep_pass() except Exception as e: logger.error(f"Re-correlation sweep failed: {e}") async def _run_sweep_pass() -> None: cutoff = datetime.now(timezone.utc) - timedelta(minutes=settings.recorrelation_scan_minutes) # Server-side range query: only calls that ended within the scan window. # Filter incident_id=null client-side (Firestore can't query for missing fields). # This keeps the fetched set small regardless of total collection size. recent_ended = await fstore.collection_where("calls", [ ("status", "==", "ended"), ("ended_at", ">=", cutoff), ]) orphans = [c for c in recent_ended if not c.get("incident_id")] if not orphans: return logger.info(f"Re-correlation sweep: {len(orphans)} orphaned call(s) to check") linked = 0 for call in orphans: if await _recorrelate_orphan(call): linked += 1 if linked: logger.info(f"Re-correlation sweep: linked {linked}/{len(orphans)} orphaned call(s)") async def _recorrelate_orphan(call: dict) -> bool: """ Attempt to link a single orphaned call to an existing incident. Returns True if a match was found and the call was linked. """ from app.internal import incident_correlator call_id = call.get("call_id") started_at = _parse_dt(call.get("started_at")) if not call_id or not started_at: return False # All data needed for correlation was stored by the first-pass extraction. incident_id = await incident_correlator.correlate_call( call_id = call_id, node_id = call.get("node_id", ""), system_id = call.get("system_id"), talkgroup_id = call.get("talkgroup_id"), talkgroup_name = call.get("talkgroup_name"), tags = call.get("tags") or [], incident_type = call.get("incident_type"), location = call.get("location"), location_coords= call.get("location_coords"), reference_time = started_at, # anchor window to when the call happened create_if_new = False, # never create — link-only ) if incident_id: logger.info( f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}" ) return True return False def _parse_dt(value) -> Optional[datetime]: if not value: return None try: dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except Exception: return None