Backend: three new routes on the calls router, deliberately separate from
PATCH /{call_id}/transcript (a production correction with real side effects
-- re-extraction, incident unlinking, vocabulary learning). This is pure
measurement and must never share that code path.
GET /calls/eval-queue -- calls with a transcript but no
eval_transcript yet, paged (same bounded-
window-plus-cursor shape as /search)
PUT /{call_id}/eval-transcript -- records eval_transcript/_by/_at only;
never touches transcript/transcript_corrected
GET /calls/eval-stats -- eval_count + average word error rate of
the raw and corrected machine transcripts
against the human-verified ones
internal/wer.py: standard word-level Levenshtein WER. Returns None (not 0.0)
when the reference is empty -- a call nobody transcribed must not score as a
perfect match.
Frontend: a new "STT Eval" tab on /admin -- one call at a time, audio player,
a textarea pre-filled with the machine transcript to correct into ground
truth, Save & next / Skip, running WER stats at the top. Built for working a
handful of calls at a time over however many sittings it takes, not a
one-shot form: the queue auto-refills from where the last save left off.
Verified: 438 pass, 0 fail (12 new backend tests). Frontend is UNVERIFIED --
this box has no Node.js/npm (confirmed absent), so neither typecheck nor the
dev server could be run. Matches existing code patterns and the CallRecord/
c2api types by manual review only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
309 lines
8.8 KiB
TypeScript
309 lines
8.8 KiB
TypeScript
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
|
export type UserRole = "admin" | "operator" | "viewer";
|
|
|
|
export interface UserRecord {
|
|
uid: string;
|
|
email: string | null;
|
|
display_name: string | null;
|
|
role: UserRole;
|
|
owned_node_ids: string[];
|
|
disabled: boolean;
|
|
creation_time: string | null;
|
|
last_sign_in: string | null;
|
|
discord_linked: boolean;
|
|
discord_username: string | null;
|
|
discord_user_id: string | null;
|
|
// only present on GET /admin/users/{uid}
|
|
sessions?: UserSession[];
|
|
// only present on POST /admin/users response
|
|
invite_link?: string | null;
|
|
}
|
|
|
|
export interface UserSession {
|
|
session_id: string;
|
|
uid: string;
|
|
email: string;
|
|
timestamp: string;
|
|
ip: string | null;
|
|
user_agent: string | null;
|
|
}
|
|
|
|
export interface AuditEntry {
|
|
log_id: string;
|
|
action: string;
|
|
actor_uid: string;
|
|
actor_email: string;
|
|
target_uid: string | null;
|
|
target_email: string | null;
|
|
details: Record<string, unknown>;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface NodeRecord {
|
|
node_id: string;
|
|
name: string;
|
|
lat: number;
|
|
lon: number;
|
|
status: NodeStatus;
|
|
configured: boolean;
|
|
last_seen: string | null;
|
|
assigned_system_id: string | null;
|
|
approval_status: ApprovalStatus | null;
|
|
hardware_preset?: string;
|
|
ppm_override?: number | null;
|
|
node_type?: string;
|
|
secondary_sdr_mode?: string;
|
|
sdr_count?: number;
|
|
enforce_override_timeout?: boolean;
|
|
is_overridden?: boolean;
|
|
override_system_id?: string | null;
|
|
override_timeout_at?: string | null;
|
|
}
|
|
|
|
export interface AircraftTrack {
|
|
icao: string;
|
|
org_id?: string;
|
|
node_id: string;
|
|
callsign: string | null;
|
|
lat: number | null;
|
|
lon: number | null;
|
|
altitude_ft: number | null;
|
|
ground_speed_kt: number | null;
|
|
track_deg: number | null;
|
|
last_seen: string;
|
|
}
|
|
|
|
export interface VesselTrack {
|
|
mmsi: string;
|
|
org_id?: string;
|
|
node_id: string;
|
|
name: string | null;
|
|
lat: number | null;
|
|
lon: number | null;
|
|
speed_kt: number | null;
|
|
heading_deg: number | null;
|
|
last_seen: string;
|
|
}
|
|
|
|
export interface VocabularyPendingTerm {
|
|
term: string;
|
|
source: "induction" | "correction";
|
|
added_at: string;
|
|
source_call_ids?: string[];
|
|
}
|
|
|
|
export interface SystemRecord {
|
|
system_id: string;
|
|
name: string;
|
|
type: string; // P25 | DMR | NBFM
|
|
config: Record<string, unknown>;
|
|
vocabulary?: string[];
|
|
vocabulary_pending?: VocabularyPendingTerm[];
|
|
vocabulary_bootstrapped?: boolean;
|
|
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
|
preferred_token_id?: string | null;
|
|
}
|
|
|
|
export interface TranscriptSegment {
|
|
start: number;
|
|
end: number;
|
|
text: string;
|
|
}
|
|
|
|
export interface CallRecord {
|
|
call_id: string;
|
|
node_id: string;
|
|
system_id: string | null;
|
|
talkgroup_id: number | null;
|
|
talkgroup_name: string | null;
|
|
freq: number | null;
|
|
started_at: string;
|
|
ended_at: string | null;
|
|
/** Private gs:// object location. Present on the Firestore doc; not playable. */
|
|
audio_gcs_uri?: string | null;
|
|
/**
|
|
* Short-lived playback link. Minted per read by the API — only populated on
|
|
* calls fetched via c2api, never on docs read straight from Firestore.
|
|
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
|
|
*/
|
|
audio_url: string | null;
|
|
transcript: string | null;
|
|
transcript_corrected: string | null;
|
|
segments: TranscriptSegment[] | null;
|
|
/** New: one entry per scene detected in the recording. */
|
|
incident_ids: string[];
|
|
/** Legacy field — present on calls recorded before the multi-scene migration. */
|
|
incident_id?: string | null;
|
|
location: string | null;
|
|
/** Per-call geocode, written by intelligence.py. Powers the incident-path polyline on the map. */
|
|
location_coords?: { lat: number; lng: number } | null;
|
|
/** Unit callsigns mentioned in this specific transmission (e.g. "15-9", "E-41"). */
|
|
units?: string[];
|
|
vehicles?: string[];
|
|
/** Units this call reported as clearing/back in service. */
|
|
cleared_units?: string[];
|
|
/** Set when dedup.py identifies this as a second node's recording of the same transmission — the canonical copy has this null. */
|
|
duplicate_of?: string | null;
|
|
/** Radio source address, when the system exposes it. */
|
|
srcaddr?: string | null;
|
|
tags: string[];
|
|
status: "active" | "ended";
|
|
/** Four-level ladder: routine | minor | moderate | major. Legacy docs may still carry "unknown". */
|
|
severity?: string | null;
|
|
// Correlation debug — written by the correlator, present after a call is linked
|
|
corr_path?: string | null;
|
|
corr_score?: number | null;
|
|
corr_distance_km?: number | null;
|
|
corr_incident_idle_min?: number | null;
|
|
corr_shared_units?: number | null;
|
|
corr_candidates?: number | null;
|
|
/** Human-verified reference transcript for the STT eval harness (server-26#163) — never read by anything downstream. */
|
|
eval_transcript?: string | null;
|
|
eval_transcript_by?: string | null;
|
|
eval_transcript_at?: string | null;
|
|
}
|
|
|
|
export interface IncidentRecord {
|
|
incident_id: string;
|
|
title: string | null;
|
|
type: string | null;
|
|
status: "active" | "resolved";
|
|
location: string | null;
|
|
location_coords: { lat: number; lng: number } | null;
|
|
call_ids: string[];
|
|
system_ids: string[];
|
|
talkgroup_ids: string[];
|
|
/** Omitted on incident docs written before these fields existed. */
|
|
units?: string[];
|
|
vehicles?: string[];
|
|
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
|
units_active?: string[];
|
|
/** Units that reported clearing/back in service on this incident. */
|
|
units_cleared?: string[];
|
|
/** Free-text location mentions accumulated across the incident's calls, beyond the primary `location`. */
|
|
location_mentions?: string[];
|
|
/** ISO timestamp of the last thin/status-only call attached (doesn't refresh `updated_at`). */
|
|
last_thin_at?: string | null;
|
|
severity: string | null;
|
|
started_at: string;
|
|
updated_at: string;
|
|
summary: string | null;
|
|
tags: string[];
|
|
}
|
|
|
|
export interface AlertRule {
|
|
rule_id: string;
|
|
name: string;
|
|
keywords: string[];
|
|
talkgroup_ids: number[];
|
|
enabled: boolean;
|
|
discord_webhook: string | null;
|
|
created_at?: string;
|
|
}
|
|
|
|
export interface TripEvent {
|
|
event_id: string;
|
|
trip_id: string;
|
|
title: string;
|
|
date: string;
|
|
start_time: string | null;
|
|
end_time: string | null;
|
|
location: string;
|
|
location_inherited: boolean;
|
|
maps_link: string | null;
|
|
place_id: string | null;
|
|
notes: string | null;
|
|
tags: string[];
|
|
attendees: Record<string, string>;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface PlaceResult {
|
|
name: string;
|
|
address: string;
|
|
place_id: string;
|
|
lat: number;
|
|
lng: number;
|
|
maps_link: string;
|
|
rating?: number;
|
|
}
|
|
|
|
export interface TripRecord {
|
|
trip_id: string;
|
|
name: string;
|
|
location: string;
|
|
maps_link: string | null;
|
|
start_date: string;
|
|
end_date: string;
|
|
attendees: Record<string, string>;
|
|
available_tags: string[];
|
|
overlap_tags: string[];
|
|
visibility: "public" | "private";
|
|
invited_discord_ids: string[];
|
|
created_at: string;
|
|
events?: TripEvent[];
|
|
}
|
|
|
|
export interface AlertEvent {
|
|
alert_id: string;
|
|
rule_id: string;
|
|
rule_name: string;
|
|
call_id: string;
|
|
node_id: string;
|
|
talkgroup_id: number | null;
|
|
talkgroup_name: string | null;
|
|
matched_keywords: string[];
|
|
transcript_snippet: string | null;
|
|
triggered_at: string;
|
|
acknowledged: boolean;
|
|
}
|
|
|
|
/**
|
|
* Ground truth about the area a system or talkgroup covers, handed to the
|
|
* transcript corrector so it can recognise local street, town and business
|
|
* names that Whisper mangles (server-26#36).
|
|
*
|
|
* Set on the system, and optionally narrowed per talkgroup inside
|
|
* `config.talkgroups[]` — the talkgroup's entries rank ABOVE the system's, so
|
|
* a multi-county system can be specific per channel without its wider list
|
|
* burying the detail.
|
|
*/
|
|
export interface LocalKnowledgeEntry {
|
|
term: string;
|
|
meaning?: string | null;
|
|
}
|
|
|
|
export interface AreaContext {
|
|
// Set by an operator. Every field nullable on purpose: which SCOPE gets
|
|
// filled is the declaration of how homogeneous the system is. A one-town
|
|
// system is described once here and inherited by every talkgroup; a
|
|
// statewide one is left empty here and described per talkgroup.
|
|
municipality?: string;
|
|
county?: string;
|
|
state?: string;
|
|
local_knowledge?: LocalKnowledgeEntry[];
|
|
// Written by the backend, read-only here. Absent means the place is unset or
|
|
// too wide to discriminate, and location verification skips entirely
|
|
// (server-26#37) — never send these back.
|
|
center?: { lat: number; lng: number };
|
|
radius_km?: number;
|
|
resolved_from?: string;
|
|
resolved_at?: string;
|
|
}
|
|
|
|
/** A term the verifier or the induction loop proposed for one talkgroup. */
|
|
export interface PendingLocalTerm {
|
|
term: string;
|
|
meaning?: string | null;
|
|
source: string;
|
|
added_at: string;
|
|
source_call_ids?: string[];
|
|
}
|
|
|
|
export interface TalkgroupPending {
|
|
talkgroup_id: number;
|
|
talkgroup_name?: string;
|
|
pending: PendingLocalTerm[];
|
|
}
|