from pydantic_settings import BaseSettings from typing import Optional class Settings(BaseSettings): # MQTT mqtt_broker: str = "localhost" mqtt_port: int = 1883 mqtt_user: Optional[str] = None mqtt_pass: Optional[str] = None # mosquitto's built-in dynamic-security plugin (see app/internal/dynsec.py). # "admin" is hardcoded by the plugin itself on first boot — not actually # configurable — kept as a named setting rather than a literal for # readability. mqtt_dynsec_admin_pass must equal the mosquitto # container's own MOSQUITTO_DYNSEC_PASSWORD env var (root .env / # root.env.j2) or c2-core can't administer node credentials at all. mqtt_dynsec_admin_user: str = "admin" mqtt_dynsec_admin_pass: Optional[str] = None # GCP gcp_credentials_path: Optional[str] = None # None → uses ADC gcs_bucket: Optional[str] = None # None → audio upload disabled firestore_database: str = "(default)" # Node health node_offline_threshold: int = 90 # seconds without checkin before marking offline # OpenAI (STT + intelligence) openai_api_key: Optional[str] = None stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe # Google Maps (geocoding) google_maps_api_key: Optional[str] = None # Gemini (intelligence extraction, embeddings, incident summaries) gemini_api_key: Optional[str] = None # Correlation consensus models # corr_cheap_model — first-pass LLM correlator (runs on every call) # corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree) # Both IDs below were retired by Google and returned 404 on every call from # some point before 2026-08-18 until they were corrected. Because a failed # LLM call falls back to the rules decision, nothing broke loudly -- the # entire LLM tier and the consensus tiebreak were simply dead in production # while correlation behaviour was being tuned against rules-only output. # Verify against https://ai.google.dev/gemini-api/docs/models before changing. corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down) corr_smart_model: str = "gemini-2.5-pro" # was gemini-1.5-pro (shut down) # Transcript correction (server-26#36). Runs inside transcription, once per # transcribed call above MIN_WORDS_FOR_CORRECTION, so it is priced like STT # rather than like the correlation tier — cheap model on purpose. transcript_correction_enabled: bool = True transcript_correction_model: str = "gemini-3.6-flash" # Retry Whisper once when its output is degenerate. The same clip produced a # 56-word ten-code counting run on one attempt and real speech on the next # (2026-08-23, call e49ea32c), so a hallucination is a coin-flip rather than # a property of the audio, and discarding on the first bad roll threw away a # recoverable transcript. stt_retry_on_degenerate: bool = True # Place verification (server-26#37). Checks the corrector's location nouns # against the talkgroup's own anchor instead of stuffing every road in town # into the prompt, so cost scales with location nouns rather than call volume. place_verification_enabled: bool = True # Raw transcript text in alert payloads (server-26#85). Default CLOSED. # Board minutes #42 suppress person names on every surface until E&O is # bound, and an alert webhook is the least recoverable surface there is: # once the text is in a Discord channel we do not own it, cannot unsend # it, and cannot audit who read it. This switch is the operator-level # gate and is deliberately NOT reachable from the app -- the per-org # opt-in alone would let an org owner self-serve their way to somebody # else's PII. Both gates must be open before any snippet leaves. alert_transcript_snippet_enabled: bool = False place_verify_max_per_call: int = 3 # How close a candidate has to sound before it may rewrite a transcript. # Below this, Places Text Search will confidently hand back the nearest # business for any garbage string. place_soundalike_min_ratio: float = 0.6 # An anchor wider than this is not stored at all. A statewide radius would # confirm any location inside it, so the check would rubber-stamp everything # while appearing to work — absent anchor means SKIP, never "accept anything". area_anchor_max_radius_km: float = 60.0 summary_interval_minutes: int = 2 # how often the summary loop runs correlation_window_hours: int = 2 # slow/location path: max hours since last call embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar) embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units location_proximity_km: float = 0.5 # radius for location-proximity matching geocode_max_km: float = 40.0 # reject geocode results farther than this from the node incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated # Dispatch channels only: tier-2 thin calls attach to a lone candidate idle < this. # Was 10, which is long enough for the channel to have moved on to something else: # on 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central train # meet 9.6 min later, and a status check absorbed a records lookup at 9.7 min. # Across that dump every correct thin attach was <= 3.4 min idle and every wrong # one was >= 8.2, so 5 separates them with room on both sides. Genuine # back-and-forth is handled by the 30-second tier-1 path above this. # Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate escape # hatch (_recent_incident_on_same_talkgroup) reuses this same value, selected # the same way (dispatch vs tactical) via _is_dispatch_channel. Retuning this # for fast/thin reasons moves that gate's behavior too — check both call # sites before changing it. tg_dispatch_thin_idle_minutes: int = 5 # Every other channel: tier-2 thin calls attach to a lone candidate idle < this. # Non-dispatch talkgroups previously had NO tier-2 bound at all — they used the # whole 90-minute tg_fast_path_idle_minutes window with no single-candidate # requirement and no fit test, which is the widest version of the 2026-08-20 # over-merge. A tactical channel really is dedicated to one scene, so it earns # a longer window than a dispatch backbone, but not an unbounded one: 15 sits # inside the 20-minute tactical-default window in _call_fits_incident, so the # no-evidence thin path is never more permissive than the fit-tested path on # the same channel. tg_thin_idle_minutes: int = 15 # ── Hard caps: an incident past either of these stops accepting calls ────── # Enforced on every correlation path (see _incident_at_capacity). Pairwise fit # tests judge one call against one incident and cannot see the shape of the # chain they are building, so these are the only guard against a "work shift" # incident regardless of how individually plausible each link looked. # # 120 minutes: the one incident in the 2026-08-20 dump that was genuinely a # single event ran 63 minutes (06:15 wrong-way driver → 07:18 closeout), so # the cap has to clear an hour with real headroom. The four junk chains ran # 3h41m, 3h43m, 4h05m and 4h09m, so it has to sit well under three hours. # 120 also equals correlation_window_hours: the location and slow paths # already refuse to consider a candidate older than that, and the fast path # was the only one exempt. Making it agree removes that inconsistency rather # than inventing a new number. incident_max_duration_minutes: int = 120 # 40 calls: a backstop for a burst that fills up inside the duration cap # rather than the primary bound. The worst observed chain averaged ~16 # calls/hour while absorbing an ENTIRE dispatch backbone, so 40 calls in # under two hours means the incident is eating most of the channel — that is # a chain, not an event. Set deliberately above any plausible single-incident # call volume (a multi-alarm fire on its own tactical channel) so this cap # errs toward keeping real incidents whole and lets the duration cap do the # cutting. incident_max_calls: int = 40 # Vocabulary learning vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs vocabulary_induction_sample_tokens: int = 4000 # ~tokens of transcript text sampled per system # Internal service key — allows server-side services (discord bot) to call C2 without Firebase service_key: Optional[str] = None # Automation/agent service key — the unattended work-session agent's own # credential for the headless routes it needs (currently GET/PUT # /admin/features). # # DELIBERATELY SEPARATE from service_key above, not a second consumer of # it. service_key is the Discord bot's, and it is handed to a process that # relays radio traffic to a chat server; sharing it here would make "the # bot" and "the agent" the same principal in every log line and audit # entry, so a global AI-cost flag flip could never be attributed to whoever # actually made it. Two keys, two identities (server-26#64 item 1). # # Unset means the agent path is simply closed — the routes still accept a # Firebase admin token. Generate with: openssl rand -hex 32 agent_service_key: Optional[str] = None # Fleet-wide token edge nodes present to POST /nodes/enroll on first boot. # Not a per-node secret — see routers/enrollment.py for why a leaked copy # of this alone can't steal an already-approved node's key. enrollment_token: Optional[str] = None # Upload size limit — reject audio files larger than this (bytes). Default 100 MB. upload_max_bytes: int = 100 * 1024 * 1024 # Public origin this API is reachable on, e.g. "https://api.drb.example.com". # Only used to build absolute call-audio playback links: an