Files
pi-agent-config/scenarios/curator/backend/curator/config.py
T
Kai 88b06d782f feat(curator): vendor the application backend as the scenario's tracked source
The curator Python backend (package, tests, systemd units, config templates, scripts) now lives under scenarios/curator/backend and is the single source of truth; the live checkout at the workspace path is a runtime copy. Exported from the app repo's tracked tree via git archive (no history, .pi/venv/caches excluded). 149 unit tests pass from the new location.

profile.toml backend is now repo-relative (scenarios/curator/backend); verify-generated.sh resolves a relative backend against REPO_ROOT. verify-no-secrets ASSIGN heuristic now requires value entropy so vendored kwargs like token=extraction_token no longer false-positive. README documents the backend/ layout and the operator-owned app rollout step.
2026-08-30 18:49:10 -07:00

173 lines
8.5 KiB
Python

from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def _path(name: str, default: Path) -> Path:
return Path(os.path.expanduser(os.getenv(name, str(default)))).resolve()
def _strip_provider(model: str, provider: str) -> str:
prefix = f"{provider}/"
return model[len(prefix):] if model.startswith(prefix) else model
@dataclass(frozen=True)
class Settings:
data_root: Path
library_root: Path
staging_root: Path
backup_root: Path
database: Path
host: str
port: int
max_upload_bytes: int
telegram_token: str | None
telegram_allowed_users: frozenset[int]
pi_bin: str
pi_workspace: Path
pi_session_dir: Path
pi_model: str
pi_thinking: str
pi_fallback_model: str
pi_timeout_seconds: int
wechat_article_base_url: str
radarr_url: str = ""
radarr_api_key: str = ""
radarr_4k_url: str = ""
radarr_4k_api_key: str = ""
sonarr_url: str = ""
sonarr_api_key: str = ""
sonarr_4k_url: str = ""
sonarr_4k_api_key: str = ""
radarr_root_folder: str = "/mnt/truenas/multimedia/movies"
radarr_quality_profile_id: int = 4
sonarr_root_folder: str = "/mnt/truenas/multimedia/tv"
sonarr_quality_profile_id: int = 4
radarr_4k_root_folder: str = "/mnt/unRaid/movie4k"
radarr_4k_quality_profile_id: int = 5
sonarr_4k_root_folder: str = "/mnt/unRaid/tv4k"
sonarr_4k_quality_profile_id: int = 7
plex_url: str = ""
plex_token: str = ""
plex_music_section_id: str = ""
catalog_cache_ttl_seconds: int = 60
review_cache_ttl_seconds: int = 21600
tavily_api_key: str = ""
# Access token for the web UI. Empty means the UI is unauthenticated, which
# is only acceptable when CURATOR_HOST is loopback. Set CURATOR_WEB_TOKEN to
# require a login token (browser cookie or Bearer header) on every route
# except /api/health. A dedicated agent holds tracker credentials and library
# write paths, so leaving this empty and the port on 0.0.0.0 is the one thing
# that turns a LAN-resident web UI into a write primitive (plan P2-6).
web_token: str = ""
pi_provider: str = "zenmux"
# Thinking level for supplied-evidence JSON synthesis. Conversation and
# source extraction use the full skill-driven thinking level below.
pi_thinking_structured: str = "medium"
# One deadline per skill-driven turn, enforced with the RPC abort command.
pi_turn_deadline_seconds: int = 180
# Review synthesis is toolless and normally finishes quickly, so a stall must
# not consume the longer conversation/extraction budget before fallback.
pi_structured_turn_deadline_seconds: int = 60
pi_startup_timeout_seconds: int = 60
# Stop a conversation's pi process after this long with no message. Each is
# 100-200 MB and several tasks; without a TTL the set of live processes grows
# with the number of distinct chats and never shrinks.
pi_idle_ttl_seconds: int = 1800
tavily_search_url: str = "https://api.tavily.com/search"
book_web_review_max_results: int = 6
zlib_search_url_template: str = "https://zlib.li/s/{query}"
@property
def pi_model_name(self) -> str:
"""The model without its provider prefix.
The configured value carries the provider ("zenmux/openai/...") because
pi accepts that form on --model. The RPC launcher passes --provider
separately, and giving it a prefixed model too produces a model id that
no provider recognises.
"""
return _strip_provider(self.pi_model, self.pi_provider)
@property
def pi_fallback_model_name(self) -> str:
return _strip_provider(self.pi_fallback_model, self.pi_provider)
@classmethod
def from_env(cls) -> "Settings":
data = _path("CURATOR_DATA_ROOT", Path("~/.local/share/curator").expanduser())
allowed = frozenset(
int(item.strip())
for item in os.getenv("CURATOR_TELEGRAM_ALLOWED_USERS", "").split(",")
if item.strip()
)
return cls(
data_root=data,
library_root=_path("CURATOR_LIBRARY_ROOT", data / "library"),
staging_root=_path("CURATOR_STAGING_ROOT", data / "staging" / "books"),
backup_root=_path("CURATOR_BACKUP_ROOT", data / "backup"),
database=_path("CURATOR_DATABASE", data / "curator.sqlite3"),
host=os.getenv("CURATOR_HOST", "0.0.0.0"),
port=int(os.getenv("CURATOR_PORT", "8766")),
max_upload_bytes=int(os.getenv("CURATOR_MAX_UPLOAD_BYTES", str(256 * 1024 * 1024))),
telegram_token=os.getenv("CURATOR_TELEGRAM_BOT_TOKEN") or None,
telegram_allowed_users=allowed,
pi_bin=os.getenv("CURATOR_PI_BIN", "/home/claw/.npm-global/bin/pi"),
pi_workspace=_path("CURATOR_PI_WORKSPACE", Path("~/pi-workspaces/curator").expanduser()),
pi_session_dir=_path("CURATOR_PI_SESSION_DIR", Path("~/.local/share/pi-curator/sessions").expanduser()),
pi_model=os.getenv("CURATOR_PI_MODEL", "zenmux/openai/gpt-5.6-luna"),
pi_thinking=os.getenv("CURATOR_PI_THINKING", "high"),
pi_fallback_model=os.getenv("CURATOR_PI_FALLBACK_MODEL", "zenmux/x-ai/grok-4.6"),
pi_timeout_seconds=int(os.getenv("CURATOR_PI_TIMEOUT_SECONDS", "120")),
web_token=os.getenv("CURATOR_WEB_TOKEN", ""),
pi_provider=os.getenv("CURATOR_PI_PROVIDER", "zenmux"),
pi_thinking_structured=os.getenv("CURATOR_PI_THINKING_STRUCTURED", "medium"),
pi_turn_deadline_seconds=int(os.getenv("CURATOR_PI_TURN_DEADLINE_SECONDS", "180")),
pi_structured_turn_deadline_seconds=int(os.getenv("CURATOR_PI_STRUCTURED_TURN_DEADLINE_SECONDS", "60")),
pi_startup_timeout_seconds=int(os.getenv("CURATOR_PI_STARTUP_TIMEOUT_SECONDS", "60")),
pi_idle_ttl_seconds=int(os.getenv("CURATOR_PI_IDLE_TTL_SECONDS", "1800")),
wechat_article_base_url=os.getenv("CURATOR_WECHAT_ARTICLE_BASE_URL", "http://192.168.50.145:8091").rstrip("/"),
radarr_url=os.getenv("CURATOR_RADARR_URL", "").rstrip("/"),
radarr_api_key=os.getenv("CURATOR_RADARR_API_KEY", ""),
radarr_4k_url=os.getenv("CURATOR_RADARR_4K_URL", "").rstrip("/"),
radarr_4k_api_key=os.getenv("CURATOR_RADARR_4K_API_KEY", ""),
sonarr_url=os.getenv("CURATOR_SONARR_URL", "").rstrip("/"),
sonarr_api_key=os.getenv("CURATOR_SONARR_API_KEY", ""),
sonarr_4k_url=os.getenv("CURATOR_SONARR_4K_URL", "").rstrip("/"),
sonarr_4k_api_key=os.getenv("CURATOR_SONARR_4K_API_KEY", ""),
radarr_root_folder=os.getenv("CURATOR_RADARR_ROOT_FOLDER", "/mnt/truenas/multimedia/movies"),
radarr_quality_profile_id=int(os.getenv("CURATOR_RADARR_QUALITY_PROFILE_ID", "4")),
sonarr_root_folder=os.getenv("CURATOR_SONARR_ROOT_FOLDER", "/mnt/truenas/multimedia/tv"),
sonarr_quality_profile_id=int(os.getenv("CURATOR_SONARR_QUALITY_PROFILE_ID", "4")),
radarr_4k_root_folder=os.getenv("CURATOR_RADARR_4K_ROOT_FOLDER", "/mnt/unRaid/movie4k"),
radarr_4k_quality_profile_id=int(os.getenv("CURATOR_RADARR_4K_QUALITY_PROFILE_ID", "5")),
sonarr_4k_root_folder=os.getenv("CURATOR_SONARR_4K_ROOT_FOLDER", "/mnt/unRaid/tv4k"),
sonarr_4k_quality_profile_id=int(os.getenv("CURATOR_SONARR_4K_QUALITY_PROFILE_ID", "7")),
plex_url=os.getenv("CURATOR_PLEX_URL", "").rstrip("/"),
plex_token=os.getenv("CURATOR_PLEX_TOKEN", ""),
plex_music_section_id=os.getenv("CURATOR_PLEX_MUSIC_SECTION_ID", ""),
catalog_cache_ttl_seconds=int(os.getenv("CURATOR_CATALOG_CACHE_TTL_SECONDS", "60")),
review_cache_ttl_seconds=int(os.getenv("CURATOR_REVIEW_CACHE_TTL_SECONDS", "21600")),
tavily_api_key=os.getenv("CURATOR_TAVILY_API_KEY", os.getenv("TAVILY_API_KEY", "")),
tavily_search_url=os.getenv("CURATOR_TAVILY_SEARCH_URL", "https://api.tavily.com/search"),
book_web_review_max_results=int(os.getenv("CURATOR_BOOK_WEB_REVIEW_MAX_RESULTS", "6")),
zlib_search_url_template=os.getenv("CURATOR_ZLIB_SEARCH_URL_TEMPLATE", "https://zlib.li/s/{query}"),
)
def prepare(self) -> None:
for path in (
self.data_root,
self.library_root,
self.staging_root,
self.backup_root / "database" / "daily",
self.data_root / "covers",
self.pi_workspace,
self.pi_session_dir,
):
path.mkdir(parents=True, exist_ok=True)