Files
hold-slayer/config.py
Robert Helewka 1644999bcb feat(logging): structured JSON logs, uvicorn access log included
Hold Slayer's logs are shipped to Loki by the host's Alloy agent, which
reads container stdout. Text lines arrive there as an opaque blob:
filtering on a status code meant regex over a formatted string. This adds
LOG_FORMAT=json (default "text", so local dev stays readable) rendering
one JSON object per line.

Two parts were less obvious than a format= argument would suggest, and
both are why this is a module rather than a basicConfig tweak:

Uvicorn attaches its own handlers to `uvicorn` and `uvicorn.access` with
propagate=False, so configuring only the root logger would have left the
access log — the highest-volume, most useful stream — as colourised text
next to our JSON. configure_logging clears those handlers and re-enables
propagation, and is called both at import (for startup config checks) and
in lifespan (uvicorn configures itself after importing the app). The
__main__ path passes log_config=None so uvicorn never applies its own.

The access record's payload lives in record.args as a 5-tuple, not in the
message. Formatting it would throw the structure away and force Loki to
parse it back out, so the tuple is unpacked into real fields and
status_code is emitted as a number for range filtering.

Also drops uvicorn's `color_message` extra, an ANSI-coloured duplicate of
the message that generic extra-promotion would otherwise copy into every
startup line — the same unreadable-in-Grafana problem recently fixed for
the lab's Asterisk logs.

Verified against a real uvicorn server: 39/39 lines valid JSON, zero ANSI
escapes, no duplicates, access lines structured with correct status codes;
text mode unchanged. Thread name is included off the main thread, since
"which execution context logged this" is the first question when debugging
across the asyncio/Sippy/PJSUA2 boundary. SecretStr extras stay masked.

README Phase 4 item ticked; LOG_FORMAT and the previously-undocumented
LOG_LEVEL added to the config table and .env.example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:18:06 -04:00

201 lines
6.8 KiB
Python

"""
Hold Slayer Gateway — Configuration
All settings loaded from environment variables / .env file.
"""
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class SIPTrunkSettings(BaseSettings):
"""SIP trunk provider configuration."""
model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_", env_file=".env", extra="ignore")
host: str = "sip.provider.com"
port: int = 5060
username: str = ""
password: SecretStr = SecretStr("")
transport: str = "udp" # udp, tcp, tls
did: str = "" # Your phone number (E.164)
class GatewaySIPSettings(BaseSettings):
"""Gateway SIP listener for device registration."""
model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_", env_file=".env", extra="ignore")
host: str = "0.0.0.0"
port: int = 5060
domain: str = "gateway.local"
class SpeachesSettings(BaseSettings):
"""Speaches STT service configuration."""
model_config = SettingsConfigDict(env_prefix="SPEACHES_", env_file=".env", extra="ignore")
url: str = "http://localhost:22070"
model: str = "whisper-large-v3"
class ClassifierSettings(BaseSettings):
"""Audio classifier thresholds."""
model_config = SettingsConfigDict(env_prefix="CLASSIFIER_", env_file=".env", extra="ignore")
music_threshold: float = 0.7
speech_threshold: float = 0.6
silence_threshold: float = 0.85
window_seconds: float = 3.0
class LLMSettings(BaseSettings):
"""LLM service configuration (OpenAI-compatible API)."""
model_config = SettingsConfigDict(env_prefix="LLM_", env_file=".env", extra="ignore")
base_url: str = "http://localhost:11434/v1"
model: str = "llama3"
api_key: SecretStr = SecretStr("not-needed")
timeout: float = 30.0
max_tokens: int = 1024
temperature: float = 0.3
class HoldSlayerSettings(BaseSettings):
"""Hold Slayer behavior settings."""
model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True, env_file=".env", extra="ignore")
default_transfer_device: str = Field(
default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE"
)
max_hold_time: int = Field(default=7200, validation_alias="MAX_HOLD_TIME")
hold_check_interval: float = Field(default=2.0, validation_alias="HOLD_CHECK_INTERVAL")
class TTSSettings(BaseSettings):
"""Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech)."""
model_config = SettingsConfigDict(env_prefix="TTS_", env_file=".env", extra="ignore")
base_url: str = "http://localhost:8000"
model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
voice: str = "af_heart"
api_key: SecretStr = SecretStr("")
timeout: float = 30.0
sample_rate: int = 16000
class CasdoorSettings(BaseSettings):
"""Casdoor SSO (OIDC) configuration.
When `enabled` is true the browser authenticates via Casdoor and every
surface is gated to the owner; the SDK is used only for the OAuth2 code
exchange in the /auth/callback route (JWTs are validated against the
endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only).
"""
model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore")
enabled: bool = False
endpoint: str = "https://id.ouranos.helu.ca"
client_id: str = ""
client_secret: SecretStr = SecretStr("")
org_name: str = "heluca"
app_name: str = ""
class ReceptionistSettings(BaseSettings):
"""AI Receptionist behavior settings."""
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_", env_file=".env", extra="ignore")
enabled: bool = True
greeting_template: str = (
"Hi, you've reached Robert's line. Who's calling, and what's this about?"
)
message_prompt: str = "Please leave your message after the tone."
listen_timeout_s: float = 15.0
end_of_utterance_silence_s: float = 1.2
message_max_seconds: int = 90
llm_persona: str = (
"You are a helpful, concise phone receptionist. Decide whether to ring "
"the owner, take a message, or politely decline."
)
class Settings(BaseSettings):
"""Root application settings."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Database — no default credentials; must be set in the environment
database_url: str = ""
# Server
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
log_level: str = "info"
# Log rendering: "text" (human-readable, for a terminal) or "json" (one
# object per line, for Loki). Text is the default so local dev is readable;
# the container sets LOG_FORMAT=json. See core/logging_config.py.
log_format: str = "text"
# Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
# gated to a single owner. `owner_name` is the Casdoor username that owns
# this gateway (everyone else gets 403). `public_base_url` seeds the OAuth
# discovery URLs; blank derives them from request headers. Both cross-cut
# every surface, so they live on the root model (like DATABASE_URL); the
# Casdoor connection knobs live under the CASDOOR_ prefix.
owner_name: str = ""
public_base_url: str = ""
# Outbound-call safety cap (REST + MCP make_call)
max_concurrent_calls: int = 4
# Explicit engine mode — the mock engine must be asked for. An
# unconfigured trunk without this flag fails startup instead of
# silently degrading to a gateway that can't place real calls.
use_mock_sip: bool = False
# SIP stack: "sippy" (signalling only — the classifier gets no audio) or
# "pjsua2" (call control + media, the only path where audio reaches the
# classifier). Opt-in while the PJSUA2 engine is proven against the lab;
# see docs/architecture.md → "Media plane: why PJSUA2 places the call".
sip_engine: str = "sippy"
# Notifications
notify_sms_number: str = ""
# Sub-configs
sip_trunk: SIPTrunkSettings = Field(default_factory=SIPTrunkSettings)
gateway_sip: GatewaySIPSettings = Field(default_factory=GatewaySIPSettings)
speaches: SpeachesSettings = Field(default_factory=SpeachesSettings)
classifier: ClassifierSettings = Field(default_factory=ClassifierSettings)
llm: LLMSettings = Field(default_factory=LLMSettings)
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
tts: TTSSettings = Field(default_factory=TTSSettings)
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
# Singleton
_settings: Settings | None = None
def get_settings() -> Settings:
"""Get cached application settings."""
global _settings
if _settings is None:
_settings = Settings()
return _settings