Give the archive a real read, and the debug view a verdict
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Build & Deploy / Report a failed deploy (push) Skipped

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:
Logan Cusano
2026-08-23 12:33:52 -04:00
co-authored by Claude Opus 5
parent 7ef5704be2
commit 140dfbfc74
3 changed files with 234 additions and 8 deletions
+90
View File
@@ -40,6 +40,96 @@ async def list_calls(
return [with_playback_url(c) for c in calls]
@router.get("/search")
async def search_calls(
limit: int = Query(50, ge=1, le=200),
cursor: Optional[str] = Query(None, description="started_at of the last row of the previous page"),
system_id: Optional[str] = Query(None),
node_id: Optional[str] = Query(None),
talkgroup_id: Optional[int] = Query(None),
link: str = Query("any", pattern="^(any|orphan|linked)$"),
transcript: str = Query("any", pattern="^(any|yes|no)$"),
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
decoded: dict = Depends(require_admin_token),
):
"""
Paged, filterable call archive — the backend for the /calls page.
`GET /calls` returns every call in one unordered shot, which is fine for a
node's handful of active calls and useless as an archive: no order, no
paging, no way to find the orphans. This route is the archive read.
Only the org scope and the started_at ordering go to Firestore, because
that pair is the one composite index that exists (infra/firestore/
firestore.indexes.json). Every other filter runs in Python over a bounded
window, the same shape admin.py's correlation debug route uses — adding a
composite index per filter combination would be a worse trade than reading
10x the page and discarding most of it.
`window_exhausted` says the scan hit its cap before filling the page, so an
empty result means "not in this window", not "none exist".
"""
org_id = await resolve_caller_org_id(decoded)
if org_id is None:
# resolve_caller_org_id lets platform admins see every org (server-26#4).
# A new browse surface shouldn't widen that, so fall back to the
# caller's own org claim when they have one.
org_id = decoded.get("org_id")
if not org_id:
raise HTTPException(403, "No organization scope for this caller.")
window = max(limit * 10, 200)
rows = await fstore.collection_where(
"calls",
[("org_id", "==", org_id)],
order_by=[("started_at", "DESCENDING")],
limit_to=window,
start_after={"started_at": cursor} if cursor else None,
)
needle = (q or "").strip().lower()
def _keep(c: dict) -> bool:
if system_id and c.get("system_id") != system_id:
return False
if node_id and c.get("node_id") != node_id:
return False
if talkgroup_id is not None and c.get("talkgroup_id") != talkgroup_id:
return False
linked = bool(c.get("incident_ids") or c.get("incident_id"))
if link == "orphan" and linked:
return False
if link == "linked" and not linked:
return False
text = c.get("transcript_corrected") or c.get("transcript") or ""
if transcript == "yes" and not text:
return False
if transcript == "no" and text:
return False
if needle and needle not in text.lower():
return False
return True
matches = [c for c in rows if _keep(c)]
page = matches[:limit]
# Cursor advances over the SCANNED window, not the filtered page — otherwise
# a page whose last match sits early in the window would re-scan everything
# after it on the next request and loop forever on a sparse filter.
next_cursor = None
if len(rows) == window:
last_scanned = rows[-1].get("started_at")
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
return {
"calls": [with_playback_url(c) for c in page],
"next_cursor": next_cursor,
"scanned": len(rows),
"matched": len(matches),
"window_exhausted": len(rows) == window,
}
@router.get("/{call_id}")
async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
call = await fstore.doc_get("calls", call_id)