Implement Admin UI to disable AI components
This commit is contained in:
@@ -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()
|
||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user