second-brain/src/second_brain/config.py
Travis Herbranson ceeae77e7d postgres migration: schema, models, embeddings, alembic
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.
2026-05-24 22:46:48 -04:00

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"]