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.
This commit is contained in:
Travis Herbranson 2026-05-24 22:46:48 -04:00
parent 4696d7f015
commit ceeae77e7d
13 changed files with 867 additions and 47 deletions

52
alembic.ini Normal file
View File

@ -0,0 +1,52 @@
# Alembic config for the second_brain schema migrations.
#
# Mirrors vault-mcp/alembic.ini. The sqlalchemy.url placeholder lets the
# `alembic` CLI parse this file without env vars at import time; env.py
# overrides it from SECOND_BRAIN_DATABASE_URL / HERBYLAB_DATABASE_URL at
# runtime.
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url = postgresql+psycopg://placeholder:placeholder@localhost/placeholder
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

108
alembic/env.py Normal file
View File

@ -0,0 +1,108 @@
"""Alembic environment for second-brain — second_brain schema.
Mirrors vault-mcp/alembic/env.py. Reads SECOND_BRAIN_DATABASE_URL (with
HERBYLAB_DATABASE_URL as a fallback) so the same migration tree works
across local dev, CI, and production.
The connection bootstraps the `second_brain` schema and pins alembic's
own version table to that schema; migration scripts themselves set
search_path before issuing unqualified DDL.
The bootstrap-then-commit pattern below is the fix for the "autobegin
trap": running any further statement before context.begin_transaction()
would autobegin a new transaction, causing alembic's begin_transaction()
to nest as a savepoint that silently rolls back when the connection
closes. Do not regress this.
"""
from __future__ import annotations
import os
from logging.config import fileConfig
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool
from alembic import context
# Make the project's `src/` importable so `second_brain.models` resolves
# without a `pip install -e .`.
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_PROJECT_ROOT / "src"))
from second_brain.models import Base, SCHEMA # noqa: E402
load_dotenv()
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
def _resolve_db_url() -> str | None:
"""Pick the first usable URL out of the two homelab conventions."""
for var in ("SECOND_BRAIN_DATABASE_URL", "HERBYLAB_DATABASE_URL"):
v = os.environ.get(var)
if v:
return v
return None
_db_url = _resolve_db_url()
if _db_url:
config.set_main_option("sqlalchemy.url", _db_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_schema=SCHEMA,
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
# Bootstrap the schema before alembic looks for alembic_version.
# Commit explicitly so the schema persists; otherwise the implicit
# transaction is rolled back when the connection closes. Avoid
# running any further statement here — it would autobegin a new
# transaction and cause alembic's `begin_transaction()` to nest as
# a savepoint, which silently rolls back when the connection closes.
# The migration script sets search_path itself.
connection.exec_driver_sql(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
connection.commit()
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema=SCHEMA,
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

27
alembic/script.py.mako Normal file
View File

@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,117 @@
"""v1 second_brain: sources, extractions, wiki_pages
Revision ID: 4a1e2f6c9d10
Revises:
Create Date: 2026-05-24 00:00:00.000000
Emits the v1 second_brain schema: the relational backbone of the
extraction pipeline. Enum types (`source_type`, `source_status`) live
inside the schema so they don't collide with anything else in the
petalbrain cluster.
env.py creates the second_brain schema and pins alembic_version to it
before this migration runs. Below we re-issue CREATE SCHEMA IF NOT
EXISTS defensively and SET search_path so unqualified names resolve.
Vector indexing lives in public.embeddings managed out-of-band, not
by this migration tree.
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "4a1e2f6c9d10"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create the v1 second_brain schema."""
op.execute("CREATE SCHEMA IF NOT EXISTS second_brain")
op.execute("SET search_path TO second_brain")
op.execute(
"CREATE TYPE source_type AS ENUM ('video', 'article')"
)
op.execute(
"""
CREATE TYPE source_status AS ENUM (
'pending', 'pulled', 'transcribed', 'analyzed',
'accepted', 'published', 'failed'
)
"""
)
op.execute(
"""
CREATE TABLE sources (
id SERIAL PRIMARY KEY,
url VARCHAR(2000) NOT NULL UNIQUE,
title VARCHAR(500),
source_type source_type NOT NULL DEFAULT 'video',
domain VARCHAR(50) NOT NULL DEFAULT 'development',
focus TEXT,
status source_status NOT NULL DEFAULT 'pending',
media_path VARCHAR(1000),
transcript_path VARCHAR(1000),
transcript_text TEXT,
published_at TIMESTAMP,
duration_seconds INTEGER,
author VARCHAR(200),
ingested_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMP,
error_message TEXT
)
"""
)
op.execute("CREATE INDEX idx_sources_status ON sources(status)")
op.execute("CREATE INDEX idx_sources_domain ON sources(domain)")
op.execute(
"""
CREATE TABLE extractions (
id SERIAL PRIMARY KEY,
source_id INTEGER NOT NULL UNIQUE
REFERENCES sources(id) ON DELETE CASCADE,
summary TEXT,
key_points JSON,
entities JSON,
claims JSON,
open_questions JSON,
contradictions JSON,
raw_response TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
)
"""
)
op.execute(
"""
CREATE TABLE wiki_pages (
id SERIAL PRIMARY KEY,
vault_path VARCHAR(500) NOT NULL UNIQUE,
domain VARCHAR(50) NOT NULL,
title VARCHAR(300) NOT NULL,
git_sha_before VARCHAR(40),
git_sha_after VARCHAR(40),
created_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMP
)
"""
)
def downgrade() -> None:
"""Drop the v1 second_brain tables and enums (schema is left intact)."""
op.execute("SET search_path TO second_brain")
op.execute("DROP TABLE IF EXISTS wiki_pages")
op.execute("DROP TABLE IF EXISTS extractions")
op.execute("DROP TABLE IF EXISTS sources")
op.execute("DROP TYPE IF EXISTS source_status")
op.execute("DROP TYPE IF EXISTS source_type")

View File

@ -2,10 +2,9 @@
# Copy this file and edit paths to match your setup. # Copy this file and edit paths to match your setup.
# Override the config file path with SECOND_BRAIN_CONFIG env var. # Override the config file path with SECOND_BRAIN_CONFIG env var.
# Path to the SQLite database # Path to your Obsidian vault (git-managed directory). The wiki compiler
db_path = "~/.local/share/second-brain/second_brain.db" # writes markdown into this directory and commits inside it. We do NOT
# dual-write into petalbrain.wiki — the file-based vault stays canonical.
# Path to your Obsidian vault (git-managed directory)
vault_path = "~/Documents/second-brain-vault" vault_path = "~/Documents/second-brain-vault"
# Directory where downloaded media files are stored # Directory where downloaded media files are stored
@ -21,6 +20,21 @@ whisper_model = "small"
# Active knowledge domains # Active knowledge domains
domains = ["development", "content", "business", "homelab"] domains = ["development", "content", "business", "homelab"]
[database]
# Postgres + pgvector lives on the petalbrain instance.
#
# Preferred: leave `url` empty and export SECOND_BRAIN_DATABASE_URL (or the
# shared HERBYLAB_DATABASE_URL) in the environment so the password never
# lands in this checked-in file.
#
# Containerised second-brain runs should target homelab-postgres on the
# homelab docker network:
# postgresql+psycopg://lovebug:<password>@homelab-postgres:5432/petalbrain
#
# Host-side dev runs can point at the published port instead:
# postgresql+psycopg://lovebug:<password>@127.0.0.1:5433/petalbrain
url = ""
[extractor] [extractor]
# "cli" → runs `claude -p` under your Max OAuth (no per-token billing). # "cli" → runs `claude -p` under your Max OAuth (no per-token billing).
# "api" → uses Anthropic SDK, requires ANTHROPIC_API_KEY. # "api" → uses Anthropic SDK, requires ANTHROPIC_API_KEY.
@ -29,6 +43,17 @@ model = "claude-sonnet-4-6"
cli_binary = "claude" cli_binary = "claude"
timeout_sec = 600 timeout_sec = 600
[embeddings]
# Best-effort: extraction summaries are chunked (512 tokens / 64 overlap)
# and embedded via Ollama's nomic-embed-text (768-d) into the shared
# public.embeddings table. Failures (no DB / no Ollama) log a warning and
# the pipeline keeps moving — the relational + file vault stay canonical.
enabled = true
# Containerized default; for host-side runs set OLLAMA_URL=http://127.0.0.1:11434
ollama_url = "http://ollama:11434"
model = "nomic-embed-text"
embed_field = "summary"
[scheduler] [scheduler]
# Time window for overnight processing (24h format, may cross midnight) # Time window for overnight processing (24h format, may cross midnight)
window_start = "22:00" window_start = "22:00"

View File

@ -0,0 +1,69 @@
# second-brain runtime configuration
# Copy this file and edit paths to match your setup.
# Override the config file path with SECOND_BRAIN_CONFIG env var.
# Path to your Obsidian vault (git-managed directory). The wiki compiler
# writes markdown into this directory and commits inside it. We do NOT
# dual-write into petalbrain.wiki — the file-based vault stays canonical.
vault_path = "~/Documents/second-brain-vault"
# Directory where downloaded media files are stored
media_dir = "~/.local/share/second-brain/media"
# Directory where Whisper SRT transcripts are stored
subtitles_dir = "~/.local/share/second-brain/subtitles"
# Whisper model size: tiny | base | small | medium | large
# small is a good balance of speed and accuracy
whisper_model = "small"
# Active knowledge domains
domains = ["development", "content", "business", "homelab"]
[database]
# Postgres + pgvector lives on the petalbrain instance.
#
# Preferred: leave `url` empty and export SECOND_BRAIN_DATABASE_URL (or the
# shared HERBYLAB_DATABASE_URL) in the environment so the password never
# lands in this checked-in file.
#
# Containerised second-brain runs should target homelab-postgres on the
# homelab docker network:
# postgresql+psycopg://lovebug:<password>@homelab-postgres:5432/petalbrain
#
# Host-side dev runs can point at the published port instead:
# postgresql+psycopg://lovebug:<password>@127.0.0.1:5433/petalbrain
url = ""
[extractor]
# "cli" → runs `claude -p` under your Max OAuth (no per-token billing).
# "api" → uses Anthropic SDK, requires ANTHROPIC_API_KEY.
backend = "cli"
model = "claude-sonnet-4-6"
cli_binary = "claude"
timeout_sec = 600
[embeddings]
# Best-effort: extraction summaries are chunked (512 tokens / 64 overlap)
# and embedded via Ollama's nomic-embed-text (768-d) into the shared
# public.embeddings table. Failures (no DB / no Ollama) log a warning and
# the pipeline keeps moving — the relational + file vault stay canonical.
enabled = true
# Containerized default; for host-side runs set OLLAMA_URL=http://127.0.0.1:11434
ollama_url = "http://ollama:11434"
model = "nomic-embed-text"
embed_field = "summary"
[scheduler]
# Time window for overnight processing (24h format, may cross midnight)
window_start = "22:00"
window_end = "06:00"
# CLI backend: cap calls per rolling hour (Max sub has 5h-window message caps).
max_calls_per_hour = 30
# Minimum seconds between extraction calls (rate-limit smoother)
min_gap_seconds = 120
# Legacy API-backend budget (only enforced when extractor.backend = "api").
max_tokens_per_hour = 100_000

View File

@ -4,10 +4,15 @@ version = "0.1.0"
description = "Personal knowledge system: video/article extraction pipeline + LLM-maintained wiki" description = "Personal knowledge system: video/article extraction pipeline + LLM-maintained wiki"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.13.0",
"click>=8.1.0", "click>=8.1.0",
"embedding-chunking",
"fastapi>=0.115.0", "fastapi>=0.115.0",
"httpx>=0.28.0",
"jinja2>=3.1.0", "jinja2>=3.1.0",
"openai-whisper", "openai-whisper",
"psycopg[binary]>=3.2",
"psycopg-pool>=3.2",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
@ -23,6 +28,11 @@ dependencies = [
# Max OAuth subscription and has no Python-side Anthropic dependency. # Max OAuth subscription and has no Python-side Anthropic dependency.
api = ["anthropic>=0.40.0"] api = ["anthropic>=0.40.0"]
[tool.uv.sources]
# Shared chunker that vault-mcp / ob1-enricher / ob1-reflector also pin.
# Same source so the 512/64 chunking stays in lockstep across services.
embedding-chunking = { git = "https://gitea.plantbasedsoutherner.com/petal-power/embedding-chunking.git", tag = "v0.1.0" }
[project.scripts] [project.scripts]
second-brain = "second_brain.main:cli" second-brain = "second_brain.main:cli"

View File

@ -3,6 +3,11 @@ Configuration loading for second-brain.
Settings are read from config/settings.toml (or a path set via SECOND_BRAIN_CONFIG). 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. 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 from __future__ import annotations
@ -21,12 +26,17 @@ except ModuleNotFoundError:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_DEFAULTS: dict[str, Any] = { _DEFAULTS: dict[str, Any] = {
"db_path": "~/.local/share/second-brain/second_brain.db",
"vault_path": "~/Documents/second-brain-vault", "vault_path": "~/Documents/second-brain-vault",
"media_dir": "~/.local/share/second-brain/media", "media_dir": "~/.local/share/second-brain/media",
"subtitles_dir": "~/.local/share/second-brain/subtitles", "subtitles_dir": "~/.local/share/second-brain/subtitles",
"whisper_model": "small", "whisper_model": "small",
"domains": ["development", "content", "business", "homelab"], "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": { "extractor": {
# "cli" runs `claude -p` under the Max OAuth subscription (default). # "cli" runs `claude -p` under the Max OAuth subscription (default).
# "api" uses the Anthropic SDK and requires ANTHROPIC_API_KEY. # "api" uses the Anthropic SDK and requires ANTHROPIC_API_KEY.
@ -35,6 +45,19 @@ _DEFAULTS: dict[str, Any] = {
"cli_binary": "claude", "cli_binary": "claude",
"timeout_sec": 600, "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": { "scheduler": {
"window_start": "22:00", "window_start": "22:00",
"window_end": "06:00", "window_end": "06:00",
@ -49,6 +72,13 @@ _DEFAULTS: dict[str, Any] = {
DOMAINS = ["development", "content", "business", "homelab"] 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: class Config:
"""Holds resolved configuration values.""" """Holds resolved configuration values."""
@ -57,10 +87,6 @@ class Config:
# --- paths --- # --- paths ---
@property
def db_path(self) -> Path:
return Path(self._raw.get("db_path", _DEFAULTS["db_path"])).expanduser()
@property @property
def vault_path(self) -> Path: def vault_path(self) -> Path:
return Path(self._raw.get("vault_path", _DEFAULTS["vault_path"])).expanduser() return Path(self._raw.get("vault_path", _DEFAULTS["vault_path"])).expanduser()
@ -89,17 +115,23 @@ class Config:
def anthropic_api_key(self) -> str | None: def anthropic_api_key(self) -> str | None:
return os.getenv("ANTHROPIC_API_KEY") return os.getenv("ANTHROPIC_API_KEY")
# --- extractor --- # --- structured sections ---
@property
def database(self) -> dict[str, Any]:
return _merge(_DEFAULTS["database"], self._raw.get("database", {}))
@property @property
def extractor(self) -> dict[str, Any]: def extractor(self) -> dict[str, Any]:
return {**_DEFAULTS["extractor"], **self._raw.get("extractor", {})} return _merge(_DEFAULTS["extractor"], self._raw.get("extractor", {}))
# --- scheduler --- @property
def embeddings(self) -> dict[str, Any]:
return _merge(_DEFAULTS["embeddings"], self._raw.get("embeddings", {}))
@property @property
def scheduler(self) -> dict[str, Any]: def scheduler(self) -> dict[str, Any]:
return {**_DEFAULTS["scheduler"], **self._raw.get("scheduler", {})} return _merge(_DEFAULTS["scheduler"], self._raw.get("scheduler", {}))
# --- prompts dir --- # --- prompts dir ---
@ -112,7 +144,7 @@ class Config:
def ensure_dirs(self) -> None: def ensure_dirs(self) -> None:
"""Create runtime directories if they don't exist.""" """Create runtime directories if they don't exist."""
for d in (self.db_path.parent, self.media_dir, self.subtitles_dir): for d in (self.media_dir, self.subtitles_dir):
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@ -150,4 +182,10 @@ def load_config(path: Path | None = None) -> Config:
return _config_instance return _config_instance
__all__ = ["Config", "DOMAINS", "load_config"] 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"]

View File

@ -1,43 +1,105 @@
""" """
Database engine and session management for second-brain. Database engine and session management for second-brain.
Ported and adapted from xtract/database.py. Postgres + pgvector backend on the petalbrain instance.
Uses a singleton pattern: call get_database() everywhere.
Resolution order for the connection URL:
1. constructor arg passed to `get_database()`
2. env var `SECOND_BRAIN_DATABASE_URL`
3. env var `HERBYLAB_DATABASE_URL` (shared homelab convention)
4. `[database].url` in settings.toml
The URL is expected to use SQLAlchemy's `postgresql+psycopg://` driver
(psycopg v3). Plain `postgresql://` URLs are accepted and rewritten to use
psycopg; this matches the vault-mcp convention where one URL feeds both
Alembic (`+psycopg`) and raw psycopg (no prefix).
Connection mode: containerized (homelab-postgres:5432 on the homelab docker
network). For local-host dev runs, point at 127.0.0.1:5433 instead.
""" """
from __future__ import annotations from __future__ import annotations
import os
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path
from typing import Generator from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from second_brain.models import Base from second_brain.models import SCHEMA, Base
def _normalize_url(url: str) -> str:
"""Ensure the URL uses SQLAlchemy's psycopg-v3 driver."""
if url.startswith("postgresql+psycopg://"):
return url
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+psycopg://", 1)
return url
def _resolve_url(explicit: str | None) -> str:
if explicit:
return _normalize_url(explicit)
for var in ("SECOND_BRAIN_DATABASE_URL", "HERBYLAB_DATABASE_URL"):
v = os.environ.get(var)
if v:
return _normalize_url(v)
# Fall back to settings.toml [database].url
try:
from second_brain.config import load_config
cfg = load_config()
url = cfg.database.get("url")
if url:
return _normalize_url(url)
except Exception:
pass
raise RuntimeError(
"No Postgres connection URL found. Set SECOND_BRAIN_DATABASE_URL "
"(or HERBYLAB_DATABASE_URL), or fill `[database].url` in "
"config/settings.toml."
)
class Database: class Database:
"""Manages the SQLAlchemy engine and session factory.""" """Manages the SQLAlchemy engine and session factory for second-brain."""
def __init__(self, db_path: Path) -> None: def __init__(self, url: str | None = None) -> None:
self.db_path = db_path self.url = _resolve_url(url)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.engine = create_engine( # Pool sizing matches vault-mcp (min=1/max=10). No PgBouncer; a normal
f"sqlite:///{self.db_path}", # QueuePool is fine for the CLI + scheduler + web workers we run.
self.engine: Engine = create_engine(
self.url,
echo=False, echo=False,
connect_args={"check_same_thread": False}, pool_size=int(os.environ.get("SECOND_BRAIN_DB_POOL_MIN", "1")),
max_overflow=int(os.environ.get("SECOND_BRAIN_DB_POOL_MAX", "9")),
pool_pre_ping=True,
future=True,
) )
self.SessionLocal = sessionmaker( self.SessionLocal = sessionmaker(
autocommit=False, autocommit=False,
autoflush=False, autoflush=False,
bind=self.engine, bind=self.engine,
future=True,
) )
def init_db(self) -> None: def init_db(self) -> None:
"""Create all tables (idempotent).""" """Create the schema and tables if missing.
Schema creation is idempotent and only relevant for dev/test setups
where Alembic hasn't been applied yet. In production, prefer
`alembic upgrade head` this method just defensively ensures the
schema exists before `create_all` issues unqualified DDL.
"""
with self.engine.begin() as conn:
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}"))
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
def get_session(self) -> Session: def get_session(self) -> Session:
@ -57,6 +119,9 @@ class Database:
finally: finally:
sess.close() sess.close()
def close(self) -> None:
self.engine.dispose()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Singleton # Singleton
@ -65,17 +130,25 @@ class Database:
_db_instance: Database | None = None _db_instance: Database | None = None
def get_database(db_path: Path | None = None) -> Database: def get_database(url: str | None = None) -> Database:
"""Return the singleton Database, creating and initialising it on first call.""" """Return the singleton Database, creating it lazily on first call.
Schema/tables are *not* auto-created here run `alembic upgrade head`
once during setup. Tests may explicitly call `Database(url).init_db()`
against a scratch database.
"""
global _db_instance global _db_instance
if _db_instance is None: if _db_instance is None:
if db_path is None: _db_instance = Database(url)
from second_brain.config import load_config
db_path = load_config().db_path
_db_instance = Database(db_path)
_db_instance.init_db()
return _db_instance return _db_instance
__all__ = ["Database", "get_database"] def reset_database_singleton() -> None:
"""Test hook — drop the cached singleton so a new URL can take effect."""
global _db_instance
if _db_instance is not None:
_db_instance.close()
_db_instance = None
__all__ = ["Database", "get_database", "reset_database_singleton"]

View File

@ -0,0 +1,251 @@
"""Embed second-brain extractions into the shared public.embeddings table.
This is a near-verbatim copy of the vault-mcp recipe (vault_mcp.core.embeddings),
adapted for our `source_schema='second_brain' / source_table='extractions'`
key tuple and our embed payload (the LLM-generated summary).
Behaviour:
- Reuses the shared `embedding_chunking` library for 512-token chunking with
64-token overlap.
- Calls Ollama's /api/embeddings with `num_ctx=8192` to embed nomic-embed-text
vectors (768-d).
- Writes vector literals as `%s::vector` to avoid taking a hard dependency on
pgvector's Python adapter.
- Delete-before-insert on the (schema, table, source_id, embedding_model)
tuple so a re-embed of an extraction that previously had N chunks but now
has M<N doesn't leave orphans.
Graceful degradation: any failure (no DB URL, no Ollama, DB error, empty
summary) logs at WARNING and returns None. The relational `extractions` row
and the file-based vault remain the source of truth embeddings are an
index, not authoritative state.
"""
from __future__ import annotations
import logging
import os
from typing import Any
import httpx
from embedding_chunking import Chunk, chunk_text
from psycopg_pool import ConnectionPool
logger = logging.getLogger(__name__)
SOURCE_SCHEMA = "second_brain"
SOURCE_TABLE = "extractions"
_pool: ConnectionPool | None = None
# ---------------------------------------------------------------------------
# Pool / URL plumbing
# ---------------------------------------------------------------------------
def _database_url() -> str | None:
"""Resolve a raw psycopg DSN (no SQLAlchemy `+psycopg` prefix)."""
for var in ("SECOND_BRAIN_DATABASE_URL", "HERBYLAB_DATABASE_URL"):
v = os.environ.get(var)
if v:
return v.replace("postgresql+psycopg://", "postgresql://", 1)
# Fallback: pull from settings.toml so a host-side `process` works even
# when the operator forgot to export the env var.
try:
from second_brain.config import load_config
url = load_config().database.get("url")
if url:
return url.replace("postgresql+psycopg://", "postgresql://", 1)
except Exception:
pass
return None
def _get_pool() -> ConnectionPool | None:
"""Lazy-init a small connection pool. Returns None if no DB URL is configured."""
global _pool
if _pool is not None:
return _pool
dsn = _database_url()
if not dsn:
return None
_pool = ConnectionPool(dsn, min_size=1, max_size=4, open=True, timeout=10)
return _pool
def close_pool() -> None:
"""Close the embedding-side pool. Safe to call multiple times."""
global _pool
if _pool is not None:
_pool.close()
_pool = None
# ---------------------------------------------------------------------------
# Embedding model client
# ---------------------------------------------------------------------------
def _ollama_url() -> str:
"""Env var takes precedence over settings.toml so containerized runs can
point at a different Ollama instance without rebuilding the image."""
env = os.environ.get("OLLAMA_URL")
if env:
return env
try:
from second_brain.config import load_config
return load_config().embeddings.get("ollama_url", "http://ollama:11434")
except Exception:
return "http://ollama:11434"
def _embedding_model() -> str:
env = os.environ.get("EMBEDDING_MODEL")
if env:
return env
try:
from second_brain.config import load_config
return load_config().embeddings.get("model", "nomic-embed-text")
except Exception:
return "nomic-embed-text"
def embed_text(text: str) -> list[float]:
"""Call Ollama's embeddings endpoint. Returns the model-native vector."""
resp = httpx.post(
f"{_ollama_url()}/api/embeddings",
json={
"model": _embedding_model(),
"prompt": text,
# nomic-embed-text supports 8192-token context. Ollama defaults
# the runner to 2048, which 500s on long summaries.
"options": {"num_ctx": 8192},
},
timeout=120.0,
)
resp.raise_for_status()
data = resp.json()
vec = data.get("embedding")
if not vec or not isinstance(vec, list):
raise RuntimeError(
f"Ollama returned no embedding for model {_embedding_model()}"
)
return vec
def _vector_literal(vec: list[float]) -> str:
"""pgvector accepts text-literal vectors of form '[v1,v2,...]'."""
return "[" + ",".join(repr(float(v)) for v in vec) + "]"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def embed_extraction(extraction_id: int, summary: str | None) -> int | None:
"""Embed an extraction summary into public.embeddings.
Args:
extraction_id: PK of the row in second_brain.extractions.
summary: the LLM-generated summary string; we embed this and
nothing else for the current round (key_points / claims
are not embedded yet).
Returns the number of chunk rows written, or None on skip/failure.
Best-effort: failures are logged, not raised.
"""
if not summary or not summary.strip():
logger.debug("embed_extraction: skip — extraction %s has no summary", extraction_id)
return None
pool = _get_pool()
if pool is None:
logger.info(
"embed_extraction: skip — no SECOND_BRAIN_DATABASE_URL configured"
)
return None
model = _embedding_model()
try:
chunks: list[Chunk] = chunk_text(summary)
chunk_embeddings: list[tuple[Chunk, list[float]]] = [
(c, embed_text(c.text)) for c in chunks
]
except Exception as exc:
logger.warning(
"embed_extraction: chunk/embed failed for extraction %s: %s",
extraction_id,
exc,
)
return None
try:
with pool.connection() as conn:
n = _upsert_embeddings(
conn, extraction_id, chunk_embeddings, model
)
conn.commit()
logger.info(
"embed_extraction: extraction=%s chunks=%d model=%s",
extraction_id,
n,
model,
)
return n
except Exception as exc:
logger.warning(
"embed_extraction: DB write failed for extraction %s: %s",
extraction_id,
exc,
)
return None
def _upsert_embeddings(
conn: Any,
extraction_id: int,
chunk_embeddings: list[tuple[Chunk, list[float]]],
model: str,
) -> int:
"""Replace all public.embeddings rows for this (extraction, model) pair."""
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM public.embeddings
WHERE source_schema = %s
AND source_table = %s
AND source_id = %s
AND embedding_model = %s
""",
(SOURCE_SCHEMA, SOURCE_TABLE, extraction_id, model),
)
for chunk, vec in chunk_embeddings:
cur.execute(
"""
INSERT INTO public.embeddings
(source_schema, source_table, source_id, chunk_index,
chunk_text, chunk_token_count, embedding, embedding_model)
VALUES (%s, %s, %s, %s, %s, %s, %s::vector, %s)
""",
(
SOURCE_SCHEMA,
SOURCE_TABLE,
extraction_id,
chunk.index,
chunk.text,
chunk.token_count,
_vector_literal(vec),
model,
),
)
return len(chunk_embeddings)
__all__ = ["embed_extraction", "embed_text", "close_pool"]

View File

@ -54,7 +54,7 @@ def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> No
"""Queue a source URL for processing.""" """Queue a source URL for processing."""
config = load_config() config = load_config()
config.ensure_dirs() config.ensure_dirs()
db = get_database(config.db_path) db = get_database()
source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO
@ -115,7 +115,7 @@ def process(
config = load_config() config = load_config()
config.ensure_dirs() config.ensure_dirs()
db = get_database(config.db_path) db = get_database()
yt_adapter = YouTubeAdapter(config) yt_adapter = YouTubeAdapter(config)
art_adapter = ArticleAdapter(config) art_adapter = ArticleAdapter(config)
@ -199,19 +199,32 @@ def process(
existing_ext.raw_response = getattr(result, "_raw_response", None) existing_ext.raw_response = getattr(result, "_raw_response", None)
existing_ext.input_tokens = getattr(result, "_input_tokens", None) existing_ext.input_tokens = getattr(result, "_input_tokens", None)
existing_ext.output_tokens = getattr(result, "_output_tokens", None) existing_ext.output_tokens = getattr(result, "_output_tokens", None)
ext_row = existing_ext
else: else:
extraction = Extraction( ext_row = Extraction(
source_id=source.id, source_id=source.id,
raw_response=getattr(result, "_raw_response", None), raw_response=getattr(result, "_raw_response", None),
input_tokens=getattr(result, "_input_tokens", None), input_tokens=getattr(result, "_input_tokens", None),
output_tokens=getattr(result, "_output_tokens", None), output_tokens=getattr(result, "_output_tokens", None),
**result.to_db_dict(), **result.to_db_dict(),
) )
sess.add(extraction) sess.add(ext_row)
source.status = SourceStatus.ANALYZED source.status = SourceStatus.ANALYZED
source.updated_at = utcnow() source.updated_at = utcnow()
# Flush so the new extraction gets its PK before we kick
# off embedding (we key public.embeddings rows by it).
sess.flush()
extraction_id = ext_row.id
extraction_summary = ext_row.summary
click.echo(" ✓ analyzed") click.echo(" ✓ analyzed")
if config.embeddings.get("enabled", True):
from second_brain.embeddings import embed_extraction
n = embed_extraction(extraction_id, extraction_summary)
if n is not None:
click.echo(f" ✓ embedded ({n} chunk(s))")
except Exception as exc: except Exception as exc:
source.error_message = str(exc) source.error_message = str(exc)
source.status = SourceStatus.FAILED source.status = SourceStatus.FAILED

View File

@ -1,6 +1,10 @@
""" """
SQLAlchemy ORM models for second-brain. SQLAlchemy ORM models for second-brain.
All tables live in the Postgres `second_brain` schema (in the petalbrain DB).
PK style is int autoincrement (SERIAL in Postgres); timestamps are naive UTC
(`timestamp without time zone`) per the locked design decisions in CLAUDE.md.
Tables: Tables:
- Source a queued/processed URL (video or article) - Source a queued/processed URL (video or article)
- Extraction structured LLM output for a source - Extraction structured LLM output for a source
@ -24,14 +28,19 @@ from sqlalchemy import (
ForeignKey, ForeignKey,
Integer, Integer,
JSON, JSON,
MetaData,
String, String,
Text, Text,
) )
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
SCHEMA = "second_brain"
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""Shared declarative base.""" """Shared declarative base. All tables live in the `second_brain` schema."""
metadata = MetaData(schema=SCHEMA)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -54,6 +63,22 @@ class SourceStatus(enum.Enum):
FAILED = "failed" FAILED = "failed"
# Native Postgres enum types, scoped to the second_brain schema so they
# don't collide with anything else in the cluster.
_SourceTypeEnum = Enum(
SourceType,
name="source_type",
schema=SCHEMA,
values_callable=lambda et: [e.value for e in et],
)
_SourceStatusEnum = Enum(
SourceStatus,
name="source_status",
schema=SCHEMA,
values_callable=lambda et: [e.value for e in et],
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Models # Models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -69,7 +94,7 @@ class Source(Base):
url: Mapped[str] = mapped_column(String(2000), unique=True, nullable=False) url: Mapped[str] = mapped_column(String(2000), unique=True, nullable=False)
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
source_type: Mapped[SourceType] = mapped_column( source_type: Mapped[SourceType] = mapped_column(
Enum(SourceType), nullable=False, default=SourceType.VIDEO _SourceTypeEnum, nullable=False, default=SourceType.VIDEO
) )
# Domain and focus # Domain and focus
@ -78,7 +103,7 @@ class Source(Base):
# Pipeline status # Pipeline status
status: Mapped[SourceStatus] = mapped_column( status: Mapped[SourceStatus] = mapped_column(
Enum(SourceStatus), nullable=False, default=SourceStatus.PENDING _SourceStatusEnum, nullable=False, default=SourceStatus.PENDING
) )
# File paths (set after pull/transcribe steps) # File paths (set after pull/transcribe steps)
@ -115,7 +140,9 @@ class Extraction(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column( source_id: Mapped[int] = mapped_column(
ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, unique=True ForeignKey(f"{SCHEMA}.sources.id", ondelete="CASCADE"),
nullable=False,
unique=True,
) )
# Core fields (mirrors the extraction schema contract) # Core fields (mirrors the extraction schema contract)
@ -167,6 +194,7 @@ class WikiPage(Base):
__all__ = [ __all__ = [
"Base", "Base",
"SCHEMA",
"Source", "Source",
"SourceStatus", "SourceStatus",
"SourceType", "SourceType",

View File

@ -127,18 +127,27 @@ class Scheduler:
existing.raw_response = getattr(result, "_raw_response", None) existing.raw_response = getattr(result, "_raw_response", None)
existing.input_tokens = getattr(result, "_input_tokens", None) existing.input_tokens = getattr(result, "_input_tokens", None)
existing.output_tokens = getattr(result, "_output_tokens", None) existing.output_tokens = getattr(result, "_output_tokens", None)
ext_row = existing
else: else:
sess.add(Extraction( ext_row = Extraction(
source_id=source.id, source_id=source.id,
raw_response=getattr(result, "_raw_response", None), raw_response=getattr(result, "_raw_response", None),
input_tokens=getattr(result, "_input_tokens", None), input_tokens=getattr(result, "_input_tokens", None),
output_tokens=getattr(result, "_output_tokens", None), output_tokens=getattr(result, "_output_tokens", None),
**fields, **fields,
)) )
sess.add(ext_row)
source.status = SourceStatus.ANALYZED source.status = SourceStatus.ANALYZED
source.updated_at = utcnow() source.updated_at = utcnow()
# Flush so the embedding step can key on the new PK.
sess.flush()
if self.config.embeddings.get("enabled", True):
from second_brain.embeddings import embed_extraction
embed_extraction(ext_row.id, ext_row.summary)
calls_this_hour += 1 calls_this_hour += 1
used = (getattr(result, "_input_tokens", 0) or 0) + ( used = (getattr(result, "_input_tokens", 0) or 0) + (
getattr(result, "_output_tokens", 0) or 0 getattr(result, "_output_tokens", 0) or 0