3d72fd28ec
Task 5-1-01: voice/ — VoiceProvider protocol (transcribe/synthesize, D-030 mirroring
LLMProvider), deterministic MockVoiceProvider (scripted STT queue, canned tone-WAV
TTS chunks, failure modes incl. empty audio), browser fallback descriptor (client
native SR/TTS), factory (mock default; browser; openai-audio REJECTED as a v0.4 seam
per CUT-1/G-7), config key AI_VOICE_PROVIDER + .env.example note. voice/ imports no
agents/api (AST-tested).
Task 5-1-03: DefenseStore (4th D-027 store; first FK family) — DefenseRecord +
DefenseTurn (ordered by (defense_id, seq)); start/append_turn/finalize/get/
list_for_learner; PRAGMA foreign_keys=ON for Postgres parity; integrity signals JSON
(A-109); store owns the finished transition.
34 voice tests green; ruff clean.
---ci---
phase: 5
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-006], partial: []}
---/ci---
88 lines
4.0 KiB
Python
88 lines
4.0 KiB
Python
"""Service settings — pydantic-settings, env prefix AI_, .env support."""
|
|
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from pydantic import field_validator
|
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
|
|
# apps/ai-service/ (sandbox dir default is relative to the app, not the CWD)
|
|
_SERVICE_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore")
|
|
|
|
port: int = 8420
|
|
provider: str = "mock"
|
|
model: str = "gemma4:31b"
|
|
|
|
ollama_cloud_base_url: str = "https://ollama.com/v1"
|
|
ollama_cloud_api_key: str = "" # SecretStr adds friction here; never logged, never echoed
|
|
local_base_url: str = "http://localhost:11434/v1"
|
|
|
|
# "auto" sends response_format and degrades on 400; "off" never sends it
|
|
json_mode: str = "auto"
|
|
|
|
# v0.3 sandbox fabric (REQ-3-001): root holding per-sandbox workdirs.
|
|
# Relative paths resolve against the app dir (apps/ai-service/), not the CWD.
|
|
sandbox_dir: Path = _SERVICE_ROOT / "sandboxes"
|
|
|
|
# D-032: single-box capacity, no queue — pool full → API maps to 503.
|
|
sandbox_max_concurrent: int = 5
|
|
|
|
# Wall-clock ceiling per sandbox; the manager's async reaper destroys
|
|
# sandboxes idle past this age (same timer runs the G-2 workdir sweep).
|
|
sandbox_timeout_s: float = 900.0
|
|
|
|
# G-2: soft disk cap per sandbox workdir, enforced best-effort by the
|
|
# manager sweep (NOT kernel-enforced — no cgroup delegation/sudo here).
|
|
sandbox_max_workdir_mb: int = 512
|
|
|
|
# G-5 abuse control (NOT auth — KYC/auth is deferred; these keep the
|
|
# single-box pilot from melting down before identity lands):
|
|
#
|
|
# Server-side learner allowlist. Env form is a COMMA-SEPARATED string
|
|
# (e.g. AI_LEARNER_ALLOWLIST="pilot-learner,learner-2"); NoDecode skips
|
|
# pydantic-settings' JSON decoding of complex types and the validator
|
|
# below splits/strips/drops empties. Default: the single mock pilot id.
|
|
learner_allowlist: Annotated[list[str], NoDecode] = ["pilot-learner"]
|
|
|
|
# Max ACTIVE sandboxes per learner → API maps excess to 429.
|
|
sandbox_max_per_learner: int = 1
|
|
|
|
# Global create-rate ceiling (creates per rolling 60s window, shared
|
|
# across learners) → API maps excess to 429. In-memory, process-local.
|
|
sandbox_creates_per_min: int = 10
|
|
|
|
@field_validator("learner_allowlist", mode="before")
|
|
@classmethod
|
|
def _split_allowlist_csv(cls, value: object) -> object:
|
|
if isinstance(value, str):
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
return value
|
|
|
|
# D-027: SQLite path for telemetry/grades/variants/defenses stores.
|
|
db_path: Path = _SERVICE_ROOT / "ai_service" / "data" / "nextcraft.db"
|
|
|
|
# G-3 flood control (NOT backpressure-by-silence): max events ingested per
|
|
# (learner_id, task_id) trace before the WS endpoint closes the connection
|
|
# with 1008 and marks the trace INCOMPLETE_FLOODED. Drop-oldest is
|
|
# FORBIDDEN — it corrupts grading input (GRILL G-3).
|
|
telemetry_max_events_per_task: int = 50000
|
|
|
|
# Sandbox telemetry wiring (REQ-3-003): loopback host the in-sandbox capture
|
|
# agent dials to reach this service's WS ingest (the agent joins the sandbox
|
|
# mount ns but NOT the net ns — exec namespaces are offline, so the agent
|
|
# shares the host network and reaches the app over loopback). Port reuses
|
|
# `port` (A-004); only the host is configurable — never a second port.
|
|
telemetry_ingest_host: str = "127.0.0.1"
|
|
|
|
# Voice provider selection (REQ-3-006, D-030): 'mock' (default — the
|
|
# no-key path is first-class; tests never call a real voice API) or
|
|
# 'browser' (browser-native SpeechRecognition/speechSynthesis fallback;
|
|
# the descriptor tells the web client). The real server STT/TTS
|
|
# ('openai-audio') is a v0.4 seam (GRILL CUT-1 / G-7) — AI_VOICE_BASE_URL
|
|
# and AI_VOICE_API_KEY are documented in .env.example for that future.
|
|
voice_provider: str = "mock"
|