second-brain/src/second_brain/config.py
Travis Herbranson 33ae5a6f7d pipeline split: tower transcribe worker + claim queue + faster-whisper
Splits pull+transcribe (now tower-side, eager) from extract+embed
(stays on the dev scheduler). Three machine-coordination pieces land
together because they reference each other:

- v3 migration adds sources.claimed_by + claimed_at — observability +
  stale-claim recovery columns. The actual race-safety primitive is
  `SELECT ... FOR UPDATE SKIP LOCKED` in the new claim helper, so two
  machines can poll the queue without doubling work.

- src/second_brain/claim.py owns the claim dance (claim_next_source,
  release_claim, reap_stale_claims). Both stage gates filter by
  source_type so the tower never grabs articles and the dev side never
  grabs videos.

- src/second_brain/transcribe.py wraps faster-whisper (lazy-imported so
  it stays out of the dev install). resolve_settings() reads env >
  [whisper] block > defaults, falling back to int8 on cpu / float16 on
  cuda when compute_type is unspecified. Default model large-v3.

- src/second_brain/scheduler/transcribe_worker.py is the long-running
  poll loop. Reads pipeline_settings every iteration so the dashboard's
  enable/window/max-items/max-video-length take effect within one
  cycle. Reaps stale claims at startup. SIGTERM-clean. DB-unreachable
  backs off with a log line; never crash-loops.

- adapters/youtube.py drops the torch-whisper transcribe path; pull
  stays. Removes openai-whisper from the default deps and gates
  faster-whisper behind a new `tower` extra (uv sync --extra tower).

- main.py: new `second-brain transcribe-worker` (--once for ad-hoc).
  `process` now article-only on the pull side but still picks up
  TRANSCRIBED of any source_type for the extract step.

Live-verified: migration applies clean, transcribe-worker --once
honors transcription_enabled=false gate.
2026-05-25 10:11:37 -04:00

211 lines
6.9 KiB
Python

