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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user