Implement Admin UI to disable AI components

This commit is contained in:
Logan
2026-04-27 00:37:51 -04:00
parent 92c8351864
commit c959437059
9 changed files with 289 additions and 47 deletions
+62
View File
@@ -0,0 +1,62 @@
"""
Global AI feature flags stored in Firestore at config/ai_features.
Defaults to all-on when the document does not exist yet. Uses a short
in-memory TTL cache so flag reads don't add a Firestore round-trip to every
call upload.
"""
import time
from typing import Any
from app.internal.logger import logger
from app.internal import firestore as fstore
_COLLECTION = "config"
_DOC_ID = "ai_features"
_TTL = 30.0 # seconds before re-reading from Firestore
_DEFAULTS: dict[str, bool] = {
"stt_enabled": True,
"correlation_enabled": True,
"summaries_enabled": True,
"vocabulary_learning_enabled": True,
}
_cache: dict[str, Any] = {}
_cache_ts: float = 0.0
async def get_flags() -> dict[str, bool]:
"""Return the current feature flags, using the TTL cache when fresh."""
global _cache, _cache_ts
now = time.monotonic()
if _cache and (now - _cache_ts) < _TTL:
return dict(_cache)
try:
doc = await fstore.doc_get(_COLLECTION, _DOC_ID)
if doc:
merged = {**_DEFAULTS, **{k: bool(v) for k, v in doc.items() if k in _DEFAULTS}}
else:
merged = dict(_DEFAULTS)
except Exception as e:
logger.warning(f"Feature flags: could not read from Firestore ({e}), using defaults")
merged = dict(_DEFAULTS)
_cache = merged
_cache_ts = now
return dict(_cache)
async def set_flags(updates: dict[str, bool]) -> dict[str, bool]:
"""Write flag updates to Firestore and invalidate the cache."""
global _cache, _cache_ts
clean = {k: bool(v) for k, v in updates.items() if k in _DEFAULTS}
if not clean:
raise ValueError(f"No recognised flag keys in update: {list(updates)}")
await fstore.doc_set(_COLLECTION, _DOC_ID, clean)
_cache_ts = 0.0 # force re-read on next get_flags()
logger.info(f"Feature flags updated: {clean}")
return await get_flags()
+5
View File
@@ -16,13 +16,18 @@ from app.config import settings
async def summarizer_loop() -> None: async def summarizer_loop() -> None:
from app.internal.feature_flags import get_flags
interval = settings.summary_interval_minutes * 60 interval = settings.summary_interval_minutes * 60
logger.info(f"Summarizer started — interval: {settings.summary_interval_minutes}m") logger.info(f"Summarizer started — interval: {settings.summary_interval_minutes}m")
while True: while True:
await asyncio.sleep(interval) await asyncio.sleep(interval)
try: try:
flags = await get_flags()
if flags["summaries_enabled"]:
await _run_summary_pass() await _run_summary_pass()
await _resolve_stale_incidents() await _resolve_stale_incidents()
else:
logger.info("Summaries disabled — skipping summary pass and stale incident sweep")
except Exception as e: except Exception as e:
logger.error(f"Summarizer pass failed: {e}") logger.error(f"Summarizer pass failed: {e}")
@@ -243,6 +243,7 @@ def build_gpt_vocab_block(vocabulary: list[str]) -> str:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
async def vocabulary_induction_loop() -> None: async def vocabulary_induction_loop() -> None:
from app.internal.feature_flags import get_flags
interval = settings.vocabulary_induction_interval_hours * 3600 interval = settings.vocabulary_induction_interval_hours * 3600
logger.info( logger.info(
f"Vocabulary induction loop started — " f"Vocabulary induction loop started — "
@@ -252,7 +253,11 @@ async def vocabulary_induction_loop() -> None:
while True: while True:
await asyncio.sleep(interval) await asyncio.sleep(interval)
try: try:
flags = await get_flags()
if flags["vocabulary_learning_enabled"]:
await _run_induction_pass() await _run_induction_pass()
else:
logger.info("Vocabulary learning disabled — skipping induction pass")
except Exception as e: except Exception as e:
logger.error(f"Vocabulary induction pass failed: {e}") logger.error(f"Vocabulary induction pass failed: {e}")
+2 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -69,6 +69,7 @@ app.include_router(tokens.router, dependencies=[Depends(require_service_or_fi
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
@app.get("/health") @app.get("/health")
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from app.internal.auth import require_admin_token, require_firebase_token
from app.internal.feature_flags import get_flags, set_flags
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/features")
async def get_feature_flags(_=Depends(require_firebase_token)):
"""Return the current AI feature flag state. Any authenticated user can read."""
return await get_flags()
@router.put("/features")
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)
+10
View File
@@ -157,29 +157,39 @@ async def _run_intelligence_pipeline(
4. Check alert rules and dispatch notifications 4. Check alert rules and dispatch notifications
""" """
from app.internal import transcription, intelligence, incident_correlator, alerter from app.internal import transcription, intelligence, incident_correlator, alerter
from app.internal.feature_flags import get_flags
flags = await get_flags()
transcript: Optional[str] = None transcript: Optional[str] = None
segments: list[dict] = [] segments: list[dict] = []
# Step 1: Transcription # Step 1: Transcription
if gcs_uri: if gcs_uri:
if flags["stt_enabled"]:
transcript, segments = await transcription.transcribe_call( transcript, segments = await transcription.transcribe_call(
call_id, gcs_uri, talkgroup_name, system_id=system_id call_id, gcs_uri, talkgroup_name, system_id=system_id
) )
else:
logger.info(f"STT disabled — skipping transcription for call {call_id}")
# Step 2: Scene detection + intelligence extraction # Step 2: Scene detection + intelligence extraction
scenes: list[dict] = [] scenes: list[dict] = []
if flags["correlation_enabled"]:
if transcript: if transcript:
scenes = await intelligence.extract_scenes( scenes = await intelligence.extract_scenes(
call_id, transcript, talkgroup_name, call_id, transcript, talkgroup_name,
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
node_id=node_id, node_id=node_id,
) )
else:
logger.info(f"Correlation disabled — skipping scene extraction and correlation for call {call_id}")
# Step 3: Correlate each scene independently. # Step 3: Correlate each scene independently.
# A single recording can produce multiple incidents on a busy channel. # A single recording can produce multiple incidents on a busy channel.
incident_ids: list[str] = [] incident_ids: list[str] = []
all_tags: list[str] = [] all_tags: list[str] = []
if flags["correlation_enabled"]:
for scene in scenes: for scene in scenes:
all_tags.extend(scene["tags"]) all_tags.extend(scene["tags"])
incident_id = await incident_correlator.correlate_call( incident_id = await incident_correlator.correlate_call(
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface FeatureFlags {
stt_enabled: boolean;
correlation_enabled: boolean;
summaries_enabled: boolean;
vocabulary_learning_enabled: boolean;
}
const FLAG_META: { key: keyof FeatureFlags; label: string; description: string }[] = [
{
key: "stt_enabled",
label: "Speech-to-Text (Whisper)",
description: "Transcribe call audio via OpenAI Whisper. When off, calls are recorded and stored but no transcript is generated.",
},
{
key: "correlation_enabled",
label: "Incident Correlation",
description: "Run scene extraction and incident correlation on each call. When off, calls are logged but not linked to incidents.",
},
{
key: "summaries_enabled",
label: "Incident Summaries",
description: "Generate AI summaries for active incidents on each summarizer pass. Auto-resolve sweep is also paused when off.",
},
{
key: "vocabulary_learning_enabled",
label: "Vocabulary Learning",
description: "Run the background vocabulary induction loop that proposes new STT terms from recent transcripts.",
},
];
function Toggle({
enabled,
onChange,
disabled,
}: {
enabled: boolean;
onChange: (val: boolean) => void;
disabled: boolean;
}) {
return (
<button
onClick={() => onChange(!enabled)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 ${
enabled ? "bg-indigo-600" : "bg-gray-700"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}
export default function AdminPage() {
const { isAdmin } = useAuth();
const router = useRouter();
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isAdmin) {
router.replace("/dashboard");
return;
}
c2api.getFeatureFlags()
.then((f) => setFlags(f as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [isAdmin, router]);
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
if (!flags) return;
setSaving(key);
setError(null);
try {
const updated = await c2api.setFeatureFlags({ [key]: value });
setFlags(updated as FeatureFlags);
} catch (e) {
setError(String(e));
} finally {
setSaving(null);
}
}
if (!isAdmin) return null;
return (
<div className="p-6 max-w-2xl mx-auto space-y-8">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<section className="space-y-3">
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider">AI Features</h2>
{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>
)}
{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 key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<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>
)}
</section>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ const links = [
const adminLinks = [ const adminLinks = [
{ href: "/tokens", label: "Tokens" }, { href: "/tokens", label: "Tokens" },
{ href: "/admin", label: "Admin" },
]; ];
export function Nav() { export function Nav() {
+6
View File
@@ -115,4 +115,10 @@ export const c2api = {
request(`/systems/${systemId}/vocabulary/pending/approve`, { method: "POST", body: JSON.stringify({ term }) }), request(`/systems/${systemId}/vocabulary/pending/approve`, { method: "POST", body: JSON.stringify({ term }) }),
dismissPendingTerm: (systemId: string, term: string) => dismissPendingTerm: (systemId: string, term: string) =>
request(`/systems/${systemId}/vocabulary/pending/dismiss`, { method: "POST", body: JSON.stringify({ term }) }), request(`/systems/${systemId}/vocabulary/pending/dismiss`, { method: "POST", body: JSON.stringify({ term }) }),
// Feature flags (admin)
getFeatureFlags: () =>
request<Record<string, boolean>>("/admin/features"),
setFeatureFlags: (flags: Record<string, boolean>) =>
request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }),
}; };