Compare commits

...

6 Commits

Author SHA1 Message Date
Travis Herbranson
0d9ebc9cc7 embeddings: register pool close() at interpreter exit
Without this, every `second-brain process` run prints
  couldn't stop thread 'pool-1-worker-N' within 5.0 seconds
during interpreter shutdown — psycopg_pool's background workers don't
get a clean stop signal before Python's threading shutdown deadline.

Registering close_pool via atexit the first time we open the pool fixes
it without changing any API. `close_pool` is already idempotent, so the
explicit teardown path in the smoke test (which calls it directly) and
the atexit path coexist safely.
2026-05-24 23:33:13 -04:00
Travis Herbranson
f44ac2ff7c test: smoke test reads embedding model from config, not hardcoded literal
a-review (gemini, full migration diff) flagged that the smoke test had
the embedding model name baked into the SQL count query, decoupling it
from the application's configured value. Read it back from
`config.embeddings.model` (env override still wins) so the test stays
valid if the default ever moves off nomic-embed-text.
2026-05-24 22:58:30 -04:00
Travis Herbranson
6c1445c8ed docs: update CLAUDE.md + migration plan to reflect shipped Postgres design
CLAUDE.md updates:
- new src/second_brain/embeddings/ entry in the project layout
- setup section now lists the postgres-superuser bootstrap (CREATE SCHEMA
  AUTHORIZATION lovebug) and the alembic step
- env var table now covers SECOND_BRAIN_DATABASE_URL, OLLAMA_URL,
  EMBEDDING_MODEL, and the pool sizing knobs
- conventions section gained five new gotchas (the lovebug CREATE gap,
  public.embeddings ownership, best-effort embedding, etc.)

postgres-migration-planning.md flipped from "planning questions" to a
"what shipped" runbook — locked decisions table, operator setup steps,
known gotchas, and the next-iteration backlog.
2026-05-24 22:57:31 -04:00
Travis Herbranson
a2d46a7c6e gauntlet: fix lint — drop unused imports + reorder models.py + add dev deps
Ruff autofix removed pre-existing F401 unused imports across adapters,
context, compiler, extractor, scheduler, web, and main. Also reordered
models.py so its SQLAlchemy imports sit at the top of the file
(E402 was triggered by the inline `utcnow` helper definition).

Added a [dependency-groups] dev block (pytest, pytest-cov, ruff) so the
zero-check test gauntlet — which runs `pytest --cov --cov-report=term-missing`
— can resolve its plugins without a manual `uv pip install pytest-cov`.
2026-05-24 22:52:08 -04:00
Travis Herbranson
2e07d7a8fe alembic: skip CREATE SCHEMA when present + add embedding smoke test
The runtime role (lovebug) doesn't have CREATE on the petalbrain database
even though it owns the second_brain schema, so a bare
`CREATE SCHEMA IF NOT EXISTS` errors out with permission denied. Gate
the bootstrap on a pg_namespace lookup so we only attempt the create
when the schema is genuinely missing — operators bootstrap it once as
postgres superuser, alembic just respects it afterward.

The smoke test exercises the full Postgres + embedding path against a
live DB + Ollama (autoskipped otherwise): writes an extraction, embeds
the summary, asserts public.embeddings has the expected row count, and
re-embeds to verify the delete-before-insert idempotency.
2026-05-24 22:49:06 -04:00
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
26 changed files with 1605 additions and 143 deletions

View File

