Files
server-26/drb-c2-core/app/routers/incidents.py
T
Logan CusanoandClaude Opus 5 140dfbfc74
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
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>
2026-08-23 12:33:52 -04:00

182 lines
6.8 KiB
Python

import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore
from app.internal.auth import (
require_admin_token,
require_service_or_firebase_token,
resolve_caller_org_id,
summarize_limiter,
)
router = APIRouter(prefix="/incidents", tags=["incidents"])
@router.get("")
async def list_incidents(
status: Optional[str] = None,
type: Optional[str] = None,
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {}
if status:
filters["status"] = status
if type:
filters["type"] = type
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
filters["org_id"] = org_id
return await fstore.collection_list("incidents", **filters)
@router.post("/summarize")
async def summarize_all_stale(
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
from app.internal.summarizer import _run_summary_pass
background_tasks.add_task(_run_summary_pass)
return {"ok": True}
@router.get("/{incident_id}")
async def get_incident(incident_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and doc.get("org_id") != org_id:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
return doc
@router.post("")
async def create_incident(body: IncidentCreate, _: dict = Depends(require_admin_token)):
now = datetime.now(timezone.utc).isoformat()
incident_id = str(uuid.uuid4())
doc = {
"incident_id": incident_id,
"title": body.title,
"type": body.type,
"status": body.status,
"location": body.location,
"call_ids": body.call_ids,
"summary": body.summary,
"tags": body.tags,
"started_at": now,
"updated_at": now,
}
await fstore.doc_set("incidents", incident_id, doc, merge=False)
return doc
@router.put("/{incident_id}")
async def update_incident(incident_id: str, body: IncidentUpdate, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
updates = body.model_dump(exclude_none=True)
updates["updated_at"] = datetime.now(timezone.utc).isoformat()
await fstore.doc_update("incidents", incident_id, updates)
return {**doc, **updates}
@router.delete("/{incident_id}")
async def delete_incident(incident_id: str, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
await fstore.doc_delete("incidents", incident_id)
return {"ok": True}
@router.post("/{incident_id}/summarize")
async def summarize_incident(
incident_id: str,
background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token),
):
"""Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident
inc = await fstore.doc_get("incidents", incident_id)
if not inc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
# Rate limit by incident ID to prevent repeated expensive LLM calls
summarize_limiter.check(incident_id)
background_tasks.add_task(_summarize_incident, inc)
return {"ok": True, "incident_id": incident_id}
@router.post("/{incident_id}/calls/{call_id}")
async def link_call_to_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
"""Manually attach a call to an incident (the /calls page's attribution action)."""
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
call_ids = list(doc.get("call_ids") or [])
if call_id not in call_ids:
call_ids.append(call_id)
await fstore.doc_update("incidents", incident_id, {
"call_ids": call_ids,
"updated_at": datetime.now(timezone.utc).isoformat(),
# A manually attached call changes what the incident is about.
"summary_stale": True,
})
# incident_ids is the canonical link — it is what the correlator writes and
# what the frontend queries with array-contains. This route only ever set
# the legacy scalar incident_id, so a manually attached call stayed
# invisible on the incident's own page.
incident_ids = list(call.get("incident_ids") or ([call["incident_id"]] if call.get("incident_id") else []))
if incident_id not in incident_ids:
incident_ids.append(incident_id)
await fstore.doc_update("calls", call_id, {
"incident_ids": incident_ids,
"incident_id": incident_id,
"corr_path": "manual",
})
return {"ok": True, "incident_ids": incident_ids}
@router.delete("/{incident_id}/calls/{call_id}")
async def unlink_call_from_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
"""
Detach a call from an incident — the other half of manual attribution.
An incident left with no calls is resolved rather than deleted, matching
what calls.py's transcript correction does when it empties one.
"""
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
remaining = [c for c in (doc.get("call_ids") or []) if c != call_id]
updates: dict = {
"call_ids": remaining,
"updated_at": datetime.now(timezone.utc).isoformat(),
"summary_stale": True,
}
if not remaining:
updates["status"] = "resolved"
updates["resolved_at"] = datetime.now(timezone.utc).isoformat()
await fstore.doc_update("incidents", incident_id, updates)
call = await fstore.doc_get("calls", call_id)
if call:
incident_ids = [
i for i in (call.get("incident_ids") or ([call["incident_id"]] if call.get("incident_id") else []))
if i != incident_id
]
await fstore.doc_update("calls", call_id, {
"incident_ids": incident_ids,
"incident_id": incident_ids[0] if incident_ids else None,
})
return {"ok": True, "incident_emptied": not remaining}