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.
173 lines
8.5 KiB
Python
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)
|