#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:
area_context: { municipality?, county?, state?,
center?, radius_km?, resolved_from?, resolved_at?,
local_knowledge?: [{term, meaning}] }
`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.
Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.
The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.
#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.
No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.
Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.
The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.
38 new tests; 240 pass. Frontend typechecks clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
9.9 KiB
Python
289 lines
9.9 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 LocalKnowledgeEntry(BaseModel):
|
|
"""A local name and what it is. Both scopes, one shape (server-26#36)."""
|
|
term: str
|
|
meaning: Optional[str] = None
|
|
|
|
|
|
class AreaContextBody(BaseModel):
|
|
"""
|
|
Ground truth about the area a system or talkgroup covers.
|
|
|
|
Every field is nullable on purpose: which SCOPE an operator fills is their
|
|
declaration of how homogeneous the system is. A single-municipality system
|
|
is described once at system level and inherited by every talkgroup; a
|
|
statewide one is left null there and described per talkgroup. See
|
|
`internal/area_context.py` for the merge rules and the anchor.
|
|
|
|
`center`/`radius_km`/`resolved_from`/`resolved_at` are absent here by
|
|
design — the backend geocodes and writes those. A client that sends them is
|
|
ignored.
|
|
"""
|
|
municipality: Optional[str] = None
|
|
county: Optional[str] = None
|
|
state: Optional[str] = None
|
|
local_knowledge: List[LocalKnowledgeEntry] = []
|
|
|
|
|
|
class TalkgroupEntry(BaseModel):
|
|
"""
|
|
One entry in `config.talkgroups[]`.
|
|
|
|
Declared so the talkgroup copy of `area_context` stops being unvalidated
|
|
JSON riding inside the config blob — it is the same shape as the system's
|
|
and gets the same validator (server-26#36).
|
|
"""
|
|
model_config = {"extra": "allow"}
|
|
|
|
id: int
|
|
name: str = ""
|
|
tag: str = "other"
|
|
vocabulary: List[str] = []
|
|
area_context: Optional[AreaContextBody] = None
|
|
|
|
|
|
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 and the place verifier (server-26#36 / #37). Shape is
|
|
# AreaContextBody plus the backend-owned anchor. Per-talkgroup overrides
|
|
# live inside config.talkgroups[] and rank ABOVE this, so a multi-county
|
|
# system narrows per channel rather than replacing this wholesale.
|
|
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
|