config/ai_features was not the switch it was documented to be. Three paths spent money with it off, and one path read it wrong, so per-system opt-outs did not opt anything out. - Correlation in the ingest pipeline tested the raw global flag instead of the per-system resolution. With a system opted out, extraction was skipped but the no-scenes fallback still correlated the call with empty tags, taking the thin/recency path and attaching it to whatever incident was most recent on that system. The opt-out did not disable correlation, it disabled good correlation and left the worst kind running. (#75) - Transcript correction ran on every transcribed call gated only by an env var, spending Gemini tokens and a Places lookup per proposed location. An "STT-only" window was never STT-only and its cost could not be attributed. Now behind transcript_correction_enabled. (#76) - _run_extraction_pipeline and the vocabulary learner, both reachable from PATCH /calls/{id}/transcript, checked no flags at all. (#76, #81) The flag resolver now lives in feature_flags.resolve_flags() rather than as a local helper in upload.py. Three copies of that logic is how #75 happened. PATCH /calls/{id}/transcript now refuses with 409 when correlation is off. That route wipes tags, severity, location, units, embedding and unlinks the call from every incident before queueing re-extraction. Gating extraction alone would have made it destructive-only in the standing flags-off configuration: the call left blank and orphaned forever, with the route still answering 200. The wipe and the rebuild are one transaction in intent, so it refuses before the first write. Also: the summarizer's stale-incident sweep is no longer behind summaries_enabled. It is pure Firestore with no model call in it, and gating it meant nothing auto-resolved while AI was off - so every incident stayed active forever and the candidate set every correlation reads kept growing. transcript_correction_enabled is documented as NOT a pure cost lever. The corrector is also the noise gate that sets not_speech; with it off, recogniser noise reaches extraction as a real transcript, comes back thin, and auto-attaches. Never open an evaluation window with correction off and correlation on. 14 tests added covering flag precedence, both pipeline paths, the 409, the correction gate and the summarizer no-op. Suite: 264 passed. Refs #75, #76, #81, #45.
186 lines
7.0 KiB
Python
186 lines
7.0 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
|
|
from app.internal.feature_flags import get_flags
|
|
inc = await fstore.doc_get("incidents", incident_id)
|
|
if not inc:
|
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
|
flags = await get_flags()
|
|
if not flags["summaries_enabled"]:
|
|
return {"ok": False, "incident_id": incident_id, "summaries_enabled": False}
|
|
# 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, "summaries_enabled": True}
|
|
|
|
|
|
@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}
|