Swap the SQLite backing store for petalbrain Postgres + pgvector, modeled on vault-mcp. All second-brain relational tables now live in the `second_brain` schema (owned by the lovebug role); embeddings are written to the shared public.embeddings table. Locked design decisions (per Travis): - DB: existing petalbrain Postgres, second_brain schema, lovebug role. - Connection: containerized homelab-postgres:5432, plain psycopg_pool (min=1/max=10), no PgBouncer. - ORM stays SQLAlchemy; int autoincrement PKs + naive UTC DateTime. - Embeddings: reuse shared public.embeddings keyed by (source_schema='second_brain', source_table='extractions', source_id, model='nomic-embed-text'). Summaries only for this round. - Pipeline: chunk_text → Ollama nomic-embed-text → delete-before-insert upsert, with graceful degradation (no DB / no Ollama → log + skip). - Alembic stands up second-brain's own schema; public.embeddings stays out-of-band. - File-based wiki compiler is unchanged. No SQLite data import — starting clean. This commit is the scaffolding only; `alembic upgrade head` and a smoke test of the embedding path are the next checkpoint.
192 lines
6.1 KiB
Python
192 lines
6.1 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",
|
|
"whisper_model": "small",
|
|
"domains": ["development", "content", "business", "homelab"],
|
|
"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()
|
|
|
|
# --- 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")
|
|
|
|
# --- 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"]
|