"""
Configuration loading for second-brain.
Settings are read from config/settings.toml (or a path set via SECOND_BRAIN_CONFIG).
All values have sensible defaults so the system works out of the box.
The relational store is Postgres (petalbrain DB, `second_brain` schema). Connection
details are resolved by `second_brain.database` — see that module for the URL
resolution order. The vault is still a file-based Obsidian directory; this
project does NOT dual-write to petalbrain.wiki.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
try:
import tomllib # Python 3.11+
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-reuse-impl]
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
_DEFAULTS: dict[str, Any] = {
"vault_path": "~/Documents/second-brain-vault",
"media_dir": "~/.local/share/second-brain/media",
"subtitles_dir": "~/.local/share/second-brain/subtitles",
"domains": ["development", "content", "business", "homelab"],
"whisper": {
# Tower default: faster-whisper large-v3 on cuda/float16. The
# transcribe helper picks int8 automatically if the device is cpu.
"whisper_model": "large-v3",
"whisper_device": "cuda",
"whisper_compute_type": "",
},
"database": {
# Empty by default — the runtime expects SECOND_BRAIN_DATABASE_URL
# (or HERBYLAB_DATABASE_URL) in the environment. Fill this only if
# you prefer keeping the URL inside settings.toml.
"url": "",
},
"extractor": {
# "cli" runs `claude -p` under the Max OAuth subscription (default).
# "api" uses the Anthropic SDK and requires ANTHROPIC_API_KEY.
"backend": "cli",
"model": "claude-sonnet-4-6",
"cli_binary": "claude",
"timeout_sec": 600,
},
"embeddings": {
# Best-effort embedding of extraction summaries into public.embeddings.
# If disabled, or if Ollama / the DB is unreachable, the pipeline
# logs a warning and keeps going — file/relational data stays the
# source of truth.
"enabled": True,
# Default targets the `ollama` service on the homelab docker network
# (same convention vault-mcp uses). For host-side runs, set
# OLLAMA_URL=http://127.0.0.1:11434 in the env instead.
"ollama_url": "http://ollama:11434",
"model": "nomic-embed-text",
"embed_field": "summary",
},
"scheduler": {
"window_start": "22:00",
"window_end": "06:00",
# CLI backend: throttle by request rate (no per-token billing).
"max_calls_per_hour": 30,
"min_gap_seconds": 120,
# Legacy API-backend budget; kept for backwards compat.
"max_tokens_per_hour": 100_000,
},
}
DOMAINS = ["development", "content", "business", "homelab"]
def _merge(default: dict, override: dict) -> dict:
"""Shallow merge — override wins per top-level key."""
out = dict(default)
out.update(override or {})
return out
class Config:
"""Holds resolved configuration values."""
def __init__(self, raw: dict[str, Any]) -> None:
self._raw = raw
# --- paths ---
@property
def vault_path(self) -> Path:
return Path(self._raw.get("vault_path", _DEFAULTS["vault_path"])).expanduser()
@property
def media_dir(self) -> Path:
return Path(self._raw.get("media_dir", _DEFAULTS["media_dir"])).expanduser()
@property
def subtitles_dir(self) -> Path:
return Path(
self._raw.get("subtitles_dir", _DEFAULTS["subtitles_dir"])
).expanduser()
# --- transcription ---
@property
def whisper(self) -> dict[str, Any]:
"""Merged [whisper] block. Env vars in `transcribe.resolve_settings`
still win over these for the tower worker."""
return _merge(_DEFAULTS["whisper"], self._raw.get("whisper", {}))
@property
def whisper_model(self) -> str:
"""Backwards-compat shortcut — older callers (and settings.toml
in the wild) reference whisper_model at the top level."""
return (
self._raw.get("whisper_model")
or self.whisper.get("whisper_model", "large-v3")
)
# --- extraction ---
@property
def domains(self) -> list[str]:
return self._raw.get("domains", _DEFAULTS["domains"])
@property
def anthropic_api_key(self) -> str | None:
return os.getenv("ANTHROPIC_API_KEY")
# --- structured sections ---
@property
def database(self) -> dict[str, Any]:
return _merge(_DEFAULTS["database"], self._raw.get("database", {}))
@property
def extractor(self) -> dict[str, Any]:
return _merge(_DEFAULTS["extractor"], self._raw.get("extractor", {}))
@property
def embeddings(self) -> dict[str, Any]:
return _merge(_DEFAULTS["embeddings"], self._raw.get("embeddings", {}))
@property
def scheduler(self) -> dict[str, Any]:
return _merge(_DEFAULTS["scheduler"], self._raw.get("scheduler", {}))
# --- prompts dir ---
@property
def prompts_dir(self) -> Path:
"""Directory containing per-domain prompt templates."""
# src/second_brain/config.py → ../../.. = project root
here = Path(__file__).resolve().parent.parent.parent
return here / "prompts"
def ensure_dirs(self) -> None:
"""Create runtime directories if they don't exist."""
for d in (self.media_dir, self.subtitles_dir):
d.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# Loader
# ---------------------------------------------------------------------------
_config_instance: Config | None = None
def load_config(path: Path | None = None) -> Config:
"""Load (and cache) configuration from a TOML file."""
global _config_instance
if _config_instance is not None:
return _config_instance
if path is None:
env_path = os.getenv("SECOND_BRAIN_CONFIG")
if env_path:
path = Path(env_path)
else:
# Walk up from CWD looking for config/settings.toml
cwd = Path.cwd()
for candidate in [cwd / "config" / "settings.toml", cwd / "settings.toml"]:
if candidate.exists():
path = candidate
break
raw: dict[str, Any] = {}
if path and path.exists():
with open(path, "rb") as fh:
raw = tomllib.load(fh)
_config_instance = Config(raw)
return _config_instance
def reset_config_singleton() -> None:
"""Test hook — clear the cached Config so a re-read picks up new settings."""
global _config_instance
_config_instance = None
__all__ = ["Config", "DOMAINS", "load_config", "reset_config_singleton"]