diff --git a/drb-c2-core/app/routers/calls.py b/drb-c2-core/app/routers/calls.py index 9c5959d..753acb3 100644 --- a/drb-c2-core/app/routers/calls.py +++ b/drb-c2-core/app/routers/calls.py @@ -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 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"]) @@ -51,3 +57,41 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks): gcs_uri=gcs_uri, ) 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} diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index 27bd2db..6df2f7f 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -83,6 +83,45 @@ def _public_url_to_gcs_uri(url: str) -> Optional[str]: 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( call_id: str, node_id: str, diff --git a/drb-frontend/app/calls/page.tsx b/drb-frontend/app/calls/page.tsx index d804f31..55eea20 100644 --- a/drb-frontend/app/calls/page.tsx +++ b/drb-frontend/app/calls/page.tsx @@ -4,11 +4,13 @@ import { useState } from "react"; import { useCalls } from "@/lib/useCalls"; import { useSystems } from "@/lib/useSystems"; import { CallRow } from "@/components/CallRow"; +import { useAuth } from "@/components/AuthProvider"; export default function CallsPage() { const [limitCount, setLimitCount] = useState(100); const { calls, loading } = useCalls(limitCount); const { systems } = useSystems(); + const { isAdmin } = useAuth(); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const active = calls.filter((c) => c.status === "active"); @@ -41,7 +43,7 @@ export default function CallsPage() { {active.map((c) => ( - + ))} @@ -73,7 +75,7 @@ export default function CallsPage() { {ended.map((c) => ( - + ))} diff --git a/drb-frontend/app/dashboard/page.tsx b/drb-frontend/app/dashboard/page.tsx index 416202a..c8e14b5 100644 --- a/drb-frontend/app/dashboard/page.tsx +++ b/drb-frontend/app/dashboard/page.tsx @@ -8,6 +8,7 @@ import { CallRow } from "@/components/CallRow"; import { NodeConfigModal } from "@/components/NodeConfigModal"; import { useState } from "react"; import type { NodeRecord } from "@/lib/types"; +import { useAuth } from "@/components/AuthProvider"; function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { return ( @@ -26,6 +27,7 @@ export default function DashboardPage() { const { systems, error: systemsError } = useSystems(); const [configNode, setConfigNode] = useState(null); + const { isAdmin } = useAuth(); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const onlineCount = nodes.filter((n) => n.status !== "offline").length; @@ -98,7 +100,7 @@ export default function DashboardPage() { {calls.map((c) => ( - + ))} diff --git a/drb-frontend/components/CallRow.tsx b/drb-frontend/components/CallRow.tsx index ebe7b92..eb6214e 100644 --- a/drb-frontend/components/CallRow.tsx +++ b/drb-frontend/components/CallRow.tsx @@ -2,10 +2,12 @@ import { useState } from "react"; import type { CallRecord } from "@/lib/types"; +import { c2api } from "@/lib/c2api"; interface Props { call: CallRecord; systemName?: string; + isAdmin?: boolean; } function duration(started: string, ended: string | null): string { @@ -22,13 +24,35 @@ const TAG_COLORS: Record = { 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 [showOriginal, setShowOriginal] = useState(false); + const [editing, setEditing] = useState(false); + const [editText, setEditText] = useState(""); + const [saving, setSaving] = useState(false); const isActive = call.status === "active"; 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 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 ( <> @@ -104,16 +128,64 @@ export function CallRow({ call, systemName }: Props) { )} {/* Transcript */} - {displayTranscript ? ( + {editing ? ( +
e.stopPropagation()}> +