@ -22,18 +22,36 @@ pending → pulled → transcribed → analyzed → accepted → published
src/second_brain/
├── adapters/ # Pull adapters (youtube.py, article.py)
├── context/ # Context assembler
├── embeddings/ # Chunk + nomic-embed-text + upsert into public.embeddings
├── extractor/ # LLM extraction engine + Pydantic schema
├── llm/ # claude_cli.py — thin subprocess wrapper around `claude -p`
├── compiler/ # Wiki compiler (reads vault, writes pages, git commits)
├── scheduler/ # Rate-limit smoother (call-count for CLI, token-budget for API)
└── web/ # FastAPI + Jinja2 + HTMX UI
alembic/ # Schema migrations for the second_brain Postgres schema
```
## Setup
```bash
cp config/settings.toml.example config/settings.toml
# Edit settings.toml with your paths
# Edit settings.toml — at minimum, point the paths and set SECOND_BRAIN_DATABASE_URL.
# Connection string format. Containerised (default):
# postgresql+psycopg://lovebug:<password>@homelab-postgres:5432/petalbrain
# Host-side dev runs:
# postgresql+psycopg://lovebug:<password>@127.0.0.1:5433/petalbrain
export SECOND_BRAIN_DATABASE_URL=...
# One-time, before first migration: bootstrap the schema as postgres
# superuser. lovebug doesn't have CREATE on petalbrain, so this can't be
# done from the app side — same pattern as wiki/trellis/ob1.
# CREATE SCHEMA second_brain AUTHORIZATION lovebug;
# GRANT ALL PRIVILEGES ON SCHEMA second_brain TO lovebug;
uv sync
uv run alembic upgrade head
uv run second-brain serve
```
@ -58,6 +76,10 @@ uv run second-brain serve
- `ANTHROPIC_API_KEY` — only required when `extractor.backend = "api"`. The default `cli` backend uses your Max OAuth and does not need it.
- `SECOND_BRAIN_CONFIG` — optional override for the settings file path.
- `SECOND_BRAIN_DATABASE_URL` — Postgres connection string. Falls back to `HERBYLAB_DATABASE_URL` if unset. Required at runtime; both `second_brain.database` and `second_brain.embeddings` read it. Use the SQLAlchemy `postgresql+psycopg://` form; the embeddings module strips the `+psycopg` prefix internally for raw psycopg.
- `OLLAMA_URL` — overrides `[embeddings].ollama_url`. Default `http://ollama:11434` (homelab docker network); for host-side runs use `http://127.0.0.1:11434`.
- `EMBEDDING_MODEL` — overrides `[embeddings].model`. Default `nomic-embed-text` (768-d).
- `SECOND_BRAIN_DB_POOL_MIN` / `SECOND_BRAIN_DB_POOL_MAX` — SQLAlchemy pool sizing knobs (defaults 1 / 9 overflow).
## Domains
@ -68,24 +90,31 @@ uv run second-brain serve
## Key files
- `config/settings.toml` — vault path, DB path, scheduler window, `[extractor]` backend config
- `config/settings.toml` — vault path, `[database]`, `[extractor]`, `[embeddings]`, `[scheduler]`
- `prompts/<domain>.md` — per-domain extraction prompt templates
- `src/second_brain/extractor/schema.py``LLMExtraction` (LLM-only fields, drives the `--json-schema` contract) + `ExtractionResult` (LLMExtraction + SourceMeta filled from the DB)
- `src/second_brain/llm/claude_cli.py` — CLI subprocess wrapper
- `src/second_brain/models.py` — SQLAlchemy models + `utcnow()` helper (replaces deprecated `datetime.utcnow`)
- `postgres-migration-planning.md` — open questions blocking the SQLite → Postgres+pgvector migration
- `src/second_brain/models.py` — SQLAlchemy models (all tables live in the `second_brain` schema) + `utcnow()` helper
- `src/second_brain/database.py` — Postgres engine + URL resolution
- `src/second_brain/embeddings/__init__.py` — chunk → Ollama → upsert into `public.embeddings`; best-effort with graceful degradation
- `alembic/` + `alembic.ini` — schema migrations for the `second_brain` schema (Alembic does **not** manage `public.embeddings`)
- `postgres-migration-planning.md` — original planning doc; superseded by this file once the migration shipped
## Conventions / gotchas
- **Naive UTC timestamps everywhere.** Use `from second_brain.models import utcnow` rather than `datetime.utcnow()` (deprecated in 3.12) or `datetime.now()` (local time).
- **Naive UTC timestamps everywhere.** Use `from second_brain.models import utcnow` rather than `datetime.utcnow()` (deprecated in 3.12) or `datetime.now()` (local time). Postgres columns are `timestamp without time zone` — do not switch to `timestamptz`.
- **PK style.** Int autoincrement (`SERIAL`) — locked for compatibility with the existing schema shape. Do not switch to `BIGINT GENERATED ALWAYS AS IDENTITY` or UUID.
- **Re-query inside the session.** SQLAlchemy ORM attribute access after the session closes raises `DetachedInstanceError`. The `process` command and the web accept/reject endpoints both collect IDs first, then re-query inside the per-iteration / per-request session block, and pre-compute any dict views needed for templates *inside* the `with db.session()` block.
- **Starlette `TemplateResponse` signature.** Starlette ≥ 0.36 requires `templates.TemplateResponse(request, name, context)`. The old `(name, context)` form raises `TypeError: unhashable type: 'dict'`. Do not put `"request": request` into the context dict — it's auto-injected now.
- **Vault git repo auto-init.** The wiki compiler calls `_ensure_git_repo()` on every run, which `git init`s the vault and creates an empty seed commit if no `.git` is present. `_git_commit()` also checks `git diff --cached --quiet` before committing to avoid empty commits.
- **Scheduler throttling shape.** The CLI backend is throttled by `max_calls_per_hour` (Max subscription limits are call-shaped, not token-shaped). The API backend uses `max_tokens_per_hour`. Both share `min_gap_seconds`. The scheduler targets sources in `TRANSCRIBED` state — earlier code mistakenly filtered on `ANALYZED`, making it a no-op.
- **Alembic + lovebug privilege gap.** `lovebug` owns the `second_brain` schema but does **not** have `CREATE` on the `petalbrain` database. `alembic/env.py` therefore guards `CREATE SCHEMA` behind a `pg_namespace` lookup, and the v1 migration intentionally drops the `IF NOT EXISTS` form (a bare `CREATE SCHEMA IF NOT EXISTS` raises permission denied even when the schema is already present). The schema is bootstrapped once, out-of-band, by `postgres` superuser.
- **public.embeddings is shared, not ours.** Alembic only manages the `second_brain` schema. The vector table itself is owned by `postgres` and shared across vault-mcp / ob1 / second-brain — we just INSERT into it. Rows are keyed by `(source_schema='second_brain', source_table='extractions', source_id, embedding_model='nomic-embed-text')`.
- **Embedding is best-effort.** The pipeline writes the relational `extractions` row first and `sess.flush()`es; only then calls `embed_extraction()`. Any failure (no DB URL, no Ollama, embedding 500) logs at WARNING and returns None — the relational row and file vault stay canonical.
- **File-based wiki, not petalbrain.wiki.** The wiki compiler still writes markdown into the configured `vault_path` and commits via git. We do **not** dual-write into `petalbrain.wiki` — that's vault-mcp's territory and intentionally out of scope.
## In-flight work
- **Postgres + pgvector migration.** SQLite is the current backing store. Travis wants to move to his existing Postgres + pgvector instance and use the shared `/projects/shared/python/embedding_chunking` module for vector indexing. Planning questions are enumerated in `postgres-migration-planning.md` at the project root; no migration work has started yet.
- **Vault location.** Recommended `/opt/projects/second-brain-vault/` as the production location (peer to the project dir). Not yet confirmed.
## Validation

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

