UI Updates

This commit is contained in:
Logan
2026-04-19 15:22:29 -04:00
parent 03212fca51
commit 0df53df92e
7 changed files with 181 additions and 13 deletions
+45 -1
View File
@@ -1,6 +1,12 @@
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
class TranscriptUpdate(BaseModel):
transcript: str
router = APIRouter(prefix="/calls", tags=["calls"]) router = APIRouter(prefix="/calls", tags=["calls"])
@@ -51,3 +57,41 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
gcs_uri=gcs_uri, gcs_uri=gcs_uri,
) )
return {"ok": True, "call_id": call_id} return {"ok": True, "call_id": call_id}
@router.patch("/{call_id}/transcript")
async def patch_transcript(
call_id: str,
body: TranscriptUpdate,
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Overwrite a call's transcript and re-run intelligence extraction."""
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
# Save new transcript, clear stale intelligence fields
await fstore.doc_set("calls", call_id, {
"transcript": body.transcript,
"transcript_corrected": None,
"tags": [],
"severity": "unknown",
"location": None,
"units": [],
"vehicles": [],
"embedding": None,
})
from app.routers.upload import _run_extraction_pipeline
background_tasks.add_task(
_run_extraction_pipeline,
call_id=call_id,
node_id=call.get("node_id"),
system_id=call.get("system_id"),
talkgroup_id=call.get("talkgroup_id"),
talkgroup_name=call.get("talkgroup_name"),
transcript=body.transcript,
segments=call.get("segments"),
)
return {"ok": True, "call_id": call_id}
+39
View File
@@ -83,6 +83,45 @@ def _public_url_to_gcs_uri(url: str) -> Optional[str]:
return None return None
async def _run_extraction_pipeline(
call_id: str,
node_id: str,
system_id: Optional[str],
talkgroup_id: Optional[int],
talkgroup_name: Optional[str],
transcript: str,
segments: Optional[list] = None,
) -> None:
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
from app.internal import intelligence, incident_correlator, alerter
tags, incident_type, location = await intelligence.extract_tags(
call_id, transcript, talkgroup_name,
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
)
if incident_type:
await incident_correlator.correlate_call(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=tags,
incident_type=incident_type,
location=location,
)
await alerter.check_and_dispatch(
call_id=call_id,
node_id=node_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=tags,
transcript=transcript,
)
async def _run_intelligence_pipeline( async def _run_intelligence_pipeline(
call_id: str, call_id: str,
node_id: str, node_id: str,
+4 -2
View File
@@ -4,11 +4,13 @@ import { useState } from "react";
import { useCalls } from "@/lib/useCalls"; import { useCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { useAuth } from "@/components/AuthProvider";
export default function CallsPage() { export default function CallsPage() {
const [limitCount, setLimitCount] = useState(100); const [limitCount, setLimitCount] = useState(100);
const { calls, loading } = useCalls(limitCount); const { calls, loading } = useCalls(limitCount);
const { systems } = useSystems(); const { systems } = useSystems();
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const active = calls.filter((c) => c.status === "active"); const active = calls.filter((c) => c.status === "active");
@@ -41,7 +43,7 @@ export default function CallsPage() {
</thead> </thead>
<tbody> <tbody>
{active.map((c) => ( {active.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} /> <CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))} ))}
</tbody> </tbody>
</table> </table>
@@ -73,7 +75,7 @@ export default function CallsPage() {
</thead> </thead>
<tbody> <tbody>
{ended.map((c) => ( {ended.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} /> <CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))} ))}
</tbody> </tbody>
</table> </table>
+3 -1
View File
@@ -8,6 +8,7 @@ import { CallRow } from "@/components/CallRow";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { useState } from "react"; import { useState } from "react";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
import { useAuth } from "@/components/AuthProvider";
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return ( return (
@@ -26,6 +27,7 @@ export default function DashboardPage() {
const { systems, error: systemsError } = useSystems(); const { systems, error: systemsError } = useSystems();
const [configNode, setConfigNode] = useState<NodeRecord | null>(null); const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const onlineCount = nodes.filter((n) => n.status !== "offline").length; const onlineCount = nodes.filter((n) => n.status !== "offline").length;
@@ -98,7 +100,7 @@ export default function DashboardPage() {
</thead> </thead>
<tbody> <tbody>
{calls.map((c) => ( {calls.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} /> <CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))} ))}
</tbody> </tbody>
</table> </table>
+81 -9
View File
@@ -2,10 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api";
interface Props { interface Props {
call: CallRecord; call: CallRecord;
systemName?: string; systemName?: string;
isAdmin?: boolean;
} }
function duration(started: string, ended: string | null): string { function duration(started: string, ended: string | null): string {
@@ -22,13 +24,35 @@ const TAG_COLORS: Record<string, string> = {
accident: "bg-orange-900 text-orange-300", accident: "bg-orange-900 text-orange-300",
}; };
export function CallRow({ call, systemName }: Props) { export function CallRow({ call, systemName, isAdmin }: Props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showOriginal, setShowOriginal] = useState(false); const [showOriginal, setShowOriginal] = useState(false);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState("");
const [saving, setSaving] = useState(false);
const isActive = call.status === "active"; const isActive = call.status === "active";
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || call.incident_id; const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || call.incident_id;
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript; const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
const hasBoth = !!(call.transcript && call.transcript_corrected); const hasBoth = !!(call.transcript && call.transcript_corrected);
const hasSegments = call.segments && call.segments.length > 1;
function startEdit() {
setEditText(call.transcript_corrected ?? call.transcript ?? "");
setEditing(true);
}
async function saveEdit(e: React.MouseEvent) {
e.stopPropagation();
setSaving(true);
try {
await c2api.patchTranscript(call.call_id, editText);
setEditing(false);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
}
return ( return (
<> <>
@@ -104,16 +128,64 @@ export function CallRow({ call, systemName }: Props) {
)} )}
{/* Transcript */} {/* Transcript */}
{displayTranscript ? ( {editing ? (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={4}
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
/>
<div className="flex gap-2">
<button
onClick={saveEdit}
disabled={saving}
className="text-xs bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-3 py-1 rounded transition-colors"
>
{saving ? "Saving…" : "Save & reprocess"}
</button>
<button
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors"
>
Cancel
</button>
</div>
</div>
) : hasSegments ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
{hasBoth && ( <span className="text-xs text-gray-600 font-mono">{call.segments!.length} transmissions</span>
<span className="text-xs text-gray-600 font-mono"> {isAdmin && (
{showOriginal ? "original" : "corrected"} <button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
</span>
)} )}
{!hasBoth && call.transcript_corrected && ( </div>
<span className="text-xs text-gray-600 font-mono">corrected</span> <div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
{call.segments!.map((seg, i) => (
<div key={i} className="flex gap-3 text-xs font-mono">
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span>
<span className="text-gray-300">{seg.text}</span>
</div>
))}
</div>
{hasBoth && (
<button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
>
{showOriginal ? "show corrected ↑" : "show original ↓"}
</button>
)}
</div>
) : displayTranscript ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>}
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>}
</div>
{isAdmin && (
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
)} )}
</div> </div>
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto"> <pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
+2
View File
@@ -55,6 +55,8 @@ export const c2api = {
const qs = params ? "?" + new URLSearchParams(params).toString() : ""; const qs = params ? "?" + new URLSearchParams(params).toString() : "";
return request<unknown[]>(`/calls${qs}`); return request<unknown[]>(`/calls${qs}`);
}, },
patchTranscript: (callId: string, transcript: string) =>
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
// Incidents // Incidents
getIncidents: (params?: { status?: string; type?: string }) => { getIncidents: (params?: { status?: string; type?: string }) => {
+7
View File
@@ -20,6 +20,12 @@ export interface SystemRecord {
config: Record<string, unknown>; config: Record<string, unknown>;
} }
export interface TranscriptSegment {
start: number;
end: number;
text: string;
}
export interface CallRecord { export interface CallRecord {
call_id: string; call_id: string;
node_id: string; node_id: string;
@@ -32,6 +38,7 @@ export interface CallRecord {
audio_url: string | null; audio_url: string | null;
transcript: string | null; transcript: string | null;
transcript_corrected: string | null; transcript_corrected: string | null;
segments: TranscriptSegment[] | null;
incident_id: string | null; incident_id: string | null;
location: { lat: number; lng: number } | null; location: { lat: number; lng: number } | null;
tags: string[]; tags: string[];