UI Updates
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
</table>
|
||||
@@ -73,7 +75,7 @@ export default function CallsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
</table>
|
||||
|
||||
@@ -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<NodeRecord | null>(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() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
</table>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 ? (
|
||||
<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="flex items-center gap-2">
|
||||
{hasBoth && (
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
{showOriginal ? "original" : "corrected"}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 font-mono">{call.segments!.length} transmissions</span>
|
||||
{isAdmin && (
|
||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
||||
)}
|
||||
{!hasBoth && call.transcript_corrected && (
|
||||
<span className="text-xs text-gray-600 font-mono">corrected</span>
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -55,6 +55,8 @@ export const c2api = {
|
||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return request<unknown[]>(`/calls${qs}`);
|
||||
},
|
||||
patchTranscript: (callId: string, transcript: string) =>
|
||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||
|
||||
// Incidents
|
||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface SystemRecord {
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CallRecord {
|
||||
call_id: string;
|
||||
node_id: string;
|
||||
@@ -32,6 +38,7 @@ export interface CallRecord {
|
||||
audio_url: string | null;
|
||||
transcript: string | null;
|
||||
transcript_corrected: string | null;
|
||||
segments: TranscriptSegment[] | null;
|
||||
incident_id: string | null;
|
||||
location: { lat: number; lng: number } | null;
|
||||
tags: string[];
|
||||
|
||||
Reference in New Issue
Block a user