Big updates

This commit is contained in:
Logan
2026-04-21 01:51:23 -04:00
parent 788afca339
commit 6612e4b683
13 changed files with 168 additions and 49 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim FROM python:3.14-slim
WORKDIR /app WORKDIR /app
@@ -145,6 +145,7 @@ async def correlate_call(
await _update_incident( await _update_incident(
matched_incident, call_id, talkgroup_id, system_id, tags, matched_incident, call_id, talkgroup_id, system_id, tags,
location, location_coords, call_units, call_vehicles, call_embedding, now, location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type,
) )
elif incident_type: elif incident_type:
incident_id = await _create_incident( incident_id = await _create_incident(
@@ -250,6 +251,8 @@ async def _update_incident(
call_vehicles: list[str], call_vehicles: list[str],
call_embedding: Optional[list], call_embedding: Optional[list],
now: datetime, now: datetime,
talkgroup_name: Optional[str] = None,
incident_type: Optional[str] = None,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
@@ -295,6 +298,22 @@ async def _update_incident(
if best_coords: if best_coords:
updates["location_coords"] = best_coords updates["location_coords"] = best_coords
# Re-evaluate title when a substantive call (classified incident_type) brings new tags.
# Routine status calls (type=None) do not clobber the title.
if incident_type:
content_tags = [t for t in tags if t != "auto-generated"]
primary_tag = content_tags[0].replace("-", " ").title() if content_tags else None
tg_label = (
talkgroup_name
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split("")[-1])
)
if primary_tag and best_location:
updates["title"] = f"{primary_tag} at {best_location}"
elif primary_tag and tg_label:
updates["title"] = f"{primary_tag}{tg_label}"
elif primary_tag:
updates["title"] = primary_tag
await fstore.doc_set("incidents", incident_id, updates) await fstore.doc_set("incidents", incident_id, updates)
logger.info(f"Correlator: linked call {call_id} to incident {incident_id}") logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
@@ -315,14 +334,24 @@ async def _create_incident(
now: datetime, now: datetime,
) -> str: ) -> str:
incident_id = str(uuid.uuid4()) incident_id = str(uuid.uuid4())
tg_label = ( tg_label = (
talkgroup_name talkgroup_name
or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup") or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup")
) )
# Build a descriptive title from tags + location when available
content_tags = [t for t in tags if t != "auto-generated"]
primary_tag = content_tags[0].replace("-", " ").title() if content_tags else None
if primary_tag and location:
title = f"{primary_tag} at {location}"
elif primary_tag:
title = f"{primary_tag}{tg_label}"
else:
title = f"{incident_type.title()}{tg_label}"
doc = { doc = {
"incident_id": incident_id, "incident_id": incident_id,
"title": f"{incident_type.title()}{tg_label}", "title": title,
"type": incident_type, "type": incident_type,
"status": "active", "status": "active",
"location": location, "location": location,
+2 -1
View File
@@ -62,6 +62,7 @@ async def extract_tags(
system_id: Optional[str] = None, system_id: Optional[str] = None,
segments: Optional[list[dict]] = None, segments: Optional[list[dict]] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
preserve_transcript_correction: bool = False,
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict], bool]: ) -> tuple[list[str], Optional[str], Optional[str], Optional[dict], bool]:
""" """
Extract incident tags, type, location, corrected transcript, and closure signal via GPT-4o mini. Extract incident tags, type, location, corrected transcript, and closure signal via GPT-4o mini.
@@ -119,7 +120,7 @@ async def extract_tags(
updates["units"] = units updates["units"] = units
if embedding: if embedding:
updates["embedding"] = embedding updates["embedding"] = embedding
if transcript_corrected: if transcript_corrected and not preserve_transcript_correction:
updates["transcript_corrected"] = transcript_corrected updates["transcript_corrected"] = transcript_corrected
try: try:
+12 -1
View File
@@ -154,12 +154,23 @@ class MQTTHandler:
else datetime.now(timezone.utc) else datetime.now(timezone.utc)
) )
# Prefer the name from OP25 metadata; fall back to the system config
tgid_name = payload.get("tgid_name") or ""
if not tgid_name and system_id and payload.get("tgid"):
system_doc = await fstore.doc_get("systems", system_id)
if system_doc:
tgid_int = int(payload["tgid"])
for tg in system_doc.get("config", {}).get("talkgroups", []):
if int(tg.get("id", -1)) == tgid_int:
tgid_name = tg.get("name", "")
break
doc = { doc = {
"call_id": call_id, "call_id": call_id,
"node_id": node_id, "node_id": node_id,
"system_id": system_id, "system_id": system_id,
"talkgroup_id": payload.get("tgid"), "talkgroup_id": payload.get("tgid"),
"talkgroup_name": payload.get("tgid_name") or "", "talkgroup_name": tgid_name,
"freq": payload.get("freq"), "freq": payload.get("freq"),
"srcaddr": payload.get("srcaddr"), "srcaddr": payload.get("srcaddr"),
"started_at": started_at, "started_at": started_at,
+5 -3
View File
@@ -40,7 +40,7 @@ async def transcribe_call(
return None, [] return None, []
try: try:
transcript, segments = await asyncio.to_thread(_sync_transcribe, gcs_uri) transcript, segments = await asyncio.to_thread(_sync_transcribe, gcs_uri, talkgroup_name)
except Exception as e: except Exception as e:
logger.warning(f"Transcription failed for call {call_id}: {e}") logger.warning(f"Transcription failed for call {call_id}: {e}")
return None, [] return None, []
@@ -61,7 +61,7 @@ async def transcribe_call(
return transcript, segments return transcript, segments
def _sync_transcribe(gcs_uri: str) -> tuple[Optional[str], list[dict]]: def _sync_transcribe(gcs_uri: str, talkgroup_name: Optional[str] = None) -> tuple[Optional[str], list[dict]]:
"""Download audio from GCS and transcribe with OpenAI Whisper.""" """Download audio from GCS and transcribe with OpenAI Whisper."""
from google.cloud import storage as gcs from google.cloud import storage as gcs
from google.oauth2 import service_account from google.oauth2 import service_account
@@ -94,13 +94,15 @@ def _sync_transcribe(gcs_uri: str) -> tuple[Optional[str], list[dict]]:
try: try:
blob.download_to_filename(tmp_path) blob.download_to_filename(tmp_path)
prompt = (f"Talkgroup: {talkgroup_name}. " + _WHISPER_PROMPT) if talkgroup_name else _WHISPER_PROMPT
openai_client = OpenAI(api_key=settings.openai_api_key) openai_client = OpenAI(api_key=settings.openai_api_key)
with open(tmp_path, "rb") as f: with open(tmp_path, "rb") as f:
response = openai_client.audio.transcriptions.create( response = openai_client.audio.transcriptions.create(
model="whisper-1", model="whisper-1",
file=f, file=f,
language="en", language="en",
prompt=_WHISPER_PROMPT, prompt=prompt,
response_format="verbose_json", response_format="verbose_json",
) )
text = response.text.strip() or None text = response.text.strip() or None
+4 -3
View File
@@ -71,10 +71,10 @@ async def patch_transcript(
if not call: if not call:
raise HTTPException(404, f"Call '{call_id}' not found.") raise HTTPException(404, f"Call '{call_id}' not found.")
# Save new transcript, clear stale intelligence fields # Save user correction as transcript_corrected; leave original transcript intact.
# Clear stale intelligence fields so re-extraction runs fresh.
await fstore.doc_set("calls", call_id, { await fstore.doc_set("calls", call_id, {
"transcript": body.transcript, "transcript_corrected": body.transcript,
"transcript_corrected": None,
"tags": [], "tags": [],
"severity": "unknown", "severity": "unknown",
"location": None, "location": None,
@@ -93,5 +93,6 @@ async def patch_transcript(
talkgroup_name=call.get("talkgroup_name"), talkgroup_name=call.get("talkgroup_name"),
transcript=body.transcript, transcript=body.transcript,
segments=call.get("segments"), segments=call.get("segments"),
preserve_transcript_correction=True,
) )
return {"ok": True, "call_id": call_id} return {"ok": True, "call_id": call_id}
+2
View File
@@ -91,6 +91,7 @@ async def _run_extraction_pipeline(
talkgroup_name: Optional[str], talkgroup_name: Optional[str],
transcript: str, transcript: str,
segments: Optional[list] = None, segments: Optional[list] = None,
preserve_transcript_correction: bool = False,
) -> None: ) -> None:
"""Run steps 2-4 of the intelligence pipeline using an existing transcript.""" """Run steps 2-4 of the intelligence pipeline using an existing transcript."""
from app.internal import intelligence, incident_correlator, alerter from app.internal import intelligence, incident_correlator, alerter
@@ -99,6 +100,7 @@ async def _run_extraction_pipeline(
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,
preserve_transcript_correction=preserve_transcript_correction,
) )
incident_id = await incident_correlator.correlate_call( incident_id = await incident_correlator.correlate_call(
+10 -8
View File
@@ -88,16 +88,18 @@ export default function IncidentDetailPage() {
</button> </button>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex flex-col gap-1.5">
<TypeBadge type={incident.type} /> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-white font-mono"> <TypeBadge type={incident.type} />
<StatusBadge status={incident.status} />
</div>
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
{incident.title ?? "Incident"} {incident.title ?? "Incident"}
</h1> </h1>
<StatusBadge status={incident.status} />
</div> </div>
{isAdmin && ( {isAdmin && (
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0 flex-wrap">
<button <button
onClick={handleSummarize} onClick={handleSummarize}
disabled={summarizing} disabled={summarizing}
@@ -261,8 +263,8 @@ export default function IncidentDetailPage() {
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th> <th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th> <th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th> <th className="px-4 py-2 text-left hidden sm:table-cell">System</th>
<th className="px-4 py-2 text-left">Node</th> <th className="px-4 py-2 text-left hidden sm:table-cell">Node</th>
<th className="px-4 py-2 text-left">Duration</th> <th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th> <th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th> <th className="px-4 py-2"></th>
+73 -25
View File
@@ -136,37 +136,85 @@ function CreateModal({ onClose, onCreate }: {
); );
} }
function IncidentCards({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
const router = useRouter();
return (
<div className="space-y-2">
{incidents.map((inc) => (
<div
key={inc.incident_id}
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
{typeBadge(inc.type)}
<span className={`text-xs px-2 py-0.5 rounded-full ${
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
}`}>{inc.status}</span>
</div>
{isAdmin && inc.status === "active" && (
<button
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
>
Resolve
</button>
)}
</div>
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
<p className="text-gray-500 text-xs mt-1 font-mono">
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
))}
</div>
);
}
function IncidentTable({ incidents, isAdmin, onResolve }: { function IncidentTable({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[]; incidents: IncidentRecord[];
isAdmin: boolean; isAdmin: boolean;
onResolve: (id: string) => void; onResolve: (id: string) => void;
}) { }) {
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <>
<table className="w-full text-left"> {/* Mobile card view */}
<thead> <div className="sm:hidden">
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
<th className="px-4 py-3">Type</th> </div>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th> {/* Desktop table view */}
<th className="px-4 py-3">Calls</th> <div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<th className="px-4 py-3">Started</th> <table className="w-full text-left">
<th className="px-4 py-3">Updated</th> <thead>
<th className="px-4 py-3"></th> <tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
</tr> <th className="px-4 py-3">Type</th>
</thead> <th className="px-4 py-3">Title</th>
<tbody> <th className="px-4 py-3">Status</th>
{incidents.map((inc) => ( <th className="px-4 py-3">Calls</th>
<IncidentRow <th className="px-4 py-3">Started</th>
key={inc.incident_id} <th className="px-4 py-3">Updated</th>
incident={inc} <th className="px-4 py-3"></th>
isAdmin={isAdmin} </tr>
onResolve={onResolve} </thead>
/> <tbody>
))} {incidents.map((inc) => (
</tbody> <IncidentRow
</table> key={inc.incident_id}
</div> incident={inc}
isAdmin={isAdmin}
onResolve={onResolve}
/>
))}
</tbody>
</table>
</div>
</>
); );
} }
+11
View File
@@ -233,6 +233,17 @@ export default function NodeDetailPage() {
> >
Restart OP25 Restart OP25
</button> </button>
<button
disabled={sending}
onClick={() => {
if (confirm("Restart the node container to apply any pulled image updates?")) {
sendCommand("node_update");
}
}}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Update Node
</button>
<button <button
onClick={() => setShowDiscordJoin(true)} onClick={() => setShowDiscordJoin(true)}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors" className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Run before pushing — catches TypeScript errors without a full build.
set -e
cd "$(dirname "$0")"
echo "→ TypeScript check…"
npm run typecheck
echo "✓ No type errors."
+9 -4
View File
@@ -30,6 +30,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(""); const [editText, setEditText] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
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;
@@ -44,11 +45,12 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
async function saveEdit(e: React.MouseEvent) { async function saveEdit(e: React.MouseEvent) {
e.stopPropagation(); e.stopPropagation();
setSaving(true); setSaving(true);
setSaveError(null);
try { try {
await c2api.patchTranscript(call.call_id, editText); await c2api.patchTranscript(call.call_id, editText);
setEditing(false); setEditing(false);
} catch (err) { } catch (err) {
console.error(err); setSaveError(err instanceof Error ? err.message : "Save failed");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -71,8 +73,8 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td> <td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400">{call.node_id}</td> <td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{isActive ? ( {isActive ? (
<span className="text-orange-400 animate-pulse"> live</span> <span className="text-orange-400 animate-pulse"> live</span>
@@ -136,7 +138,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
rows={4} 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" 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"> <div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={saveEdit} onClick={saveEdit}
disabled={saving} disabled={saving}
@@ -150,6 +152,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
> >
Cancel Cancel
</button> </button>
{saveError && (
<span className="text-xs text-red-400 font-mono">{saveError}</span>
)}
</div> </div>
</div> </div>
) : hasSegments ? ( ) : hasSegments ? (
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim FROM python:3.14-slim
WORKDIR /app WORKDIR /app