Give the archive a real read, and the debug view a verdict
Three backend pieces the /calls page needs, plus the fix for a debug view that
hid its data exactly when it was wanted.
GET /calls/search — paged, filterable call archive. GET /calls returns every
call in one unordered shot: fine for a node's handful of active calls, useless
as an archive. Only the org scope and the started_at ordering go to Firestore,
since that pair is the one composite index that exists; the rest filters in
Python over a bounded window, the same shape admin.py's debug route uses. The
cursor advances over the scanned window rather than the returned page, or a
sparse filter would re-scan from the same place forever.
Manual attribution. POST /incidents/{id}/calls/{id} only ever wrote the legacy
scalar incident_id, never incident_ids -- which is what the correlator writes
and what the frontend queries with array-contains. A manually attached call was
therefore invisible on the incident page it had just been attached to. It now
maintains both and marks the summary stale. DELETE is new: there was no way to
undo an attachment at all, so a wrong link was permanent.
The debug view no longer filters to AI-enabled systems by default. That filter
emptied the view the moment the flags went off, which is precisely when a
window gets reviewed -- on 2026-08-23 it fell from 100 incidents to 6 between
switching correlation off and opening the tab. ai_systems_only=true restores it.
It also returns a summary block now: corr_path / fit_signal / consensus /
llm_action tallies, transcript coverage on both linked and orphaned calls,
single-call and median-calls-per-incident for fragmentation, max span and
anything past the server-26#22 caps for merging, and the count of incidents
still carrying a fallback "— TGID" title. All of it was being recomputed by
hand from the raw payload on every review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7ef5704be2
commit
140dfbfc74
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.feature_flags import get_flags, set_flags
|
||||
from app.internal import firestore as fstore
|
||||
from app.config import settings
|
||||
|
||||
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
||||
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
||||
@@ -44,6 +45,7 @@ async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
|
||||
async def debug_correlation(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
orphan_hours: int = Query(48, ge=1, le=168),
|
||||
ai_systems_only: bool = Query(False, description="Restrict to systems with STT or correlation currently enabled"),
|
||||
_=Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
@@ -102,9 +104,19 @@ async def debug_correlation(
|
||||
}
|
||||
|
||||
# ── Determine which systems have AI active ────────────────────────────────
|
||||
# NOT a filter by default. Restricting to AI-enabled systems meant the view
|
||||
# emptied itself the moment the flags went off — which is precisely when a
|
||||
# window gets reviewed. On 2026-08-23 it dropped from 100 incidents to 6
|
||||
# between switching correlation off and opening the tab. Pass
|
||||
# ai_systems_only=true to get the old behaviour.
|
||||
global_flags = await get_flags()
|
||||
ai_systems = await _get_ai_enabled_system_ids(global_flags)
|
||||
|
||||
def _in_scope(system_ids: list) -> bool:
|
||||
if not ai_systems_only:
|
||||
return True
|
||||
return any(sid in ai_systems for sid in system_ids)
|
||||
|
||||
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
|
||||
# Read a bounded, already-sorted window rather than the whole collection.
|
||||
# This route used to pull every incident ever created and sort in Python,
|
||||
@@ -123,10 +135,7 @@ async def debug_correlation(
|
||||
order_by=[("updated_at", "DESCENDING")],
|
||||
limit_to=window,
|
||||
)
|
||||
ai_incidents = [
|
||||
i for i in all_incidents
|
||||
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
|
||||
]
|
||||
ai_incidents = [i for i in all_incidents if _in_scope(i.get("system_ids") or [])]
|
||||
incidents = ai_incidents[:limit]
|
||||
incidents_window_exhausted = len(all_incidents) >= window and len(ai_incidents) < limit
|
||||
|
||||
@@ -177,7 +186,7 @@ async def debug_correlation(
|
||||
if c.get("status") == "ended"
|
||||
and not c.get("incident_ids") and not c.get("incident_id")
|
||||
and not c.get("duplicate_of") # another node's copy — never meant to correlate
|
||||
and c.get("system_id") in ai_systems
|
||||
and _in_scope([c.get("system_id")])
|
||||
]
|
||||
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
||||
|
||||
@@ -199,8 +208,80 @@ async def debug_correlation(
|
||||
if (o.get("corr_sweep_count") or 0) >= 3:
|
||||
orphans_by_tg[tg_key]["sweep_exhausted_count"] += 1
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
# Everything below was being recomputed by hand from the raw payload on
|
||||
# every review — path counts, how much of the run the LLM tier actually saw,
|
||||
# how many incidents ended up with the "Ems — TGID 9048" fallback name, and
|
||||
# whether anything blew past the server-26#22 caps. Compute it once, here,
|
||||
# where the data already is.
|
||||
def _tally(values) -> dict:
|
||||
out: dict[str, int] = {}
|
||||
for v in values:
|
||||
k = str(v) if v is not None else "none"
|
||||
out[k] = out.get(k, 0) + 1
|
||||
return dict(sorted(out.items(), key=lambda kv: kv[1], reverse=True))
|
||||
|
||||
linked = [c for inc in incident_records for c in (inc.get("calls_detail") or [])]
|
||||
call_counts = [len(inc.get("call_ids") or []) for inc in incident_records]
|
||||
|
||||
def _span_minutes(inc: dict) -> float:
|
||||
stamps = sorted(
|
||||
s for s in ((c.get("started_at") or "") for c in (inc.get("calls_detail") or [])) if s
|
||||
)
|
||||
if len(stamps) < 2:
|
||||
return 0.0
|
||||
try:
|
||||
first = datetime.fromisoformat(str(stamps[0]).replace("Z", "+00:00"))
|
||||
last = datetime.fromisoformat(str(stamps[-1]).replace("Z", "+00:00"))
|
||||
return round((last - first).total_seconds() / 60, 1)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
spans = [_span_minutes(inc) for inc in incident_records]
|
||||
with_transcript = sum(1 for c in linked if (c.get("transcript") or "").strip())
|
||||
fallback_titles = sum(
|
||||
1 for inc in incident_records
|
||||
if " — TGID " in (inc.get("title") or "") or (inc.get("title") or "").endswith("Unknown Talkgroup")
|
||||
)
|
||||
over_cap = [
|
||||
{"incident_id": inc.get("incident_id"), "title": inc.get("title"),
|
||||
"calls": len(inc.get("call_ids") or []), "span_minutes": _span_minutes(inc)}
|
||||
for inc in incident_records
|
||||
if len(inc.get("call_ids") or []) > settings.incident_max_calls
|
||||
or _span_minutes(inc) > settings.incident_max_duration_minutes
|
||||
]
|
||||
|
||||
summary = {
|
||||
"ai_systems_only": ai_systems_only,
|
||||
"ai_enabled_system_ids": sorted(ai_systems),
|
||||
"linked_call_count": len(linked),
|
||||
"corr_path": _tally(c.get("corr_path") for c in linked),
|
||||
"corr_fit_signal": _tally(c.get("corr_fit_signal") for c in linked),
|
||||
"corr_consensus": _tally(c.get("corr_consensus") for c in linked),
|
||||
"corr_llm_action": _tally(c.get("corr_llm_action") for c in linked),
|
||||
# STT coverage: correlation quality is capped by this, so it belongs in
|
||||
# the same view rather than a separate investigation.
|
||||
"linked_calls_with_transcript": with_transcript,
|
||||
"linked_calls_without_transcript": len(linked) - with_transcript,
|
||||
"orphans_with_transcript": sum(1 for o in orphans if (o.get("transcript") or "").strip()),
|
||||
# Fragmentation vs merging, the two failure directions.
|
||||
"single_call_incidents": sum(1 for n in call_counts if n == 1),
|
||||
"median_calls_per_incident": sorted(call_counts)[len(call_counts) // 2] if call_counts else 0,
|
||||
"max_calls_in_one_incident": max(call_counts) if call_counts else 0,
|
||||
"max_span_minutes": max(spans) if spans else 0.0,
|
||||
"incidents_over_cap": over_cap,
|
||||
"caps": {
|
||||
"incident_max_calls": settings.incident_max_calls,
|
||||
"incident_max_duration_minutes": settings.incident_max_duration_minutes,
|
||||
},
|
||||
# Titling health — server-26#34.
|
||||
"fallback_titled_incidents": fallback_titles,
|
||||
"titled_incidents": len(incident_records) - fallback_titles,
|
||||
}
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"summary": summary,
|
||||
# Both reads are capped, so say plainly when a cap was hit — otherwise a
|
||||
# truncated window is indistinguishable from a quiet night.
|
||||
"incidents_window_exhausted": incidents_window_exhausted,
|
||||
|
||||
Reference in New Issue
Block a user