Compare commits
6 Commits
4696d7f015
...
0d9ebc9cc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9ebc9cc7 | ||
|
|
f44ac2ff7c | ||
|
|
6c1445c8ed | ||
|
|
a2d46a7c6e | ||
|
|
2e07d7a8fe | ||
|
|
ceeae77e7d |
41
CLAUDE.md
41
CLAUDE.md
@ -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
52
alembic.ini
Normal 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
119
alembic/env.py
Normal 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
27
alembic/script.py.mako
Normal 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"}
|
||||
120
alembic/versions/4a1e2f6c9d10_v1_second_brain_pipeline_tables.py
Normal file
120
alembic/versions/4a1e2f6c9d10_v1_second_brain_pipeline_tables.py
Normal 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")
|
||||
@ -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"
|
||||
|
||||
69
config/settings.toml.example
Normal file
69
config/settings.toml.example
Normal 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
|
||||
@ -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/`?).
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
30
reviews/a-review-2026-05-24-2.md
Normal file
30
reviews/a-review-2026-05-24-2.md
Normal 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.
|
||||
65
reviews/a-review-2026-05-24-3.md
Normal file
65
reviews/a-review-2026-05-24-3.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
257
src/second_brain/embeddings/__init__.py
Normal file
257
src/second_brain/embeddings/__init__.py
Normal 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"]
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
157
tests/test_smoke_embedding.py
Normal file
157
tests/test_smoke_embedding.py
Normal 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
303
uv.lock
generated
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user