From bcc3d3406dcbc68c9a89a61c08b84e7e3988ef79 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 17 May 2026 18:42:42 -0400 Subject: [PATCH] add debug in admin --- drb-c2-core/app/routers/admin.py | 101 +++++++++++++++++- drb-frontend/app/admin/page.tsx | 177 ++++++++++++++++++++++++++----- drb-frontend/lib/c2api.ts | 2 + 3 files changed, 250 insertions(+), 30 deletions(-) diff --git a/drb-c2-core/app/routers/admin.py b/drb-c2-core/app/routers/admin.py index 6cd2651..abcb9d1 100644 --- a/drb-c2-core/app/routers/admin.py +++ b/drb-c2-core/app/routers/admin.py @@ -1,6 +1,9 @@ -from fastapi import APIRouter, Depends +import asyncio +from datetime import datetime, timezone, timedelta +from fastapi import APIRouter, Depends, Query from app.internal.auth import require_admin_token, require_firebase_token from app.internal.feature_flags import get_flags, set_flags +from app.internal import firestore as fstore router = APIRouter(prefix="/admin", tags=["admin"]) @@ -15,3 +18,99 @@ async def get_feature_flags(_=Depends(require_firebase_token)): async def update_feature_flags(body: dict, _=Depends(require_admin_token)): """Update one or more AI feature flags. Admin only.""" return await set_flags(body) + + +@router.get("/debug/correlation") +async def debug_correlation( + limit: int = Query(20, ge=1, le=100), + orphan_hours: int = Query(48, ge=1, le=168), + _=Depends(require_admin_token), +): + """ + Return the last N incidents with full correlation debug detail, plus recent orphaned calls. + + Each incident includes a calls_detail array with per-call corr_* fields so you can see + exactly which correlation path fired (or didn't) for every call in the incident. + + Embeddings are stripped — they're large float arrays and unreadable. + + Query params: + limit — number of incidents to return, sorted by updated_at desc (default 20, max 100) + orphan_hours — how far back to scan for orphaned calls (default 48h, max 168h / 1 week) + """ + def _strip(doc: dict) -> dict: + return {k: v for k, v in doc.items() if k != "embedding"} + + def _call_summary(call: dict) -> dict: + return { + "call_id": call.get("call_id"), + "started_at": call.get("started_at"), + "ended_at": call.get("ended_at"), + "duration_s": call.get("duration_s"), + "talkgroup_id": call.get("talkgroup_id"), + "talkgroup_name": call.get("talkgroup_name"), + "system_id": call.get("system_id"), + "node_id": call.get("node_id"), + "incident_type": call.get("incident_type"), + "tags": call.get("tags"), + "location": call.get("location"), + "location_coords": call.get("location_coords"), + "units": call.get("units"), + "vehicles": call.get("vehicles"), + "cleared_units": call.get("cleared_units"), + "severity": call.get("severity"), + "transcript": call.get("transcript_corrected") or call.get("transcript"), + # Correlation decision fields written back by incident_correlator + "corr_path": call.get("corr_path"), + "corr_incident_idle_min": call.get("corr_incident_idle_min"), + "corr_distance_km": call.get("corr_distance_km"), + "corr_score": call.get("corr_score"), + "corr_candidates": call.get("corr_candidates"), + "corr_shared_units": call.get("corr_shared_units"), + "corr_sweep_count": call.get("corr_sweep_count"), + } + + # ── Fetch recent incidents ──────────────────────────────────────────────── + all_incidents = await fstore.collection_list("incidents") + all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True) + incidents = all_incidents[:limit] + + # ── Fetch all linked call docs in parallel ──────────────────────────────── + all_call_ids: list[str] = [] + for inc in incidents: + all_call_ids.extend(inc.get("call_ids") or []) + unique_call_ids = list(dict.fromkeys(all_call_ids)) # dedupe, preserve order + + call_docs = await asyncio.gather(*(fstore.doc_get("calls", cid) for cid in unique_call_ids)) + call_map: dict[str, dict] = {doc["call_id"]: doc for doc in call_docs if doc} + + # ── Build incident debug records ────────────────────────────────────────── + incident_records = [] + for inc in incidents: + rec = _strip(inc) + rec["calls_detail"] = [ + _call_summary(call_map[cid]) + for cid in (inc.get("call_ids") or []) + if cid in call_map + ] + incident_records.append(rec) + + # ── Recent orphaned calls ───────────────────────────────────────────────── + cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours) + recent_ended = await fstore.collection_where("calls", [ + ("status", "==", "ended"), + ("ended_at", ">=", cutoff), + ]) + orphans = [ + _call_summary(c) for c in recent_ended + if not c.get("incident_ids") and not c.get("incident_id") + ] + orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True) + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "incident_count": len(incident_records), + "orphaned_call_count": len(orphans), + "incidents": incident_records, + "orphaned_calls": orphans[:100], + } diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index dccddc6..bd85041 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -2,7 +2,7 @@ import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useRouter } from "next/navigation"; interface FeatureFlags { @@ -61,9 +61,113 @@ function Toggle({ ); } +function CorrelationDebugTab() { + const [limit, setLimit] = useState(20); + const [orphanHours, setOrphanHours] = useState(48); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const preRef = useRef(null); + + async function handleFetch() { + setLoading(true); + setError(null); + setData(null); + setCopied(false); + try { + const result = await c2api.getCorrelationDebug(limit, orphanHours); + setData(result); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + } + + async function handleCopy() { + if (!data) return; + await navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + const json = data ? JSON.stringify(data, null, 2) : null; + const meta = data as { incident_count?: number; orphaned_call_count?: number; generated_at?: string } | null; + + return ( +
+

+ Fetches recent incidents with per-call correlation debug fields, plus orphaned calls. + Copy the JSON and paste it to Claude to diagnose correlation problems. +

+ +
+
+ + setLimit(Math.min(100, Math.max(1, Number(e.target.value))))} + className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" + /> +
+
+ + setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))} + className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" + /> +
+ +
+ + {error && ( +
+

{error}

+
+ )} + + {meta && ( +
+

+ {meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at} +

+ +
+ )} + + {json && ( +
+          {json}
+        
+ )} +
+ ); +} + export default function AdminPage() { const { isAdmin } = useAuth(); const router = useRouter(); + const [tab, setTab] = useState<"features" | "correlation">("features"); const [flags, setFlags] = useState(null); const [loading, setLoading] = useState(true); @@ -98,38 +202,53 @@ export default function AdminPage() { if (!isAdmin) return null; return ( -
+

Admin

-
-

AI Features

+
+ {(["features", "correlation"] as const).map((t) => ( + + ))} +
- {error && ( -
-

{error}

-
- )} - - {loading ? ( -

Loading…

- ) : ( -
- {FLAG_META.map(({ key, label, description }) => ( -
-
-

{label}

-

{description}

+ {tab === "features" && ( +
+ {error && ( +
+

{error}

+
+ )} + {loading ? ( +

Loading…

+ ) : ( +
+ {FLAG_META.map(({ key, label, description }) => ( +
+
+

{label}

+

{description}

+
+ handleToggle(key, val)} + disabled={saving === key} + />
- handleToggle(key, val)} - disabled={saving === key} - /> -
- ))} -
- )} -
+ ))} +
+ )} + + )} + + {tab === "correlation" && }
); } diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 31c3128..6b12dd2 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -121,6 +121,8 @@ export const c2api = { request>("/admin/features"), setFeatureFlags: (flags: Record) => request>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }), + getCorrelationDebug: (limit: number, orphanHours: number) => + request(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`), // Per-system AI flag overrides setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>