Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.
internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.
Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
municipality, and that municipality's streets must not be buried under a
county-wide list. A single-municipality system is the degenerate case: populate
the system level and every talkgroup inherits it. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.
Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.
Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.
Two things found on the way:
PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.
Closes server-26#36.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
8.4 KiB
Python
245 lines
8.4 KiB
Python
from pydantic import BaseModel, Field
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Organizations — the tenant boundary. See SAAS_PLAN.md B2 and
|
|
# app/internal/auth.py's require_org(). plan_id/subscription_status and the
|
|
# stripe_* fields are deliberately inert (None) here: no billing model has
|
|
# been decided yet (participation-based / reciprocal access, not per-seat
|
|
# SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this
|
|
# is just the seam a future billing pass would write into, not a promise
|
|
# about what that pass looks like.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class OrganizationRecord(BaseModel):
|
|
org_id: str
|
|
name: str
|
|
created_at: datetime
|
|
created_by_uid: str
|
|
plan_id: Optional[str] = None
|
|
subscription_status: Optional[str] = None
|
|
stripe_customer_id: Optional[str] = None
|
|
stripe_subscription_id: Optional[str] = None
|
|
current_period_end: Optional[datetime] = None
|
|
seat_limit: Optional[int] = None
|
|
node_limit: Optional[int] = None
|
|
retention_days: Optional[int] = None
|
|
|
|
|
|
class OrgMember(BaseModel):
|
|
uid: str
|
|
org_id: str
|
|
org_role: str # "owner" | "member"
|
|
email: Optional[str] = None
|
|
added_at: datetime
|
|
|
|
|
|
class EnrollmentTokenRecord(BaseModel):
|
|
"""Firestore doc id is the SHA-256 hash of the raw token — see
|
|
routers/enrollment.py's _hash_secret pattern (pickup_secret_hash)."""
|
|
org_id: str
|
|
label: str
|
|
created_at: datetime
|
|
created_by_uid: str
|
|
revoked: bool = False
|
|
uses: int = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nodes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class NodeRecord(BaseModel):
|
|
node_id: str
|
|
name: str
|
|
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
status: str = "offline" # online / offline / recording / unconfigured
|
|
configured: bool = False
|
|
last_seen: Optional[datetime] = None
|
|
assigned_system_id: Optional[str] = None
|
|
node_type: str = "fixed" # fixed or portable
|
|
enforce_override_timeout: bool = True
|
|
is_overridden: bool = False
|
|
override_system_id: Optional[str] = None
|
|
override_timeout_at: Optional[datetime] = None
|
|
|
|
|
|
class CommandPayload(BaseModel):
|
|
action: str # discord_join / discord_leave / op25_restart
|
|
guild_id: Optional[str] = None
|
|
channel_id: Optional[str] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Systems
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SystemRecord(BaseModel):
|
|
system_id: str
|
|
org_id: Optional[str] = None
|
|
name: str
|
|
type: str # P25 / DMR / NBFM
|
|
config: Dict[str, Any] = {} # OP25-compatible config blob
|
|
ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...}
|
|
# Ground truth about the area this system covers, fed to the transcript
|
|
# corrector (server-26#36): {municipality, county, roads[], landmarks[]}.
|
|
# Per-talkgroup overrides live inside config.talkgroups[] and rank ABOVE
|
|
# this, so a multi-county system can narrow per channel.
|
|
area_context: Dict[str, Any] = {}
|
|
|
|
|
|
class SystemCreate(BaseModel):
|
|
name: str
|
|
type: str
|
|
config: Dict[str, Any] = {}
|
|
ten_codes: Dict[str, str] = {}
|
|
area_context: Dict[str, Any] = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CallRecord(BaseModel):
|
|
call_id: str
|
|
node_id: str
|
|
org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py
|
|
system_id: Optional[str] = None
|
|
talkgroup_id: Optional[int] = None
|
|
talkgroup_name: Optional[str] = None
|
|
freq: Optional[float] = None
|
|
srcaddr: Optional[str] = None
|
|
started_at: datetime
|
|
ended_at: Optional[datetime] = None
|
|
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
|
|
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
|
|
duplicate_of: Optional[str] = None # another node recorded this same transmission first
|
|
transcript: Optional[str] = None # populated later by STT
|
|
incident_ids: List[str] = [] # one per scene detected in the recording
|
|
location: Optional[Dict[str, float]] = None # {lat, lng}
|
|
tags: List[str] = []
|
|
status: str = "active" # active / ended
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Incidents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class IncidentRecord(BaseModel):
|
|
incident_id: str
|
|
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
|
|
title: Optional[str] = None
|
|
type: Optional[str] = None # fire / police / ems / etc.
|
|
status: str = "active" # active / resolved
|
|
location: Optional[Dict[str, float]] = None
|
|
call_ids: List[str] = []
|
|
started_at: datetime
|
|
updated_at: datetime
|
|
summary: Optional[str] = None
|
|
tags: List[str] = []
|
|
|
|
|
|
class IncidentCreate(BaseModel):
|
|
title: str
|
|
type: str = "other"
|
|
status: str = "active"
|
|
location: Optional[Dict[str, float]] = None
|
|
call_ids: List[str] = []
|
|
summary: Optional[str] = None
|
|
tags: List[str] = []
|
|
|
|
|
|
class IncidentUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
type: Optional[str] = None
|
|
status: Optional[str] = None
|
|
location: Optional[Dict[str, float]] = None
|
|
summary: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alerts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class AlertRule(BaseModel):
|
|
rule_id: Optional[str] = None
|
|
org_id: Optional[str] = None
|
|
name: str
|
|
keywords: List[str] = []
|
|
talkgroup_ids: List[int] = []
|
|
enabled: bool = True
|
|
discord_webhook: Optional[str] = None # POST here when rule fires
|
|
|
|
|
|
class AlertRuleUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
keywords: Optional[List[str]] = None
|
|
talkgroup_ids: Optional[List[int]] = None
|
|
enabled: Optional[bool] = None
|
|
discord_webhook: Optional[str] = None
|
|
|
|
|
|
class AlertEvent(BaseModel):
|
|
alert_id: Optional[str] = None
|
|
org_id: Optional[str] = None
|
|
rule_id: str
|
|
rule_name: str
|
|
call_id: str
|
|
node_id: str
|
|
talkgroup_id: Optional[int] = None
|
|
talkgroup_name: Optional[str] = None
|
|
matched_keywords: List[str] = []
|
|
transcript_snippet: Optional[str] = None
|
|
triggered_at: Optional[datetime] = None
|
|
acknowledged: bool = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trips
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TripCreate(BaseModel):
|
|
name: str
|
|
location: str
|
|
maps_link: Optional[str] = None
|
|
start_date: str # YYYY-MM-DD
|
|
end_date: str # YYYY-MM-DD
|
|
available_tags: List[str] = [] # tag labels configured for this trip
|
|
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
|
visibility: str = "public" # "public" | "private"
|
|
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
|
|
|
|
|
|
class TripEventCreate(BaseModel):
|
|
title: str
|
|
date: str # YYYY-MM-DD, must fall within parent trip range
|
|
start_time: Optional[str] = None # HH:MM (24h)
|
|
end_time: Optional[str] = None # HH:MM (24h)
|
|
location: Optional[str] = None # inherits trip location if None
|
|
maps_link: Optional[str] = None
|
|
place_id: Optional[str] = None # Google Place ID
|
|
notes: Optional[str] = None
|
|
tags: List[str] = [] # tag labels applied to this event
|
|
|
|
|
|
class TripEventUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
date: Optional[str] = None
|
|
start_time: Optional[str] = None
|
|
end_time: Optional[str] = None
|
|
location: Optional[str] = None
|
|
maps_link: Optional[str] = None
|
|
place_id: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
|
|
|
|
class AttendeeAction(BaseModel):
|
|
discord_user_id: str
|
|
discord_username: Optional[str] = None
|