Files
server-26/drb-c2-core/app/config.py
Logan CusanoandClaude Sonnet 5 b1884852d5 correlator: remove is_dispatch and the tactical fit path entirely (#134)
Full removal, not a hardcoded flag: _is_dispatch_channel, _DISPATCH_TG_RE, the is_dispatch parameter, and _call_fits_incident's tactical branch are gone. One evaluation path for every channel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-13 14:49:55 -04:00

202 lines
12 KiB
Python

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
# Tier-2 thin calls attach to a lone candidate idle < this, on every
# channel (server-26#133/#134 removed the dispatch/tactical split — a
# channel's name doesn't change how much scrutiny it gets). 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. Every correct thin attach in that dump 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. Also the escape hatch in routers/upload.py's LLM-orphan gate
# (_recent_incident_on_same_talkgroup, server-26#115) — check both call
# sites before retuning this.
tg_dispatch_thin_idle_minutes: int = 5
# ── 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 <audio src> is
# fetched by the browser directly, so a relative path would resolve against
# the frontend origin, not this one.
public_api_url: Optional[str] = None
# How long a minted call-audio playback link stays valid. Long enough for a
# browsing session, short enough that a copied link isn't durable access.
audio_link_ttl_seconds: int = 6 * 60 * 60
# Two nodes hearing the same transmission start recording within about a
# second of each other (measured across node-002/node-PI-2 on TG 9048).
# 10s is generous against clock skew while staying well under the gap
# between genuinely separate transmissions on a busy dispatch channel.
duplicate_window_seconds: int = 10
# Browser origins allowed to call this API cross-origin. The only browser
# caller is the frontend's Archive page (GET /calls/search) — every other
# page reads Firestore directly. The frontend is served on the BARE domain
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
#
# A "*" entry here still works for local dev but is refused a credentialed
# response: main.py never enables allow_credentials (auth is a Bearer
# header, not a cookie), and it logs a loud ERROR when it sees a wildcard
# in a deployment so a forgotten override is visible.
cors_origins: list[str] = ["https://drb.cusano.net"]
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
# tier (transcription/correlation) transitions into or out of degraded
# state. Empty disables the POST entirely — not every self-hosted
# deployment will set this up, and skipping it must be silent.
ai_alert_webhook_url: str = ""
class Config:
env_file = ".env"
settings = Settings()