Run extraction under the Max OAuth subscription via `claude -p` instead of the per-token Anthropic API. The new src/second_brain/llm/claude_cli.py spawns the CLI in a hermetic tempdir so the host project's CLAUDE.md, hooks, MCP config, and settings don't leak into the prompt. Uses --json-schema with LLMExtraction.model_json_schema() so the CLI guarantees valid structured output — replaces the brittle markdown-fence stripping in the old engine. The Anthropic SDK is preserved as an optional "api" backend selectable via config. While in here, fix a handful of blockers that the smoke test surfaced: - scheduler filtered ANALYZED instead of TRANSCRIBED, so it never actually advanced any sources - process command read sources in a closed session, raising DetachedInstanceError before any work happened - config.prompts_dir walked one parent too many, resolving outside the project and forcing the fallback prompt for every domain - compiler called git rev-parse against a vault that was never git-init'd; now auto-inits with an empty seed commit and skips empty commits cleanly - datetime.utcnow() deprecated in 3.12+ — single utcnow() helper in models.py keeps naive UTC semantics so no DB migration is needed - sess.query(...).get() deprecated in SA 2.x → sess.get(...) - dead `import anthropic` removed from compiler Smoke test (article → process → accept → compile) succeeds end-to-end with ANTHROPIC_API_KEY unset. a-review run saved under reviews/. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
154 lines
4.6 KiB
Python
154 lines
4.6 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.
|
|
"""
|
|
|
|
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] = {
|
|
"db_path": "~/.local/share/second-brain/second_brain.db",
|
|
"vault_path": "~/Documents/second-brain-vault",
|
|
"media_dir": "~/.local/share/second-brain/media",
|
|
"subtitles_dir": "~/.local/share/second-brain/subtitles",
|
|
"whisper_model": "small",
|
|
"domains": ["development", "content", "business", "homelab"],
|
|
"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,
|
|
},
|
|
"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"]
|
|
|
|
|
|
class Config:
|
|
"""Holds resolved configuration values."""
|
|
|
|
def __init__(self, raw: dict[str, Any]) -> None:
|
|
self._raw = raw
|
|
|
|
# --- paths ---
|
|
|
|
@property
|
|
def db_path(self) -> Path:
|
|
return Path(self._raw.get("db_path", _DEFAULTS["db_path"])).expanduser()
|
|
|
|
@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()
|
|
|
|
# --- extraction ---
|
|
|
|
@property
|
|
def whisper_model(self) -> str:
|
|
return self._raw.get("whisper_model", _DEFAULTS["whisper_model"])
|
|
|
|
@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")
|
|
|
|
# --- extractor ---
|
|
|
|
@property
|
|
def extractor(self) -> dict[str, Any]:
|
|
return {**_DEFAULTS["extractor"], **self._raw.get("extractor", {})}
|
|
|
|
# --- scheduler ---
|
|
|
|
@property
|
|
def scheduler(self) -> dict[str, Any]:
|
|
return {**_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.db_path.parent, 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
|
|
|
|
|
|
__all__ = ["Config", "DOMAINS", "load_config"]
|