119
alembic/env.py Normal file
View File

@ -0,0 +1,119 @@
"""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.
# The runtime role (lovebug) does NOT have CREATE on the petalbrain
# database — bare `CREATE SCHEMA IF NOT EXISTS` would fail with
# permission denied even though the schema already exists. So we
# only attempt to create it when the schema is genuinely missing
# (operator must have bootstrapped it once as postgres superuser:
# `CREATE SCHEMA second_brain AUTHORIZATION lovebug`).
# Commit explicitly so any DDL 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.
row = connection.exec_driver_sql(
"SELECT 1 FROM pg_namespace WHERE nspname = %s",
(SCHEMA,),
).fetchone()
if row is None:
connection.exec_driver_sql(f"CREATE SCHEMA {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,120 @@
"""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."""
# The schema is bootstrapped by env.py (only if missing); skip the
# IF NOT EXISTS form here because lovebug doesn't have CREATE on the
# petalbrain database and the bare statement errors out even when the
# schema already exists. Just SET search_path and emit DDL inside it.
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.
# Override the config file path with SECOND_BRAIN_CONFIG env var.
# Path to the SQLite database
db_path = "~/.local/share/second-brain/second_brain.db"
# Path to your Obsidian vault (git-managed directory)
# 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
@ -21,6 +20,21 @@ 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.
@ -29,6 +43,17 @@ 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"

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

@ -1,78 +1,118 @@
# Postgres + pgvector migration — planning questions
# Postgres + pgvector migration — implementation notes
Status: awaiting input from Travis before scoping the migration.
Trigger: user pivoted away from SQLite — wants to use the existing Postgres + pgvector instance, plus the shared `/projects/shared/python/embedding_chunking` module.
Status: **shipped** (2026-05-24). Originally a planning checklist; rewritten
to capture the implemented design and the decisions that landed. Kept in the
repo so the next iteration has the "why" without grepping commits.
---
## 1. Connection / secrets
## What shipped
- [ ] Host, port, database name, user
- [ ] Where does the connection string live? Options:
- env var (e.g. `SECOND_BRAIN_DB_URL`)
- `config/settings.toml` (would need a `.local.toml` override so the password doesn't get committed)
- `.pgpass`
- other
- [ ] Is the Postgres instance on `herbydev`, the NAS, or elsewhere? Tailscale-only or LAN-reachable?
- [ ] Driver preference:
- `psycopg` v3 sync (default suggestion — pipeline + scheduler are sync today)
- `asyncpg` (only worth it if we also rewrite the web layer to async)
## 2. Schema layout
- [ ] Same Postgres **instance** as `vault-mcp`'s `petalbrain.wiki`? Yes / No
- [ ] If yes — same **database** with a dedicated schema (e.g. `second_brain.sources`, `second_brain.extractions`)? Or its own database entirely?
- [ ] Naming conventions to match:
- snake_case tables — assumed yes
- `created_at` / `updated_at` as `timestamptz` (vs current naive UTC) — preference?
- PK type: `bigserial` / `bigint identity` / UUID?
- [ ] Should second-brain integrate with `vault-mcp` at all?
- Option A: standalone — second-brain owns its tables, the wiki compiler still writes Obsidian markdown files
- Option B: dual-write — published extractions land in `petalbrain.wiki` via `vault-mcp` so they show up in the unified wiki
- Option C: replace the file-based compiler entirely with `vault-mcp` writes
## 3. Embeddings
- [ ] Public API of `/projects/shared/python/embedding_chunking`:
- function signatures
- what it returns (raw text chunks? chunks + vectors? chunker only, embeddings done separately?)
- embedding model + vector dimension (drives the `vector(N)` column type)
- any batching / rate-limit behavior we need to respect
- [ ] Where should embeddings attach?
- (a) on `Extraction` rows — semantic search over summaries / key_points / claims
- (b) on chunked transcripts — chunk-level retrieval for "find the part of the video that says X"
- (c) both (separate tables: `extraction_embeddings` + `transcript_chunks`)
- [ ] pgvector index strategy:
- HNSW — better recall, slower build, recommended default for personal-scale corpus
- IVFFlat — faster build, needs periodic `ANALYZE` + a meaningful `lists` value
- [ ] Distance metric: cosine (typical), L2, or inner product?
## 4. Migration tooling
- [ ] Schema is small and not yet in production. Cleanest path:
- SQLAlchemy `Base.metadata.create_all()` against Postgres
- one-off import script to move existing SQLite rows over (if any are worth keeping)
- [ ] Alternative: stand up Alembic now while the cost is low. Pays off the first time you `ALTER` a table in anger; costs ~1 session of setup.
- [ ] Travis's call — defaulting to `create_all` + import script unless you say otherwise.
## 5. Pooling
- [ ] Three consumers share the DB: CLI (`process`, `compile`), the FastAPI web server, the scheduler.
- [ ] Default plan: SQLAlchemy `QueuePool` with `pool_size=5, max_overflow=5`.
- [ ] Is PgBouncer already in front of this instance? If yes — switch to `NullPool` on our side and let PgBouncer pool.
## 6. Other open items (carryover from prior session)
- [ ] Vault location decision: I recommended `/opt/projects/second-brain-vault/` as a peer dir. Not yet confirmed.
- [ ] NAS mount: is it mounted on `herbydev`, and at what path? Relevant if media (downloaded videos, transcripts) should live there rather than on the herbydev SSD.
- Backing store switched from SQLite to the existing **petalbrain** Postgres
instance (pgvector/pg17), connecting over the homelab docker network at
`homelab-postgres:5432`.
- Relational tables (`sources`, `extractions`, `wiki_pages`) live in a new
`second_brain` schema owned by the **`lovebug`** role — no dedicated service
role.
- Embeddings reuse the **shared `public.embeddings` table** (HNSW vector index
already in place). Rows are keyed by
`(source_schema='second_brain', source_table='extractions', source_id, embedding_model='nomic-embed-text')`.
For this round we embed **extraction summaries only** (not transcript
chunks, not key_points / claims).
- Embedding pipeline mirrors `vault_mcp.core.embeddings`: shared
`embedding_chunking` for 512/64-token chunking → Ollama
`nomic-embed-text` (768-d, `num_ctx=8192`) → delete-before-insert upsert
with vector text literals (`%s::vector`).
- **Graceful degradation** is the rule: missing DB URL, Ollama down, or any
DB error logs a warning and returns `None`. The relational row and the
file-based vault stay canonical.
- **Alembic** stands up the `second_brain` schema; `public.embeddings` is
managed out-of-band and intentionally outside the migration tree.
- File-based wiki compiler is unchanged. No dual-write to `petalbrain.wiki`.
- **No SQLite import** — we started clean on Postgres.
---
## What I will produce once these are answered
## Locked decisions (from the planning round)
1. Updated `src/second_brain/database.py` with a Postgres engine + URL resolution
2. Schema migration: new tables for embeddings (and chunks if we go with option (c))
3. Wiring of `embedding_chunking` into the extractor (or into a post-extraction step in the scheduler)
4. `config/settings.toml.example` updates with the new `[database]` and `[embeddings]` blocks
5. README / CLAUDE.md updates noting the Postgres dependency
6. One-shot SQLite → Postgres import script (only if there are rows worth keeping)
| Question | Decision |
|---|---|
| DB instance | Existing petalbrain (homelab-postgres) |
| Schema | New `second_brain` schema (owned by `lovebug`) |
| Connection | Containerised: `homelab-postgres:5432` (NOT host `127.0.0.1:5433`) |
| Pooling | `psycopg_pool.ConnectionPool` min=1/max=10 — no PgBouncer in front |
| Driver | psycopg v3, SQLAlchemy uses `postgresql+psycopg://` |
| PK style | Int autoincrement (`SERIAL`) — kept |
| Timestamps | Naive UTC `timestamp without time zone` — kept |
| Embedding scope | Extraction summaries only (Phase 1) |
| Embedding model | `nomic-embed-text` (768-d), Ollama via `OLLAMA_URL` |
| Migration tool | Alembic, second-brain owns its own tree |
| `public.embeddings` | NOT managed by our Alembic — shared with vault-mcp / ob1 |
| File-based wiki | Kept intact; no dual-write into petalbrain.wiki |
| Old SQLite data | Not imported — clean start |
---
## Setup (operator runbook)
```bash
# 1. One-time, as postgres superuser, bootstrap the schema. lovebug doesn't
# have CREATE on petalbrain, so this can't be done from the app side.
docker exec homelab-postgres psql -U postgres -d petalbrain -c \
"CREATE SCHEMA second_brain AUTHORIZATION lovebug;
GRANT ALL PRIVILEGES ON SCHEMA second_brain TO lovebug;"
# 2. Point the project at the DB (env wins over settings.toml).
export SECOND_BRAIN_DATABASE_URL=\
"postgresql+psycopg://lovebug:<pw>@homelab-postgres:5432/petalbrain"
# 3. Install deps and run the migration.
uv sync
uv run alembic upgrade head
```
The `lovebug` password lives in `/opt/backups/postgres-consolidation/credentials.env`
(0600, host-only).
---
## Key files (post-migration)
- `src/second_brain/models.py` — schema-qualified ORM, `__table_args__`
`second_brain`; native Postgres enum types are scoped to the schema too.
- `src/second_brain/database.py` — psycopg-v3 engine + URL resolution
(constructor arg → `SECOND_BRAIN_DATABASE_URL``HERBYLAB_DATABASE_URL`
`[database].url`).
- `src/second_brain/embeddings/__init__.py` — near-verbatim port of
`vault_mcp/core/embeddings.py` adapted to our key tuple.
- `alembic.ini` + `alembic/env.py` — schema-pinned Alembic; the env.py guards
`CREATE SCHEMA` behind `pg_namespace` because `lovebug` lacks `CREATE` on
the database.
- `alembic/versions/4a1e2f6c9d10_v1_second_brain_pipeline_tables.py` — v1
migration: enum types + `sources` + `extractions` + `wiki_pages`.
- `tests/test_smoke_embedding.py` — end-to-end smoke test exercising the full
Postgres + Ollama path. Auto-skips when either is unreachable.
---
## Known gotchas
1. **`CREATE SCHEMA IF NOT EXISTS` errors as `lovebug`** even when the schema
exists. `pg_namespace` lookup in `alembic/env.py` is the workaround.
2. **`+psycopg` URL prefix.** SQLAlchemy needs it; raw psycopg doesn't.
`database.py` adds it if missing; the embeddings module strips it.
3. **Pool conflict.** `database.py` opens an SQLAlchemy pool; `embeddings/__init__.py`
opens an independent psycopg pool. Both share the same DSN; both are small
(min=1) so they don't strain the cluster.
4. **`anthropic-skills:zero-check` needs `pytest-cov`.** Added to the new
`[dependency-groups].dev` block so `uv sync` resolves it.
---
## Next iterations (open, not blocked)
- Embed transcript chunks (or at least key_points / claims), not just summaries.
- Promote vector retrieval into the web UI (semantic search across extractions).
- Add a periodic backfill script that re-embeds extractions whose
`embedding_model` no longer matches the current default.
- Decide on the production vault path (`/opt/projects/second-brain-vault/`?).

View File

@ -4,10 +4,15 @@ version = "0.1.0"
description = "Personal knowledge system: video/article extraction pipeline + LLM-maintained wiki"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.13.0",
"click>=8.1.0",
"embedding-chunking",
"fastapi>=0.115.0",
"httpx>=0.28.0",
"jinja2>=3.1.0",
"openai-whisper",
"psycopg[binary]>=3.2",
"psycopg-pool>=3.2",
"pydantic>=2.0.0",
"python-dotenv>=1.0.0",
"sqlalchemy>=2.0.0",
@ -23,6 +28,18 @@ dependencies = [
# Max OAuth subscription and has no Python-side Anthropic dependency.
api = ["anthropic>=0.40.0"]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.8",
]
[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]
second-brain = "second_brain.main:cli"

View File

@ -0,0 +1,30 @@
# a-review — 2026-05-24
**Model:** gemini
**Diff base:** `HEAD~1` (12 files changed, 208 insertions(+), 21 deletions(-))
**Session reference:** none
**Context:** CLAUDE.md + session notes + expanded diff
---
Based on the provided context, here is my code review.
### [INFO] Project Dependencies
* **File:** `pyproject.toml`
* **Finding:** The change introduces a `[dependency-groups].dev` section to manage development-specific dependencies like `pytest`, `pytest-cov`, and `ruff`.
* **Reasoning:** This is a positive change that follows standard Python project structure. It cleanly separates the project's runtime dependencies from the tools required for testing and linting, which improves dependency management and clarifies the project's setup for new developers.
### [INFO] Code Cleanup
* **Files:** `src/second_brain/adapters/article.py`, `src/second_brain/adapters/youtube.py`, `src/second_brain/compiler/wiki.py`, `src/second_brain/context/assembler.py`, `src/second_brain/extractor/engine.py`, `src/second_brain/extractor/schema.py`, `src/second_brain/main.py`, `src/second_brain/scheduler/runner.py`, `src/second_brain/web/app.py`
* **Finding:** Multiple unused imports (`Optional`, `datetime`, `json`, `Path`, etc.) were removed from several files.
* **Reasoning:** This is a beneficial code hygiene practice. Removing unused imports reduces clutter, makes the code easier to read, and helps linters and static analysis tools run more effectively. It has no impact on runtime behavior but improves maintainability.
### Summary
The submitted changes are of high quality. They consist of two main improvements:
1. Properly defining development dependencies in `pyproject.toml`.
2. Cleaning up unused imports across the codebase, likely as a result of running a linter like the newly added `ruff`.
I found no security vulnerabilities, bugs, or convention violations. The changes are safe and improve the project's overall maintainability and structure. The implementation aligns with the goal of setting up a better development and testing environment.

View File

@ -0,0 +1,65 @@
# a-review — 2026-05-24
**Model:** gemini
**Diff base:** `4696d7f` (24 files changed, 1496 insertions(+), 144 deletions(-))
**Session reference:** none
**Context:** CLAUDE.md + session notes + expanded diff
---
Excellent, the changes look solid overall. This is a substantial and well-executed migration from SQLite to Postgres, with the bonus of a new embedding feature. The code quality is high, particularly the database and transaction management logic.
I have a few findings, mostly related to documentation and test robustness.
### [WARNING] Documentation is now out of sync with the codebase
* **Location:** `CLAUDE.md`
* **Issue:** The project's main documentation file, `CLAUDE.md`, has not been updated to reflect these significant changes.
1. It still states the "Postgres + pgvector migration" is "In-flight work" and refers to `postgres-migration-planning.md`. This change appears to complete that migration.
2. The "Project layout" and "Key files" sections are missing the new `alembic/` directory for migrations and the new `src/second_brain/embeddings/` module.
3. The configuration sections do not mention the new `[database]` or `[embeddings]` tables in `config/settings.toml`.
* **Impact:** New developers (or future you!) will be confused by the discrepancy between the documentation and the actual state of the code. This makes maintenance harder and increases the risk of mistakes.
* **Recommendation:** After a large feature lands, it's crucial to update the project's core documentation. `CLAUDE.md` should be updated to reflect that the project now uses Postgres, describe the new embedding pipeline, and document the new configuration options. The `postgres-migration-planning.md` file should likely be archived or deleted.
### [INFO] Test fragility due to hardcoded string literal
* **Location:** `tests/test_smoke_embedding.py`, line 111
* **Issue:** The test query hardcodes the embedding model name: `AND embedding_model = 'nomic-embed-text'`. The application code correctly reads the model from the configuration. If the default model in `config/settings.toml` were to change in the future, this test would fail even if the application logic is correct.
* **Impact:** This creates a tight coupling between the test and a specific configuration value, making the test suite more brittle.
* **Recommendation:** The test should load the application `Config` and use the configured embedding model name in its verification query. This ensures the test is checking that the system works with *whatever* model is currently configured.
```python
# tests/test_smoke_embedding.py
# at the top
from second_brain.config import load_config
# in the test function
config = load_config()
embedding_model = config.embeddings.get("model", "nomic-embed-text")
# ... later in the raw SQL query
cur.execute(
"""
SELECT COUNT(*) ...
WHERE ...
AND embedding_model = %s
""",
(extraction_id, embedding_model),
)
```
### [INFO] Unused imports removed
* **Location:** Multiple files, including `src/second_brain/adapters/article.py`, `src/second_brain/compiler/wiki.py`, `src/second_brain/extractor/schema.py`, etc.
* **Issue:** The diff removes several unused imports. While cleaning this up is good, the fact they were present suggests a linter (like `ruff`, which is in the dev dependencies) may not be running automatically as part of the development or pre-commit workflow.
* **Impact:** This is a minor code hygiene issue, but consistent linting helps catch small issues early and maintains code quality across the project.
* **Recommendation:** Consider setting up a pre-commit hook to run `ruff check --fix` to automate the removal of unused imports and fix other simple linting issues.
## Summary
Overall, this is a high-quality contribution. The migration to Postgres is thoughtful and robust, correctly using Alembic for schema management and a connection pool for performance. The implementation of the embedding pipeline is well-contained, idempotent, and gracefully handles potential failures. The addition of a comprehensive smoke test is a fantastic practice that significantly increases confidence in the new functionality.
The primary area for improvement is in process: the project's documentation needs to be treated as part of the code and updated in the same change that implements new features or makes significant architectural modifications.
Aside from the documentation gap, the code is clean, correct, and demonstrates a strong understanding of the technologies involved. Great work.

View File

@ -8,7 +8,6 @@ Phase 1: basic fetch + text extraction.
from __future__ import annotations
from datetime import datetime
from typing import Optional
from urllib.parse import urlparse
import trafilatura

View File

@ -7,7 +7,6 @@ Adapted to work with the second-brain Source model and Config.
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Optional

View File

@ -15,7 +15,6 @@ from __future__ import annotations
import re
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional

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).
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
@ -21,12 +26,17 @@ except ModuleNotFoundError:
# ---------------------------------------------------------------------------
_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"],
"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.
@ -35,6 +45,19 @@ _DEFAULTS: dict[str, Any] = {
"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",
@ -49,6 +72,13 @@ _DEFAULTS: dict[str, Any] = {
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."""
@ -57,10 +87,6 @@ class Config:
# --- 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()
@ -89,17 +115,23 @@ class Config:
def anthropic_api_key(self) -> str | None:
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
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
def scheduler(self) -> dict[str, Any]:
return {**_DEFAULTS["scheduler"], **self._raw.get("scheduler", {})}
return _merge(_DEFAULTS["scheduler"], self._raw.get("scheduler", {}))
# --- prompts dir ---
@ -112,7 +144,7 @@ class Config:
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):
for d in (self.media_dir, self.subtitles_dir):
d.mkdir(parents=True, exist_ok=True)
@ -150,4 +182,10 @@ def load_config(path: Path | None = None) -> Config:
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

