add debug in admin
This commit is contained in:
@@ -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],
|
||||||
|
}
|
||||||
|
|||||||
+148
-29
@@ -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,38 +202,53 @@ 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>
|
||||||
|
|
||||||
{error && (
|
{tab === "features" && (
|
||||||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
<section className="space-y-3">
|
||||||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
{error && (
|
||||||
</div>
|
<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>
|
||||||
{loading ? (
|
)}
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
{loading ? (
|
||||||
) : (
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
) : (
|
||||||
{FLAG_META.map(({ key, label, description }) => (
|
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
{FLAG_META.map(({ key, label, description }) => (
|
||||||
<div className="min-w-0">
|
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<p className="text-white text-sm font-semibold">{label}</p>
|
<div className="min-w-0">
|
||||||
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
<p className="text-white text-sm font-semibold">{label}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={flags?.[key] ?? true}
|
||||||
|
onChange={(val) => handleToggle(key, val)}
|
||||||
|
disabled={saving === key}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
))}
|
||||||
enabled={flags?.[key] ?? true}
|
</div>
|
||||||
onChange={(val) => handleToggle(key, val)}
|
)}
|
||||||
disabled={saving === key}
|
</section>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
))}
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user