add debug in admin

This commit is contained in:
Logan
2026-05-17 18:42:42 -04:00
parent 4006232c85
commit bcc3d3406d
3 changed files with 250 additions and 30 deletions
+100 -1
View File
@@ -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.auth import require_admin_token, require_firebase_token
from app.internal.feature_flags import get_flags, set_flags from app.internal.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore
router = APIRouter(prefix="/admin", tags=["admin"]) 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)): async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
"""Update one or more AI feature flags. Admin only.""" """Update one or more AI feature flags. Admin only."""
return await set_flags(body) 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],
}
+124 -5
View File
@@ -2,7 +2,7 @@
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
interface FeatureFlags { interface FeatureFlags {
@@ -61,9 +61,113 @@ function Toggle({
); );
} }
function CorrelationDebugTab() {
const [limit, setLimit] = useState(20);
const [orphanHours, setOrphanHours] = useState(48);
const [data, setData] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const preRef = useRef<HTMLPreElement>(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 (
<div className="space-y-4">
<p className="text-xs text-gray-500 font-mono">
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
Copy the JSON and paste it to Claude to diagnose correlation problems.
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label>
<input
type="number"
min={1} max={100}
value={limit}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label>
<input
type="number"
min={1} max={168}
value={orphanHours}
onChange={(e) => 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"
/>
</div>
<button
onClick={handleFetch}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Fetching…" : "Fetch"}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{meta && (
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
</p>
<button
onClick={handleCopy}
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors"
>
{copied ? "Copied!" : "Copy JSON"}
</button>
</div>
)}
{json && (
<pre
ref={preRef}
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre"
>
{json}
</pre>
)}
</div>
);
}
export default function AdminPage() { export default function AdminPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<"features" | "correlation">("features");
const [flags, setFlags] = useState<FeatureFlags | null>(null); const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -98,18 +202,30 @@ export default function AdminPage() {
if (!isAdmin) return null; if (!isAdmin) return null;
return ( return (
<div className="max-w-2xl space-y-8"> <div className="max-w-2xl space-y-6">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<section className="space-y-3"> <div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider">AI Features</h2> {(["features", "correlation"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : "Correlation Debug"}
</button>
))}
</div>
{tab === "features" && (
<section className="space-y-3">
{error && ( {error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3"> <div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p> <p className="text-red-400 text-sm font-mono">{error}</p>
</div> </div>
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p> <p className="text-gray-500 text-sm font-mono">Loading</p>
) : ( ) : (
@@ -130,6 +246,9 @@ export default function AdminPage() {
</div> </div>
)} )}
</section> </section>
)}
{tab === "correlation" && <CorrelationDebugTab />}
</div> </div>
); );
} }
+2
View File
@@ -121,6 +121,8 @@ export const c2api = {
request<Record<string, boolean>>("/admin/features"), request<Record<string, boolean>>("/admin/features"),
setFeatureFlags: (flags: Record<string, boolean>) => setFeatureFlags: (flags: Record<string, boolean>) =>
request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }), request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }),
getCorrelationDebug: (limit: number, orphanHours: number) =>
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
// Per-system AI flag overrides // Per-system AI flag overrides
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) => setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>