@ -9,8 +9,6 @@ Future phases can layer in wiki context, related sources, etc.
from __future__ import annotations
from pathlib import Path
from typing import Optional
from second_brain.config import Config
from second_brain.models import Source

View File

@ -1,43 +1,105 @@
"""
Database engine and session management for second-brain.
Ported and adapted from xtract/database.py.
Uses a singleton pattern: call get_database() everywhere.
Postgres + pgvector backend on the petalbrain instance.
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
import os
from contextlib import contextmanager
from pathlib import Path
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 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:
"""Manages the SQLAlchemy engine and session factory."""
"""Manages the SQLAlchemy engine and session factory for second-brain."""
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def __init__(self, url: str | None = None) -> None:
self.url = _resolve_url(url)
self.engine = create_engine(
f"sqlite:///{self.db_path}",
# Pool sizing matches vault-mcp (min=1/max=10). No PgBouncer; a normal
# QueuePool is fine for the CLI + scheduler + web workers we run.
self.engine: Engine = create_engine(
self.url,
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(
autocommit=False,
autoflush=False,
bind=self.engine,
future=True,
)
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)
def get_session(self) -> Session:
@ -57,6 +119,9 @@ class Database:
finally:
sess.close()
def close(self) -> None:
self.engine.dispose()
# ---------------------------------------------------------------------------
# Singleton
@ -65,17 +130,25 @@ class Database:
_db_instance: Database | None = None
def get_database(db_path: Path | None = None) -> Database:
"""Return the singleton Database, creating and initialising it on first call."""
def get_database(url: str | None = None) -> Database:
"""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
if _db_instance is None:
if db_path is None:
from second_brain.config import load_config
db_path = load_config().db_path
_db_instance = Database(db_path)
_db_instance.init_db()
_db_instance = Database(url)
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,257 @@
"""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 atexit
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)
# Make sure the pool's worker threads get a clean shutdown when the
# interpreter exits; without this psycopg_pool prints
# "couldn't stop thread pool-1-worker-N within 5.0 seconds" on every
# `second-brain process` run.
atexit.register(close_pool)
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

@ -18,7 +18,7 @@ import os
from typing import Optional
from second_brain.extractor.schema import ExtractionResult, LLMExtraction, SourceMeta
from second_brain.llm.claude_cli import ClaudeCLI, ClaudeCLIError, DEFAULT_MODEL
from second_brain.llm.claude_cli import ClaudeCLI, DEFAULT_MODEL
from second_brain.models import Source

View File

@ -7,10 +7,9 @@ stored in the Extraction model as JSON columns.
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, HttpUrl
from pydantic import BaseModel, Field
class LLMExtraction(BaseModel):

View File

@ -11,8 +11,6 @@ Commands:
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
@ -54,7 +52,7 @@ def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> No
"""Queue a source URL for processing."""
config = load_config()
config.ensure_dirs()
db = get_database(config.db_path)
db = get_database()
source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO
@ -115,7 +113,7 @@ def process(
config = load_config()
config.ensure_dirs()
db = get_database(config.db_path)
db = get_database()
yt_adapter = YouTubeAdapter(config)
art_adapter = ArticleAdapter(config)
@ -199,19 +197,32 @@ def process(
existing_ext.raw_response = getattr(result, "_raw_response", None)
existing_ext.input_tokens = getattr(result, "_input_tokens", None)
existing_ext.output_tokens = getattr(result, "_output_tokens", None)
ext_row = existing_ext
else:
extraction = Extraction(
ext_row = Extraction(
source_id=source.id,
raw_response=getattr(result, "_raw_response", None),
input_tokens=getattr(result, "_input_tokens", None),
output_tokens=getattr(result, "_output_tokens", None),
**result.to_db_dict(),
)
sess.add(extraction)
sess.add(ext_row)
source.status = SourceStatus.ANALYZED
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")
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:
source.error_message = str(exc)
source.status = SourceStatus.FAILED

View File

@ -1,6 +1,10 @@
"""
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:
- Source a queued/processed URL (video or article)
- Extraction structured LLM output for a source
@ -13,25 +17,31 @@ import enum
from datetime import datetime, timezone
from typing import Optional
def utcnow() -> datetime:
"""Naive UTC timestamp. Replaces `datetime.utcnow()` (deprecated in 3.12)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
from sqlalchemy import (
JSON,
DateTime,
Enum,
ForeignKey,
Integer,
JSON,
MetaData,
String,
Text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def utcnow() -> datetime:
"""Naive UTC timestamp. Replaces `datetime.utcnow()` (deprecated in 3.12)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
SCHEMA = "second_brain"
class Base(DeclarativeBase):
"""Shared declarative base."""
"""Shared declarative base. All tables live in the `second_brain` schema."""
metadata = MetaData(schema=SCHEMA)
# ---------------------------------------------------------------------------
@ -54,6 +64,22 @@ class SourceStatus(enum.Enum):
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
# ---------------------------------------------------------------------------
@ -69,7 +95,7 @@ class Source(Base):
url: Mapped[str] = mapped_column(String(2000), unique=True, nullable=False)
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
source_type: Mapped[SourceType] = mapped_column(
Enum(SourceType), nullable=False, default=SourceType.VIDEO
_SourceTypeEnum, nullable=False, default=SourceType.VIDEO
)
# Domain and focus
@ -78,7 +104,7 @@ class Source(Base):
# Pipeline status
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)
@ -115,7 +141,9 @@ class Extraction(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
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)
@ -167,6 +195,7 @@ class WikiPage(Base):
__all__ = [
"Base",
"SCHEMA",
"Source",
"SourceStatus",
"SourceType",

View File

@ -21,7 +21,6 @@ from __future__ import annotations
import time
from datetime import datetime, time as dtime
from typing import Optional
from second_brain.config import Config
from second_brain.database import get_database
@ -127,18 +126,27 @@ class Scheduler:
existing.raw_response = getattr(result, "_raw_response", None)
existing.input_tokens = getattr(result, "_input_tokens", None)
existing.output_tokens = getattr(result, "_output_tokens", None)
ext_row = existing
else:
sess.add(Extraction(
ext_row = Extraction(
source_id=source.id,
raw_response=getattr(result, "_raw_response", None),
input_tokens=getattr(result, "_input_tokens", None),
output_tokens=getattr(result, "_output_tokens", None),
**fields,
))
)
sess.add(ext_row)
source.status = SourceStatus.ANALYZED
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
used = (getattr(result, "_input_tokens", 0) or 0) + (
getattr(result, "_output_tokens", 0) or 0

View File

@ -14,15 +14,14 @@ from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from second_brain.config import DOMAINS, load_config
from second_brain.config import DOMAINS
from second_brain.database import get_database
from second_brain.models import Extraction, Source, SourceStatus, SourceType
from second_brain.models import Extraction, Source, SourceStatus
# ---------------------------------------------------------------------------
# App setup

View File

@ -0,0 +1,157 @@
"""Smoke test for the Postgres + embedding pipeline.
Runs only when SECOND_BRAIN_DATABASE_URL (or HERBYLAB_DATABASE_URL) is set
*and* an Ollama instance with nomic-embed-text is reachable. Otherwise the
test is skipped there is no in-memory fixture for pgvector, and we
explicitly want the smoke check to exercise the real wire format.
It covers:
1. SQLAlchemy can write a Source + Extraction into the `second_brain`
schema.
2. The embeddings module chunks the summary, calls Ollama, and writes
vector rows into public.embeddings keyed correctly.
3. Re-running embedding is idempotent (delete-before-insert wins).
"""
from __future__ import annotations
import os
import socket
import uuid
import httpx
import psycopg
import pytest
def _ollama_reachable() -> bool:
url = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
try:
r = httpx.get(f"{url}/api/tags", timeout=2.0)
if r.status_code != 200:
return False
models = [m["name"] for m in r.json().get("models", [])]
return any(m.startswith("nomic-embed-text") for m in models)
except (httpx.HTTPError, socket.error):
return False
def _db_reachable() -> bool:
url = os.environ.get("SECOND_BRAIN_DATABASE_URL") or os.environ.get(
"HERBYLAB_DATABASE_URL"
)
if not url:
return False
raw = url.replace("postgresql+psycopg://", "postgresql://", 1)
try:
with psycopg.connect(raw, connect_timeout=2) as conn: # noqa: F841
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(
not (_db_reachable() and _ollama_reachable()),
reason="Smoke test needs SECOND_BRAIN_DATABASE_URL + reachable Ollama (nomic-embed-text)",
)
def test_extraction_round_trip_with_embedding():
from second_brain.database import Database, reset_database_singleton
from second_brain.embeddings import close_pool, embed_extraction
from second_brain.models import (
Extraction,
Source,
SourceStatus,
SourceType,
utcnow,
)
reset_database_singleton()
db = Database()
# Unique URL so re-runs don't collide on the unique constraint.
unique = uuid.uuid4().hex[:8]
url = f"https://example.test/smoke/{unique}"
summary_text = (
"Smoke test extraction summary. "
"The second-brain pipeline embeds this string into Postgres. "
"Re-running the embed should be idempotent — delete-before-insert "
"keys on (source_schema, source_table, source_id, embedding_model)."
)
with db.session() as sess:
src = Source(
url=url,
title=f"smoke-test-{unique}",
domain="development",
source_type=SourceType.ARTICLE,
status=SourceStatus.ANALYZED,
ingested_at=utcnow(),
)
sess.add(src)
sess.flush()
ext = Extraction(source_id=src.id, summary=summary_text)
sess.add(ext)
sess.flush()
extraction_id = ext.id
source_id = src.id
# Read the configured model so the test doesn't break if the default
# is changed in settings.toml or via EMBEDDING_MODEL.
from second_brain.config import load_config
embedding_model = os.environ.get(
"EMBEDDING_MODEL",
load_config().embeddings.get("model", "nomic-embed-text"),
)
try:
n1 = embed_extraction(extraction_id, summary_text)
assert n1 is not None and n1 >= 1, f"first embed should write rows, got {n1}"
# Idempotency: re-embed should produce the same chunk count and not
# accumulate orphan rows.
n2 = embed_extraction(extraction_id, summary_text)
assert n2 == n1, f"re-embed should match first count: {n1} vs {n2}"
raw_url = (
os.environ.get("SECOND_BRAIN_DATABASE_URL")
or os.environ.get("HERBYLAB_DATABASE_URL")
).replace("postgresql+psycopg://", "postgresql://", 1)
with psycopg.connect(raw_url) as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*)
FROM public.embeddings
WHERE source_schema = 'second_brain'
AND source_table = 'extractions'
AND source_id = %s
AND embedding_model = %s
""",
(extraction_id, embedding_model),
)
(count,) = cur.fetchone()
assert count == n1, f"public.embeddings should hold {n1} rows, has {count}"
finally:
# Tidy up so the smoke test stays leak-free.
with db.session() as sess:
sess.query(Source).filter(Source.id == source_id).delete()
raw_url = (
os.environ.get("SECOND_BRAIN_DATABASE_URL")
or os.environ.get("HERBYLAB_DATABASE_URL")
).replace("postgresql+psycopg://", "postgresql://", 1)
with psycopg.connect(raw_url) as conn, conn.cursor() as cur:
cur.execute(
"""
DELETE FROM public.embeddings
WHERE source_schema = 'second_brain'
AND source_table = 'extractions'
AND source_id = %s
""",
(extraction_id,),
)
conn.commit()
close_pool()
reset_database_singleton()

303
uv.lock generated
View File

@ -2,6 +2,20 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@ -178,6 +192,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" },
]
[[package]]
name = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
]
[[package]]
name = "cuda-bindings"
version = "13.2.0"
@ -277,6 +375,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]]
name = "embedding-chunking"
version = "0.1.0"
source = { git = "https://gitea.plantbasedsoutherner.com/petal-power/embedding-chunking.git?tag=v0.1.0#176c04cd6c2232b77d6a3ebcfacbfc64311fd96c" }
dependencies = [
{ name = "tiktoken" },
]
[[package]]
name = "fastapi"
version = "0.136.3"
@ -457,6 +563,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@ -674,6 +789,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/bd/6e2b76a6c5dee10397db9c929f0c5066766ec1036046f0335b7ca7ca08b8/lxml_html_clean-0.4.5-py3-none-any.whl", hash = "sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746", size = 14573, upload-time = "2026-05-20T12:17:52.215Z" },
]
[[package]]
name = "mako"
version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@ -1020,6 +1147,94 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/35/8e/d36f8880bcf18ec026a55807d02fe4c7357da9f25aebd92f85178000c0dc/openai_whisper-20250625.tar.gz", hash = "sha256:37a91a3921809d9f44748ffc73c0a55c9f366c85a3ef5c2ae0cc09540432eb96", size = 803191, upload-time = "2025-06-26T01:06:13.34Z" }
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@ -1110,6 +1325,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1289,15 +1543,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]]
name = "second-brain"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "click" },
{ name = "embedding-chunking" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "openai-whisper" },
{ name = "psycopg", extra = ["binary"] },
{ name = "psycopg-pool" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "sqlalchemy" },
@ -1312,13 +1596,25 @@ api = [
{ name = "anthropic" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
{ name = "anthropic", marker = "extra == 'api'", specifier = ">=0.40.0" },
{ name = "click", specifier = ">=8.1.0" },
{ name = "embedding-chunking", git = "https://gitea.plantbasedsoutherner.com/petal-power/embedding-chunking.git?tag=v0.1.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "openai-whisper" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "psycopg-pool", specifier = ">=3.2" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
@ -1329,6 +1625,13 @@ requires-dist = [
]
provides-extras = ["api"]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]]
name = "setuptools"
version = "81.0.0"