1751 lines
60 KiB
Markdown
1751 lines
60 KiB
Markdown
# LLM Foundation (P33) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Stand up the `llm` module's foundation — Ollama compose service, pgvector on the existing Postgres, chunker, multi-host embedding pool, and an incremental indexer over the regulations.gov comments and bib corpus.
|
||
|
||
**Architecture:** `src/llm` is a new skinny module (`stack[llm]` extra). Text sources (comment extraction `.md` files under `.state/comments/`, bib corpus attachments) → markdown-aware chunker with deterministic ids → embeddings via a least-loaded pool of Ollama hosts (rack 3060 + optional rig 4090 / laptop 5080) → `langchain-postgres` PGVector collections in a new `llm` database on the existing Bitnami Postgres (pgvector 0.8.1 confirmed available, `vector.so` present). Resume state lives in an `index_state` table keyed on (item_key, collection, content_hash).
|
||
|
||
**Tech Stack:** Python 3.12, httpx (Ollama `/api/embed`), langchain-postgres + psycopg3 + SQLAlchemy DSN, pgvector, typer CLI, pytest + unittest.mock.
|
||
|
||
**Tracker:** milestone P33 (id 34) — issues #563 (scaffold), #564 (ollama + bake-off), #565 (pgvector), #566 (chunker), #567 (indexer), #568 (multi-host dispatch). Spec: `docs/superpowers/specs/2026-07-16-llm-module-design.md`.
|
||
|
||
## Global Constraints
|
||
|
||
- Coverage bar is **99%** (`--cov-fail-under=99` in CI); every new source file ships with tests in the same commit.
|
||
- Ruff: `select = ["E", "F", "I"]`, line-length 88. Run `uv run ruff check src/ tests/ && uv run ruff format src/ tests/` before every commit.
|
||
- **Lazy conf imports**: `from conf import ...` only inside functions, never module top-level (repo convention; keeps optional extras importable).
|
||
- Mocking: stdlib `unittest.mock` + `monkeypatch` only — **no respx, no pytest-mock** (not in dev deps).
|
||
- Tests live in `tests/llm/` mirroring `src/llm/`, with empty `tests/llm/__init__.py`.
|
||
- New module must be added to `[tool.uv.build-backend] module-name` in pyproject.toml or it won't package.
|
||
- `.gitea/workflows/*.yml` are generated — never hand-edit; regen via `uv run python dev/scripts/gen_config.py` if stack.toml `[ci]` inputs change (this plan doesn't change them).
|
||
- Commit messages: conventional commits (`feat(llm): …`), reference issues with `(refs #N)`. **No Co-Authored-By trailers.**
|
||
- Postgres is reachable from containers at `postgres:5432` (storage net) and — after Task 2 — from the host at `127.0.0.1:5432`. Secrets come from `.env` via `os.environ`, never stack.toml.
|
||
- Ollama model names used throughout until the bake-off (Task 8) says otherwise: embed `nomic-embed-text` (768-dim), instruct `llama3.1:8b`.
|
||
|
||
---
|
||
|
||
### Task 1: Module scaffold, `stack[llm]` extra, `[llm]` config (#563)
|
||
|
||
**Files:**
|
||
- Create: `src/llm/__init__.py`, `src/llm/config.py`
|
||
- Create: `tests/llm/__init__.py` (empty), `tests/llm/test_config.py`
|
||
- Modify: `pyproject.toml` (extras + module-name list), `stack.toml` (`[llm]`, `[services]`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `conf.cfg` (attribute access over stack.toml).
|
||
- Produces: `llm.config.LlmConfig` (frozen dataclass) and `llm.config.load() -> LlmConfig`; `llm.config.pg_url(cfg: LlmConfig) -> str` returning a SQLAlchemy/psycopg URL. All later tasks import these exact names.
|
||
|
||
- [ ] **Step 1: Add config + packaging entries**
|
||
|
||
`stack.toml` — add after the `[bcda]` section:
|
||
|
||
```toml
|
||
[llm]
|
||
ollama = "http://127.0.0.1:11434" # host-side default; containers override via LLM_OLLAMA_HOSTS
|
||
embed_model = "nomic-embed-text" # 768-dim; bake-off (P33 #564) may revise
|
||
instruct_model = "llama3.1:8b" # bake-off may revise
|
||
embed_dim = 768
|
||
pg_host = "127.0.0.1" # host-side default; containers set LLM_PG_HOST=postgres
|
||
pg_port = 5432
|
||
pg_db = "llm"
|
||
pg_user = "llm"
|
||
```
|
||
|
||
`stack.toml` — in `[services]`, add:
|
||
|
||
```toml
|
||
ollama = "http://ollama:11434"
|
||
```
|
||
|
||
`pyproject.toml` — add to `[project.optional-dependencies]` (after `bls`):
|
||
|
||
```toml
|
||
llm = [
|
||
"stack[conf]",
|
||
"stack[bib]",
|
||
"httpx>=0.28.1",
|
||
"langchain-core>=0.3.0",
|
||
"langchain-ollama>=0.2.0",
|
||
"langchain-postgres>=0.0.12",
|
||
"psycopg[binary]>=3.2.0",
|
||
"sqlalchemy>=2.0.0",
|
||
]
|
||
```
|
||
|
||
Add `"stack[llm]"` to the `cli` extra list and to the `all` extra list. Add `"llm"` to `[tool.uv.build-backend] module-name` (keep the list alphabetized).
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`tests/llm/test_config.py`:
|
||
|
||
```python
|
||
"""llm.config — [llm] section parsing + env overrides."""
|
||
|
||
import pytest
|
||
|
||
from llm import config as llm_config
|
||
|
||
|
||
class TestLoad:
|
||
def test_defaults_from_stack_toml(self, monkeypatch):
|
||
monkeypatch.delenv("LLM_OLLAMA_HOSTS", raising=False)
|
||
monkeypatch.delenv("LLM_PG_HOST", raising=False)
|
||
cfg = llm_config.load()
|
||
assert cfg.ollama_hosts == ("http://127.0.0.1:11434",)
|
||
assert cfg.embed_model == "nomic-embed-text"
|
||
assert cfg.embed_dim == 768
|
||
assert cfg.pg_host == "127.0.0.1"
|
||
assert cfg.pg_db == "llm"
|
||
|
||
def test_env_overrides(self, monkeypatch):
|
||
monkeypatch.setenv(
|
||
"LLM_OLLAMA_HOSTS",
|
||
"http://rig:11434, http://laptop:11434",
|
||
)
|
||
monkeypatch.setenv("LLM_PG_HOST", "postgres")
|
||
cfg = llm_config.load()
|
||
assert cfg.ollama_hosts == ("http://rig:11434", "http://laptop:11434")
|
||
assert cfg.pg_host == "postgres"
|
||
|
||
|
||
class TestPgUrl:
|
||
def test_url_includes_password_from_env(self, monkeypatch):
|
||
monkeypatch.setenv("LLM_DB_PASSWORD", "s3cret")
|
||
cfg = llm_config.load()
|
||
url = llm_config.pg_url(cfg)
|
||
assert url == f"postgresql+psycopg://llm:s3cret@{cfg.pg_host}:5432/llm"
|
||
|
||
def test_missing_password_raises(self, monkeypatch):
|
||
monkeypatch.delenv("LLM_DB_PASSWORD", raising=False)
|
||
cfg = llm_config.load()
|
||
with pytest.raises(RuntimeError, match="LLM_DB_PASSWORD"):
|
||
llm_config.pg_url(cfg)
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `uv sync --extra llm && uv run pytest tests/llm/test_config.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'llm'`
|
||
|
||
- [ ] **Step 4: Write the implementation**
|
||
|
||
`src/llm/__init__.py` (bib-style: docstring + redundant-alias re-exports):
|
||
|
||
```python
|
||
"""llm: local RAG over rulemaking comments + the Zotero/bib corpus.
|
||
|
||
Embeds regulations.gov public comments (``doctype:comment`` in the bib
|
||
store) and the reference corpus into pgvector, and (P34/P35) exposes
|
||
retrieval, grounded generation, and closed-vocab tagging — all inference
|
||
on local Ollama hosts, never cloud APIs.
|
||
|
||
Architecture::
|
||
|
||
.state/comments/*.md + bib corpus (llm.source)
|
||
-> markdown-aware chunks (llm.chunk)
|
||
-> Ollama /api/embed, multi-host (llm.pool)
|
||
-> pgvector collections (llm.index)
|
||
|
||
Spec: docs/superpowers/specs/2026-07-16-llm-module-design.md
|
||
"""
|
||
|
||
from llm.config import LlmConfig as LlmConfig
|
||
from llm.config import load as load
|
||
from llm.config import pg_url as pg_url
|
||
```
|
||
|
||
`src/llm/config.py`:
|
||
|
||
```python
|
||
"""[llm] configuration — stack.toml section + env overrides.
|
||
|
||
Env contract:
|
||
LLM_OLLAMA_HOSTS comma-separated Ollama base URLs (beats [llm].ollama)
|
||
LLM_PG_HOST beats [llm].pg_host (containers set this to "postgres")
|
||
LLM_DB_PASSWORD required for pg_url(); lives in .env, never stack.toml
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LlmConfig:
|
||
ollama_hosts: tuple[str, ...]
|
||
embed_model: str
|
||
instruct_model: str
|
||
embed_dim: int
|
||
pg_host: str
|
||
pg_port: int
|
||
pg_db: str
|
||
pg_user: str
|
||
|
||
|
||
def load() -> LlmConfig:
|
||
"""Read the [llm] section; env vars override host-ish values."""
|
||
from conf import cfg
|
||
|
||
section = cfg.llm
|
||
hosts_env = os.environ.get("LLM_OLLAMA_HOSTS", "")
|
||
if hosts_env:
|
||
hosts = tuple(h.strip() for h in hosts_env.split(",") if h.strip())
|
||
else:
|
||
hosts = (str(section.ollama),)
|
||
return LlmConfig(
|
||
ollama_hosts=hosts,
|
||
embed_model=str(section.embed_model),
|
||
instruct_model=str(section.instruct_model),
|
||
embed_dim=int(section.embed_dim),
|
||
pg_host=os.environ.get("LLM_PG_HOST", str(section.pg_host)),
|
||
pg_port=int(section.pg_port),
|
||
pg_db=str(section.pg_db),
|
||
pg_user=str(section.pg_user),
|
||
)
|
||
|
||
|
||
def pg_url(cfg: LlmConfig) -> str:
|
||
"""SQLAlchemy/psycopg URL for the llm database.
|
||
|
||
Password comes from LLM_DB_PASSWORD only — secrets never live in
|
||
stack.toml.
|
||
"""
|
||
password = os.environ.get("LLM_DB_PASSWORD", "")
|
||
if not password:
|
||
raise RuntimeError(
|
||
"LLM_DB_PASSWORD not set — add it to .env (see Task 2 provisioning)"
|
||
)
|
||
return (
|
||
f"postgresql+psycopg://{cfg.pg_user}:{password}"
|
||
f"@{cfg.pg_host}:{cfg.pg_port}/{cfg.pg_db}"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests, lint, commit**
|
||
|
||
Run: `uv run pytest tests/llm/ -v` → PASS (4 tests). `uv run ruff check src/llm tests/llm && uv run ruff format src/llm tests/llm`
|
||
|
||
```bash
|
||
git add src/llm tests/llm pyproject.toml stack.toml uv.lock
|
||
git commit -m "feat(llm): module scaffold, stack[llm] extra, [llm] config (refs #563)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Postgres provisioning — llm role/db, pgvector, host port (#565)
|
||
|
||
**Files:**
|
||
- Modify: `src/api/auth/manifest.py` (POSTGRES_ROLES, POSTGRES_DATABASES, credential manifest)
|
||
- Modify: `compose.yml` (postgres `ports:`)
|
||
- Create: `src/llm/migrate.py`, `tests/llm/test_migrate.py`
|
||
- Modify: `tests/api/test_manifest.py` if role/db counts are asserted (check first: `grep -rn "POSTGRES_ROLES\|POSTGRES_DATABASES" tests/`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llm.config.load`, `llm.config.pg_url`.
|
||
- Produces: `llm.migrate.migrate(engine) -> None` (idempotent; creates `index_state`), `llm.migrate.ensure_hnsw(engine, dim: int) -> None`, `llm.migrate.INDEX_STATE_DDL: str`. Task 7 (indexer) calls both.
|
||
|
||
- [ ] **Step 1: Register the credential + role/db in the auth manifest**
|
||
|
||
In `src/api/auth/manifest.py`, extend the two dicts (exact current values shown; add the `llm` lines):
|
||
|
||
```python
|
||
POSTGRES_ROLES = {
|
||
"git": "GITEA_DB_PASSWORD",
|
||
"nessie": "NESSIE_DB_PASSWORD",
|
||
"polaris": "POLARIS_DB_PASSWORD",
|
||
"llm": "LLM_DB_PASSWORD",
|
||
}
|
||
|
||
POSTGRES_DATABASES = {
|
||
"git": "gitea",
|
||
"nessie": "nessie",
|
||
"polaris": "polaris",
|
||
"llm": "llm",
|
||
}
|
||
```
|
||
|
||
Also add an `LLM_DB_PASSWORD` credential entry following the exact pattern of the `NESSIE_DB_PASSWORD` entry in the manifest list (same tier/provisioner/format — copy that entry and change the env_var and description). Read the file first; match whatever dataclass/fields the neighboring entries use.
|
||
|
||
- [ ] **Step 2: Expose postgres to the host (loopback only)**
|
||
|
||
In `compose.yml`, add to the `postgres` service (after `networks:`):
|
||
|
||
```yaml
|
||
ports:
|
||
# Loopback-only: host-side llm batch runs (indexer/tagger) need
|
||
# pgvector; everything else still uses the storage net.
|
||
- "127.0.0.1:5432:5432"
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing migrate test**
|
||
|
||
`tests/llm/test_migrate.py` — pure unit test: assert DDL content and that `migrate()` executes each statement on a mock engine (no live postgres in CI):
|
||
|
||
```python
|
||
"""llm.migrate — idempotent DDL for the llm database."""
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
from llm import migrate
|
||
|
||
|
||
class TestDdl:
|
||
def test_index_state_ddl_is_idempotent_sql(self):
|
||
assert "CREATE TABLE IF NOT EXISTS index_state" in migrate.INDEX_STATE_DDL
|
||
assert "PRIMARY KEY (item_key, collection)" in migrate.INDEX_STATE_DDL
|
||
|
||
def test_migrate_executes_ddl(self):
|
||
engine = MagicMock()
|
||
conn = engine.begin.return_value.__enter__.return_value
|
||
migrate.migrate(engine)
|
||
executed = " ".join(
|
||
str(call.args[0]) for call in conn.execute.call_args_list
|
||
)
|
||
assert "index_state" in executed
|
||
|
||
def test_ensure_hnsw_types_column_then_indexes(self):
|
||
engine = MagicMock()
|
||
conn = engine.begin.return_value.__enter__.return_value
|
||
migrate.ensure_hnsw(engine, 768)
|
||
executed = " ".join(
|
||
str(call.args[0]) for call in conn.execute.call_args_list
|
||
)
|
||
assert "vector(768)" in executed
|
||
assert "USING hnsw" in executed
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it fails**
|
||
|
||
Run: `uv run pytest tests/llm/test_migrate.py -v`
|
||
Expected: FAIL with `ImportError: cannot import name 'migrate'`
|
||
|
||
- [ ] **Step 5: Write the implementation**
|
||
|
||
`src/llm/migrate.py`:
|
||
|
||
```python
|
||
"""Idempotent DDL for the llm database.
|
||
|
||
The ``vector`` extension and the ``llm`` role/database are provisioned by
|
||
the auth manifest (``api.auth.provision.bootstrap_postgres``) as superuser;
|
||
everything here runs as the unprivileged ``llm`` role.
|
||
|
||
``index_state`` is the resume ledger: one row per (item, collection) with
|
||
the content hash that was last embedded. The indexer skips items whose
|
||
hash is unchanged and re-embeds the rest.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
INDEX_STATE_DDL = """
|
||
CREATE TABLE IF NOT EXISTS index_state (
|
||
item_key TEXT NOT NULL,
|
||
collection TEXT NOT NULL,
|
||
content_hash TEXT NOT NULL,
|
||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (item_key, collection)
|
||
)
|
||
"""
|
||
|
||
# langchain-postgres creates langchain_pg_embedding with an untyped vector
|
||
# column; HNSW needs a typed one. ALTER is a no-op when already typed.
|
||
_HNSW_DDL = [
|
||
"ALTER TABLE langchain_pg_embedding "
|
||
"ALTER COLUMN embedding TYPE vector({dim})",
|
||
"CREATE INDEX IF NOT EXISTS ix_embedding_hnsw "
|
||
"ON langchain_pg_embedding USING hnsw (embedding vector_cosine_ops)",
|
||
]
|
||
|
||
|
||
def migrate(engine: Engine) -> None:
|
||
"""Create llm-owned tables. Safe to run on every start."""
|
||
with engine.begin() as conn:
|
||
conn.execute(text(INDEX_STATE_DDL))
|
||
|
||
|
||
def ensure_hnsw(engine: Engine, dim: int) -> None:
|
||
"""Type the embedding column and build the HNSW index.
|
||
|
||
Call after the PGVector store has created its tables (first
|
||
``add_embeddings``), not before.
|
||
"""
|
||
with engine.begin() as conn:
|
||
for ddl in _HNSW_DDL:
|
||
conn.execute(text(ddl.format(dim=dim)))
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests, lint**
|
||
|
||
Run: `uv run pytest tests/llm/ tests/api/ -q` → PASS. Ruff check + format.
|
||
|
||
- [ ] **Step 7: Provision the live database**
|
||
|
||
```bash
|
||
# generate + store the password (provision flow owns .env writes; if the
|
||
# manifest provisioner requires the full bootstrap, use that instead):
|
||
uv run python -m api.auth bootstrap --only LLM_DB_PASSWORD $(git rev-parse HEAD) \
|
||
|| echo "LLM_DB_PASSWORD=$(openssl rand -hex 24)" >> .env
|
||
docker compose up -d postgres # picks up the new ports: mapping
|
||
# role + db (idempotent, mirrors bootstrap_postgres):
|
||
PW_SUPER=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)
|
||
PW_LLM=$(grep '^LLM_DB_PASSWORD=' .env | cut -d= -f2-)
|
||
docker exec -e PGPASSWORD="$PW_SUPER" postgres psql -U postgres <<SQL
|
||
DO \$\$ BEGIN CREATE ROLE llm LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END \$\$;
|
||
ALTER ROLE llm PASSWORD '$PW_LLM';
|
||
SELECT 'CREATE DATABASE llm OWNER llm' WHERE NOT EXISTS
|
||
(SELECT FROM pg_database WHERE datname = 'llm')\gexec
|
||
SQL
|
||
docker exec -e PGPASSWORD="$PW_SUPER" postgres psql -U postgres -d llm \
|
||
-c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||
```
|
||
|
||
Note: check whether `api.auth bootstrap` supports `--only`; if not, append the openssl line to `.env` by hand and file nothing — the manifest entry keeps rotation covered going forward.
|
||
|
||
- [ ] **Step 8: Verify from the host, then commit**
|
||
|
||
```bash
|
||
PW_LLM=$(grep '^LLM_DB_PASSWORD=' .env | cut -d= -f2-)
|
||
uv run python -c "
|
||
import os; os.environ['LLM_DB_PASSWORD'] = '$PW_LLM'
|
||
import sqlalchemy, llm
|
||
from llm import migrate
|
||
eng = sqlalchemy.create_engine(llm.pg_url(llm.load()))
|
||
migrate.migrate(eng)
|
||
print(eng.connect().execute(sqlalchemy.text('SELECT 1')).scalar())
|
||
"
|
||
```
|
||
|
||
Expected output: `1`.
|
||
|
||
```bash
|
||
git add src/api/auth/manifest.py compose.yml src/llm/migrate.py tests/llm/test_migrate.py
|
||
git commit -m "feat(llm): pgvector provisioning — llm role/db, loopback port, migrate (refs #565)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Ollama compose service (#564, infra half)
|
||
|
||
**Files:**
|
||
- Modify: `compose.yml` (new `ollama` service + `ollama_models` volume)
|
||
|
||
**Interfaces:**
|
||
- Produces: Ollama API at `http://127.0.0.1:11434` (host/LAN) and `http://ollama:11434` (data net). Tasks 4, 7, 8 depend on it.
|
||
|
||
- [ ] **Step 1: Add the service**
|
||
|
||
In `compose.yml`, after the `notebooks` block (GPU convention: `deploy.resources.reservations.devices`, like notebooks — not the legacy `runtime: nvidia`):
|
||
|
||
```yaml
|
||
ollama:
|
||
image: ollama/ollama:latest
|
||
container_name: ollama
|
||
networks:
|
||
- data
|
||
ports:
|
||
# Host + LAN: the rack's Ollama is one endpoint in the multi-host
|
||
# pool (rig 4090 / laptop 5080 serve the same API on the LAN).
|
||
- "11434:11434"
|
||
environment:
|
||
# Unload models after idle so the shared 3060 frees VRAM for
|
||
# notebooks/zotero.
|
||
- OLLAMA_KEEP_ALIVE=5m
|
||
volumes:
|
||
- ollama_models:/root/.ollama
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices:
|
||
- driver: nvidia
|
||
count: 1
|
||
capabilities: [gpu]
|
||
healthcheck:
|
||
test: ["CMD", "ollama", "ls"]
|
||
interval: 30s
|
||
timeout: 10s
|
||
retries: 3
|
||
start_period: 30s
|
||
labels:
|
||
- "promtail=true"
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
restart: unless-stopped
|
||
```
|
||
|
||
Add `ollama_models:` to the top-level `volumes:` block.
|
||
|
||
- [ ] **Step 2: Start, pull models, verify**
|
||
|
||
```bash
|
||
docker compose up -d ollama
|
||
docker exec ollama ollama pull nomic-embed-text
|
||
docker exec ollama ollama pull llama3.1:8b
|
||
curl -s http://127.0.0.1:11434/api/tags | python3 -m json.tool | grep name
|
||
curl -s http://127.0.0.1:11434/api/embed \
|
||
-d '{"model":"nomic-embed-text","input":["hello"]}' \
|
||
| python3 -c "import json,sys; e=json.load(sys.stdin)['embeddings'][0]; print(len(e))"
|
||
nvidia-smi --query-gpu=memory.used --format=csv # then again after ~5m idle
|
||
```
|
||
|
||
Expected: both models listed; embed returns `768`; VRAM drops back after keep-alive expiry.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add compose.yml
|
||
git commit -m "feat(llm): ollama compose service — GPU, idle unload, models volume (refs #564)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Multi-host Ollama pool (#568)
|
||
|
||
**Files:**
|
||
- Create: `src/llm/pool.py`, `tests/llm/test_pool.py`
|
||
- Modify: `src/llm/__init__.py` (re-export `HostPool`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llm.config.LlmConfig`.
|
||
- Produces (Tasks 7–8 and P34/P35 consume these exact names):
|
||
- `class HostPool` with `HostPool(hosts: Sequence[str])`, `HostPool.from_config(cfg: LlmConfig) -> HostPool`, `check(model: str) -> list[str]` (drops hosts that are down or lack the model; raises `RuntimeError` if none remain), `acquire()` context manager yielding the least-in-flight base URL.
|
||
- `embed_texts(pool: HostPool, model: str, texts: Sequence[str], *, batch_size: int = 64) -> list[list[float]]` — order-preserving, fans batches across hosts with threads.
|
||
- `class PoolEmbeddings(Embeddings)` — langchain adapter: `PoolEmbeddings(pool, model)`, `embed_documents`, `embed_query`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
`tests/llm/test_pool.py`:
|
||
|
||
```python
|
||
"""llm.pool — least-loaded fan-out across Ollama hosts."""
|
||
|
||
import json
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from llm.pool import HostPool, PoolEmbeddings, embed_texts
|
||
|
||
H1, H2 = "http://h1:11434", "http://h2:11434"
|
||
|
||
|
||
def _resp(payload, status=200):
|
||
r = MagicMock()
|
||
r.status_code = status
|
||
r.json.return_value = payload
|
||
return r
|
||
|
||
|
||
class TestCheck:
|
||
@patch("llm.pool.httpx.Client")
|
||
def test_drops_host_missing_model(self, MockClient):
|
||
client = MockClient.return_value.__enter__.return_value
|
||
client.get.side_effect = [
|
||
_resp({"models": [{"name": "nomic-embed-text:latest"}]}),
|
||
_resp({"models": [{"name": "other:latest"}]}),
|
||
]
|
||
pool = HostPool([H1, H2])
|
||
assert pool.check("nomic-embed-text") == [H1]
|
||
|
||
@patch("llm.pool.httpx.Client")
|
||
def test_drops_unreachable_host(self, MockClient):
|
||
import httpx
|
||
|
||
client = MockClient.return_value.__enter__.return_value
|
||
client.get.side_effect = [
|
||
_resp({"models": [{"name": "m:latest"}]}),
|
||
httpx.ConnectError("down"),
|
||
]
|
||
pool = HostPool([H1, H2])
|
||
assert pool.check("m") == [H1]
|
||
|
||
@patch("llm.pool.httpx.Client")
|
||
def test_no_hosts_left_raises(self, MockClient):
|
||
client = MockClient.return_value.__enter__.return_value
|
||
client.get.return_value = _resp({"models": []})
|
||
with pytest.raises(RuntimeError, match="no Ollama host"):
|
||
HostPool([H1]).check("m")
|
||
|
||
|
||
class TestAcquire:
|
||
def test_prefers_least_in_flight(self):
|
||
pool = HostPool([H1, H2])
|
||
with pool.acquire() as first, pool.acquire() as second:
|
||
assert {first, second} == {H1, H2}
|
||
|
||
def test_released_host_is_reused(self):
|
||
pool = HostPool([H1, H2])
|
||
with pool.acquire() as first:
|
||
pass
|
||
with pool.acquire() as a, pool.acquire() as b:
|
||
assert {a, b} == {H1, H2}
|
||
assert first in {H1, H2}
|
||
|
||
|
||
class TestEmbedTexts:
|
||
@patch("llm.pool.httpx.Client")
|
||
def test_order_preserved_across_batches(self, MockClient):
|
||
client = MockClient.return_value.__enter__.return_value
|
||
|
||
def fake_post(url, json=None, **kw):
|
||
return _resp(
|
||
{"embeddings": [[float(len(t))] for t in json["input"]]}
|
||
)
|
||
|
||
client.post.side_effect = fake_post
|
||
pool = HostPool([H1, H2])
|
||
out = embed_texts(pool, "m", ["a", "bb", "ccc"], batch_size=1)
|
||
assert out == [[1.0], [2.0], [3.0]]
|
||
|
||
@patch("llm.pool.httpx.Client")
|
||
def test_http_error_raises(self, MockClient):
|
||
client = MockClient.return_value.__enter__.return_value
|
||
resp = _resp({}, status=500)
|
||
resp.raise_for_status.side_effect = Exception("boom")
|
||
client.post.return_value = resp
|
||
with pytest.raises(Exception, match="boom"):
|
||
embed_texts(HostPool([H1]), "m", ["a"])
|
||
|
||
|
||
class TestPoolEmbeddings:
|
||
@patch("llm.pool.embed_texts")
|
||
def test_adapter_delegates(self, mock_embed):
|
||
mock_embed.return_value = [[0.1]]
|
||
pool = HostPool([H1])
|
||
emb = PoolEmbeddings(pool, "m")
|
||
assert emb.embed_documents(["x"]) == [[0.1]]
|
||
assert emb.embed_query("x") == [0.1]
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `uv run pytest tests/llm/test_pool.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'llm.pool'`
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
`src/llm/pool.py`:
|
||
|
||
```python
|
||
"""Least-loaded fan-out across Ollama hosts.
|
||
|
||
Remote GPUs are plain Ollama endpoints (rig 4090 / laptop 5080 run
|
||
``ollama serve``); this pool spreads embed calls across them while all
|
||
bib/pgvector I/O stays on the server. Uses the raw ``/api/embed`` HTTP API
|
||
rather than langchain-ollama so one pool can juggle N base URLs;
|
||
``PoolEmbeddings`` adapts it back to the langchain interface for PGVector.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from contextlib import contextmanager
|
||
from typing import Iterator, Sequence
|
||
|
||
import httpx
|
||
from langchain_core.embeddings import Embeddings
|
||
|
||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
|
||
|
||
|
||
class HostPool:
|
||
"""Tracks in-flight requests per host; hands out the idlest one."""
|
||
|
||
def __init__(self, hosts: Sequence[str]) -> None:
|
||
self._lock = threading.Lock()
|
||
self._in_flight: dict[str, int] = {h.rstrip("/"): 0 for h in hosts}
|
||
|
||
@classmethod
|
||
def from_config(cls, cfg) -> "HostPool":
|
||
return cls(cfg.ollama_hosts)
|
||
|
||
@property
|
||
def hosts(self) -> list[str]:
|
||
return list(self._in_flight)
|
||
|
||
def check(self, model: str) -> list[str]:
|
||
"""Keep only hosts that are up and serve ``model``.
|
||
|
||
Ollama tags models ``name:latest``; match on the bare prefix.
|
||
"""
|
||
alive: list[str] = []
|
||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||
for host in self.hosts:
|
||
try:
|
||
resp = client.get(f"{host}/api/tags")
|
||
names = {
|
||
m["name"].split(":")[0]
|
||
for m in resp.json().get("models", [])
|
||
}
|
||
if model.split(":")[0] in names:
|
||
alive.append(host)
|
||
except httpx.HTTPError:
|
||
continue
|
||
with self._lock:
|
||
self._in_flight = {h: 0 for h in alive}
|
||
if not alive:
|
||
raise RuntimeError(
|
||
f"no Ollama host in pool serves {model!r} — "
|
||
f"pull it or fix LLM_OLLAMA_HOSTS"
|
||
)
|
||
return alive
|
||
|
||
@contextmanager
|
||
def acquire(self) -> Iterator[str]:
|
||
with self._lock:
|
||
host = min(self._in_flight, key=self._in_flight.__getitem__)
|
||
self._in_flight[host] += 1
|
||
try:
|
||
yield host
|
||
finally:
|
||
with self._lock:
|
||
self._in_flight[host] -= 1
|
||
|
||
|
||
def embed_texts(
|
||
pool: HostPool,
|
||
model: str,
|
||
texts: Sequence[str],
|
||
*,
|
||
batch_size: int = 64,
|
||
) -> list[list[float]]:
|
||
"""Embed ``texts`` in order, fanning batches across the pool."""
|
||
batches = [
|
||
(i, list(texts[i : i + batch_size]))
|
||
for i in range(0, len(texts), batch_size)
|
||
]
|
||
results: dict[int, list[list[float]]] = {}
|
||
|
||
def run(start: int, batch: list[str]) -> None:
|
||
with pool.acquire() as host, httpx.Client(timeout=_TIMEOUT) as client:
|
||
resp = client.post(
|
||
f"{host}/api/embed", json={"model": model, "input": batch}
|
||
)
|
||
resp.raise_for_status()
|
||
results[start] = resp.json()["embeddings"]
|
||
|
||
workers = max(1, min(len(pool.hosts) * 2, len(batches)))
|
||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||
for future in [ex.submit(run, s, b) for s, b in batches]:
|
||
future.result()
|
||
return [vec for start in sorted(results) for vec in results[start]]
|
||
|
||
|
||
class PoolEmbeddings(Embeddings):
|
||
"""langchain adapter so PGVector can query through the pool."""
|
||
|
||
def __init__(self, pool: HostPool, model: str) -> None:
|
||
self._pool = pool
|
||
self._model = model
|
||
|
||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||
return embed_texts(self._pool, self._model, texts)
|
||
|
||
def embed_query(self, text: str) -> list[float]:
|
||
return self.embed_documents([text])[0]
|
||
```
|
||
|
||
Add to `src/llm/__init__.py`:
|
||
|
||
```python
|
||
from llm.pool import HostPool as HostPool
|
||
from llm.pool import PoolEmbeddings as PoolEmbeddings
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `uv run pytest tests/llm/test_pool.py -v` → PASS (8 tests). Ruff check + format.
|
||
|
||
- [ ] **Step 5: Live smoke against the rack Ollama, then commit**
|
||
|
||
```bash
|
||
uv run python -c "
|
||
from llm.pool import HostPool, embed_texts
|
||
p = HostPool(['http://127.0.0.1:11434'])
|
||
print(p.check('nomic-embed-text'))
|
||
print(len(embed_texts(p, 'nomic-embed-text', ['hello world'])[0]))
|
||
"
|
||
```
|
||
|
||
Expected: `['http://127.0.0.1:11434']` then `768`.
|
||
|
||
```bash
|
||
git add src/llm tests/llm
|
||
git commit -m "feat(llm): multi-host Ollama pool — least-loaded embed fan-out (refs #568)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Chunker (#566)
|
||
|
||
**Files:**
|
||
- Create: `src/llm/chunk.py`, `tests/llm/test_chunk.py`
|
||
|
||
**Interfaces:**
|
||
- Produces (Tasks 6–7 consume):
|
||
- `@dataclass(frozen=True) Doc: key: str; text: str; metadata: dict[str, str]`
|
||
- `@dataclass(frozen=True) Chunk: id: str; text: str; metadata: dict[str, str]`
|
||
- `chunk_doc(doc: Doc, *, target_chars: int = 2000, overlap_chars: int = 200) -> list[Chunk]`
|
||
- `content_hash(text: str) -> str` (sha256 hex, also the resume key in `index_state`)
|
||
- Chunk id format: `f"{doc.key}:{content_hash(doc.text)[:12]}:{seq:04d}"`; chunk metadata = doc metadata + `{"item_key": doc.key, "seq": str(seq)}`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
`tests/llm/test_chunk.py`:
|
||
|
||
```python
|
||
"""llm.chunk — markdown-aware chunking with deterministic ids."""
|
||
|
||
from llm.chunk import Doc, chunk_doc, content_hash
|
||
|
||
FRONTMATTER_DOC = """---
|
||
comment_id: CMS-2019-0111-0042
|
||
docket_id: CMS-2019-0111
|
||
---
|
||
|
||
# Re: CY 2020 PFS Proposed Rule
|
||
|
||
We object to the E/M consolidation.
|
||
|
||
## Telehealth
|
||
|
||
Originating-site rules should be relaxed.
|
||
"""
|
||
|
||
|
||
def _doc(text, key="K1"):
|
||
return Doc(key=key, text=text, metadata={"docket": "CMS-2019-0111"})
|
||
|
||
|
||
class TestChunkDoc:
|
||
def test_strips_yaml_frontmatter(self):
|
||
chunks = chunk_doc(_doc(FRONTMATTER_DOC))
|
||
assert "comment_id:" not in chunks[0].text
|
||
assert chunks[0].text.startswith("# Re:")
|
||
|
||
def test_deterministic_ids(self):
|
||
a = chunk_doc(_doc(FRONTMATTER_DOC))
|
||
b = chunk_doc(_doc(FRONTMATTER_DOC))
|
||
assert [c.id for c in a] == [c.id for c in b]
|
||
h = content_hash(FRONTMATTER_DOC)
|
||
assert a[0].id == f"K1:{h[:12]}:0000"
|
||
|
||
def test_metadata_carries_item_key_and_seq(self):
|
||
chunks = chunk_doc(_doc(FRONTMATTER_DOC))
|
||
assert chunks[0].metadata["item_key"] == "K1"
|
||
assert chunks[0].metadata["docket"] == "CMS-2019-0111"
|
||
assert chunks[0].metadata["seq"] == "0"
|
||
|
||
def test_splits_on_headings_before_size(self):
|
||
text = "# A\n\n" + "para. " * 100 + "\n\n# B\n\nshort."
|
||
chunks = chunk_doc(_doc(text), target_chars=300)
|
||
assert all(len(c.text) <= 300 + 200 for c in chunks)
|
||
assert any(c.text.lstrip().startswith("# B") for c in chunks)
|
||
|
||
def test_long_paragraph_hard_wrapped_with_overlap(self):
|
||
text = "x" * 5000
|
||
chunks = chunk_doc(_doc(text), target_chars=2000, overlap_chars=200)
|
||
assert len(chunks) == 3
|
||
assert chunks[1].text[:200] == chunks[0].text[-200:]
|
||
|
||
def test_empty_and_whitespace_yield_nothing(self):
|
||
assert chunk_doc(_doc("")) == []
|
||
assert chunk_doc(_doc(" \n\n ")) == []
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `uv run pytest tests/llm/test_chunk.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'llm.chunk'`
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
`src/llm/chunk.py`:
|
||
|
||
```python
|
||
"""Markdown-aware chunking with deterministic ids.
|
||
|
||
Ids are ``{item_key}:{content_hash[:12]}:{seq:04d}`` — same input, same
|
||
ids — so pgvector upserts are idempotent and ``index_state`` can skip
|
||
unchanged items by comparing hashes.
|
||
|
||
Sizes are in characters (~4 chars/token; 2000 chars ≈ 500 tokens, inside
|
||
every candidate embed model's window).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import re
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Doc:
|
||
key: str
|
||
text: str
|
||
metadata: dict[str, str]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Chunk:
|
||
id: str
|
||
text: str
|
||
metadata: dict[str, str]
|
||
|
||
|
||
def content_hash(text: str) -> str:
|
||
return hashlib.sha256(text.encode()).hexdigest()
|
||
|
||
|
||
_FRONTMATTER = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
|
||
_HEADING = re.compile(r"^#{1,6} ", re.MULTILINE)
|
||
|
||
|
||
def _sections(text: str) -> list[str]:
|
||
"""Split at markdown headings, keeping the heading with its body."""
|
||
starts = [m.start() for m in _HEADING.finditer(text)]
|
||
if not starts:
|
||
return [text]
|
||
bounds = ([0] if starts[0] != 0 else []) + starts + [len(text)]
|
||
return [text[a:b] for a, b in zip(bounds, bounds[1:])]
|
||
|
||
|
||
def _pack(section: str, target: int, overlap: int) -> list[str]:
|
||
"""Greedily pack paragraphs; hard-wrap oversized ones with overlap."""
|
||
paras = [p for p in re.split(r"\n\n+", section) if p.strip()]
|
||
pieces: list[str] = []
|
||
buf = ""
|
||
for para in paras:
|
||
if buf and len(buf) + len(para) + 2 > target:
|
||
pieces.append(buf)
|
||
buf = ""
|
||
if len(para) > target:
|
||
step = target - overlap
|
||
for i in range(0, len(para), step):
|
||
pieces.append(para[max(0, i - overlap) if i else 0 : i + step + (overlap if i else 0)])
|
||
continue
|
||
buf = f"{buf}\n\n{para}" if buf else para
|
||
if buf:
|
||
pieces.append(buf)
|
||
return pieces
|
||
|
||
|
||
def chunk_doc(
|
||
doc: Doc, *, target_chars: int = 2000, overlap_chars: int = 200
|
||
) -> list[Chunk]:
|
||
body = _FRONTMATTER.sub("", doc.text).strip()
|
||
if not body:
|
||
return []
|
||
prefix = f"{doc.key}:{content_hash(doc.text)[:12]}"
|
||
texts = [
|
||
piece
|
||
for section in _sections(body)
|
||
for piece in _pack(section, target_chars, overlap_chars)
|
||
]
|
||
return [
|
||
Chunk(
|
||
id=f"{prefix}:{seq:04d}",
|
||
text=piece,
|
||
metadata={**doc.metadata, "item_key": doc.key, "seq": str(seq)},
|
||
)
|
||
for seq, piece in enumerate(texts)
|
||
]
|
||
```
|
||
|
||
Note: `test_long_paragraph_hard_wrapped_with_overlap` pins exact overlap
|
||
semantics — get the hard-wrap slice math right against the test (first
|
||
window `[0 : step + 0]`… adjust implementation until the three-chunk /
|
||
200-char-overlap assertions pass, keeping windows ≤ target+overlap).
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `uv run pytest tests/llm/test_chunk.py -v` → PASS (6 tests). Ruff check + format.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/llm/chunk.py tests/llm/test_chunk.py
|
||
git commit -m "feat(llm): markdown-aware chunker with deterministic ids (refs #566)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Document sources — comments + corpus (#566)
|
||
|
||
**Files:**
|
||
- Create: `src/llm/source.py`, `tests/llm/test_source.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llm.chunk.Doc`; `bib.store.Store` (`list_items(tag=..., limit=...)`, `store._con()` for attachments/URL SQL — the established convention, see `src/cli/comments.py:75`); `rex.comments.combine.parse_combined`; extraction layout `.state/comments/<docket>/<comment_id>/{combined.md, <attachment>.md}`.
|
||
- Produces (Task 7 consumes):
|
||
- `iter_comment_docs(store: Store, *, docket: str = "", root: Path | None = None) -> Iterator[Doc]` — one `Doc` per comment; text = combined.md body when extracted, else bib `abstract`; metadata keys: `docket`, `comment_id`, `doctype` (= `"comment"`), `year` (from `year:` tag when present).
|
||
- `iter_corpus_docs(store: Store, *, tag: str = "") -> Iterator[Doc]` — non-comment items; text = extracted attachment text (PyMuPDF via `rex`) joined with abstract; metadata: `doctype` (= item_type), `year`.
|
||
- `comment_key_map(store: Store, docket: str) -> dict[str, tuple[str, str]]` — comment_id → (bib item key, year) via `items.url LIKE 'https://www.regulations.gov/comment/%'`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
`tests/llm/test_source.py` (in-memory bib Store + tmp extraction tree — no mocks needed for bib):
|
||
|
||
```python
|
||
"""llm.source — comment + corpus Doc iterators."""
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from bib.item import Item
|
||
from bib.store import Store
|
||
from llm.source import comment_key_map, iter_comment_docs, iter_corpus_docs
|
||
|
||
DOCKET = "CMS-2019-0111"
|
||
CID = f"{DOCKET}-0042"
|
||
|
||
COMBINED = f"""---
|
||
comment_id: {CID}
|
||
docket_id: {DOCKET}
|
||
---
|
||
|
||
We object to the E/M consolidation.
|
||
"""
|
||
|
||
|
||
@pytest.fixture
|
||
def store():
|
||
s = Store(":memory:")
|
||
key = s.create(
|
||
Item(
|
||
item_type="report",
|
||
title="A comment",
|
||
url=f"https://www.regulations.gov/comment/{CID}",
|
||
abstract="Inline abstract body.",
|
||
)
|
||
)
|
||
for tag in (f"docket:{DOCKET}", "doctype:comment", "year:2019"):
|
||
s.add_tag(key, tag)
|
||
s._comment_key = key # test convenience
|
||
return s
|
||
|
||
|
||
@pytest.fixture
|
||
def root(tmp_path):
|
||
d = tmp_path / DOCKET / CID
|
||
d.mkdir(parents=True)
|
||
(d / "combined.md").write_text(COMBINED)
|
||
return tmp_path
|
||
|
||
|
||
class TestCommentDocs:
|
||
def test_extracted_comment_uses_combined_body(self, store, root):
|
||
docs = list(iter_comment_docs(store, docket=DOCKET, root=root))
|
||
assert len(docs) == 1
|
||
assert docs[0].key == store._comment_key
|
||
assert "E/M consolidation" in docs[0].text
|
||
assert docs[0].metadata == {
|
||
"docket": DOCKET,
|
||
"comment_id": CID,
|
||
"doctype": "comment",
|
||
"year": "2019",
|
||
}
|
||
|
||
def test_unextracted_comment_falls_back_to_abstract(self, store, tmp_path):
|
||
docs = list(iter_comment_docs(store, docket=DOCKET, root=tmp_path))
|
||
assert docs[0].text == "Inline abstract body."
|
||
|
||
def test_docket_filter_excludes_others(self, store, root):
|
||
assert list(iter_comment_docs(store, docket="CMS-2021-0119", root=root)) == []
|
||
|
||
|
||
class TestCommentKeyMap:
|
||
def test_maps_comment_id_to_key_and_year(self, store):
|
||
mapping = comment_key_map(store, DOCKET)
|
||
assert mapping[CID] == (store._comment_key, "2019")
|
||
|
||
|
||
class TestCorpusDocs:
|
||
def test_non_comment_item_with_abstract(self, store):
|
||
key = store.create(
|
||
Item(item_type="rule", title="Final rule", abstract="Rule text.")
|
||
)
|
||
store.add_tag(key, "year:2020")
|
||
docs = list(iter_corpus_docs(store))
|
||
assert [d.key for d in docs] == [key]
|
||
assert docs[0].text == "Rule text."
|
||
assert docs[0].metadata["doctype"] == "rule"
|
||
|
||
def test_comments_excluded_from_corpus(self, store):
|
||
assert all(
|
||
d.metadata["doctype"] != "comment" for d in iter_corpus_docs(store)
|
||
)
|
||
```
|
||
|
||
Adjust the `Item(...)` constructor kwargs to the real `bib.item.Item` model
|
||
(read `src/bib/item.py` first — use whatever concrete subclass or field
|
||
names it defines; the assertions above are the contract, not the fixture
|
||
syntax).
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `uv run pytest tests/llm/test_source.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'llm.source'`
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
`src/llm/source.py`:
|
||
|
||
```python
|
||
"""Doc iterators over the bib store + comment extraction tree.
|
||
|
||
Comments: text prefers the #253 extraction output
|
||
(``.state/comments/<docket>/<comment_id>/combined.md`` body via
|
||
``rex.comments.combine.parse_combined``); falls back to the bib
|
||
``abstract`` for attachment-less comments. Corpus: every non-comment item;
|
||
attachment text extracted with the same rex extractors, joined with the
|
||
abstract.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import Iterator
|
||
|
||
from bib.store import Store
|
||
|
||
from llm.chunk import Doc
|
||
|
||
_COMMENT_URL_PREFIX = "https://www.regulations.gov/comment/"
|
||
|
||
|
||
def _default_root() -> Path:
|
||
from conf import ROOT
|
||
|
||
return ROOT / ".state" / "comments"
|
||
|
||
|
||
def _year_of(store: Store, item_key: str) -> str:
|
||
row = store._con().execute(
|
||
"SELECT t.name FROM tags t "
|
||
"JOIN item_tags it ON it.tag_id = t.id "
|
||
"JOIN items i ON i.id = it.item_id "
|
||
"WHERE i.key = ? AND t.name LIKE 'year:%'",
|
||
(item_key,),
|
||
).fetchone()
|
||
return row[0].split(":", 1)[1] if row else ""
|
||
|
||
|
||
def comment_key_map(store: Store, docket: str) -> dict[str, tuple[str, str]]:
|
||
"""comment_id -> (bib item key, year) for one docket."""
|
||
rows = store._con().execute(
|
||
"SELECT i.key, i.url FROM items i "
|
||
"JOIN item_tags it ON it.item_id = i.id "
|
||
"JOIN tags t ON t.id = it.tag_id "
|
||
"WHERE t.name = ? AND i.url LIKE ?",
|
||
(f"docket:{docket}", _COMMENT_URL_PREFIX + "%"),
|
||
).fetchall()
|
||
return {
|
||
url.rsplit("/", 1)[-1]: (key, _year_of(store, key))
|
||
for key, url in rows
|
||
}
|
||
|
||
|
||
def iter_comment_docs(
|
||
store: Store, *, docket: str = "", root: Path | None = None
|
||
) -> Iterator[Doc]:
|
||
"""One Doc per comment: extraction body, else abstract fallback."""
|
||
from rex.comments.combine import parse_combined
|
||
|
||
root = root if root is not None else _default_root()
|
||
dockets = [docket] if docket else sorted(
|
||
t["name"].split(":", 1)[1]
|
||
for t in store.list_tags(namespace="docket")
|
||
if t["name"] != "docket:"
|
||
)
|
||
for dk in dockets:
|
||
for comment_id, (key, year) in sorted(comment_key_map(store, dk).items()):
|
||
meta = {
|
||
"docket": dk,
|
||
"comment_id": comment_id,
|
||
"doctype": "comment",
|
||
"year": year,
|
||
}
|
||
combined = root / dk / comment_id / "combined.md"
|
||
if combined.exists():
|
||
_, body = parse_combined(combined.read_text())
|
||
if body.strip():
|
||
yield Doc(key=key, text=body, metadata=meta)
|
||
continue
|
||
item = store.get(key)
|
||
if item.abstract.strip():
|
||
yield Doc(key=key, text=item.abstract, metadata=meta)
|
||
|
||
|
||
def _attachment_text(store: Store, item_key: str) -> str:
|
||
from rex.comments.combine import extract_attachment
|
||
|
||
rows = store._con().execute(
|
||
"SELECT a.storage_path FROM attachments a "
|
||
"JOIN items i ON i.id = a.item_id WHERE i.key = ?",
|
||
(item_key,),
|
||
).fetchall()
|
||
parts = []
|
||
for (storage_path,) in rows:
|
||
path = Path(storage_path)
|
||
if path.exists():
|
||
text, status = extract_attachment(path)
|
||
if status == "ok":
|
||
parts.append(text)
|
||
return "\n\n".join(parts)
|
||
|
||
|
||
def iter_corpus_docs(store: Store, *, tag: str = "") -> Iterator[Doc]:
|
||
"""Every non-comment item; attachment text + abstract."""
|
||
for item in store.list_items(tag=tag):
|
||
if "doctype:comment" in item.tags:
|
||
continue
|
||
text = "\n\n".join(
|
||
p for p in (_attachment_text(store, item.key), item.abstract) if p.strip()
|
||
)
|
||
if not text.strip():
|
||
continue
|
||
yield Doc(
|
||
key=item.key,
|
||
text=text,
|
||
metadata={
|
||
"doctype": item.item_type,
|
||
"year": _year_of(store, item.key),
|
||
},
|
||
)
|
||
```
|
||
|
||
Check against reality while implementing: (a) `Item.tags` — read
|
||
`src/bib/item.py` for how tags are exposed on a hydrated item; if
|
||
`store.get()` doesn't hydrate tags, use a `_year_of`-style SQL exclusion
|
||
(`NOT EXISTS ... t.name = 'doctype:comment'`) instead. (b)
|
||
`extract_attachment` signature — read `src/rex/comments/combine.py:135`
|
||
(it may return `(text, status)` or a richer object; adapt the unpacking).
|
||
(c) `list_items` is O(n) `get()` calls — for the corpus (≈17k non-comment
|
||
items) this is acceptable; comments never go through it.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `uv run pytest tests/llm/test_source.py -v` → PASS. Ruff check + format.
|
||
|
||
- [ ] **Step 5: Live sanity check on a real docket, then commit**
|
||
|
||
```bash
|
||
uv run python -c "
|
||
from conf.connect import bib
|
||
from llm.source import iter_comment_docs
|
||
import itertools
|
||
docs = list(itertools.islice(iter_comment_docs(bib(), docket='CMS-2017-0092'), 5))
|
||
for d in docs: print(d.key, d.metadata['comment_id'], len(d.text))
|
||
"
|
||
```
|
||
|
||
Expected: 5 rows, nonzero lengths.
|
||
|
||
```bash
|
||
git add src/llm/source.py tests/llm/test_source.py
|
||
git commit -m "feat(llm): comment + corpus Doc sources with abstract fallback (refs #566)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Incremental indexer + `stack llm index` CLI (#567)
|
||
|
||
**Files:**
|
||
- Create: `src/llm/index.py`, `tests/llm/test_index.py`
|
||
- Create: `src/cli/llm.py`
|
||
- Modify: `src/cli/__init__.py` (register subcommand)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llm.config`, `llm.migrate.{migrate, ensure_hnsw}`, `llm.pool.{HostPool, PoolEmbeddings, embed_texts}`, `llm.chunk.{chunk_doc, content_hash}`, `llm.source.{iter_comment_docs, iter_corpus_docs}`, `langchain_postgres.PGVector`.
|
||
- Produces:
|
||
- `vectorstore(collection: str, cfg: LlmConfig, pool: HostPool) -> PGVector` — constructed with `embedding_length=cfg.embed_dim`, `use_jsonb=True`.
|
||
- `index_docs(docs: Iterable[Doc], *, collection: str, cfg: LlmConfig, pool: HostPool, force: bool = False) -> dict` returning `{"indexed": int, "skipped": int, "chunks": int}`.
|
||
- CLI: `stack llm index [--collection comments|corpus] [--docket ID] [--force] [--limit N]`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
`tests/llm/test_index.py` — unit-level: mock PGVector, engine, and embed; assert skip/re-embed/delete-then-add logic:
|
||
|
||
```python
|
||
"""llm.index — incremental, resumable embedding indexer."""
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from llm.chunk import Doc, content_hash
|
||
from llm.config import LlmConfig
|
||
from llm.index import index_docs
|
||
|
||
CFG = LlmConfig(
|
||
ollama_hosts=("http://h1:11434",),
|
||
embed_model="m",
|
||
instruct_model="g",
|
||
embed_dim=768,
|
||
pg_host="x",
|
||
pg_port=5432,
|
||
pg_db="llm",
|
||
pg_user="llm",
|
||
)
|
||
|
||
DOC = Doc(key="K1", text="Some body text.", metadata={"docket": "D"})
|
||
|
||
|
||
def _run(docs, state_rows, force=False):
|
||
"""Run index_docs with everything external mocked; return mocks."""
|
||
store = MagicMock()
|
||
engine = MagicMock()
|
||
conn = engine.begin.return_value.__enter__.return_value
|
||
conn.execute.return_value.fetchall.return_value = state_rows
|
||
with (
|
||
patch("llm.index._engine", return_value=engine),
|
||
patch("llm.index.vectorstore", return_value=store),
|
||
patch("llm.index.embed_texts", return_value=[[0.0] * 3]),
|
||
patch("llm.index.HostPool") as MockPool,
|
||
):
|
||
MockPool.return_value.check.return_value = ["http://h1:11434"]
|
||
stats = index_docs(
|
||
docs, collection="comments", cfg=CFG,
|
||
pool=MockPool.return_value, force=force,
|
||
)
|
||
return stats, store, conn
|
||
|
||
|
||
class TestIndexDocs:
|
||
def test_new_doc_embedded_and_recorded(self):
|
||
stats, store, conn = _run([DOC], state_rows=[])
|
||
assert stats == {"indexed": 1, "skipped": 0, "chunks": 1}
|
||
store.add_embeddings.assert_called_once()
|
||
kwargs = store.add_embeddings.call_args.kwargs
|
||
assert kwargs["ids"][0].startswith("K1:")
|
||
|
||
def test_unchanged_doc_skipped(self):
|
||
h = content_hash(DOC.text)
|
||
stats, store, _ = _run([DOC], state_rows=[("K1", h)])
|
||
assert stats["skipped"] == 1
|
||
store.add_embeddings.assert_not_called()
|
||
|
||
def test_changed_doc_deletes_old_chunks_first(self):
|
||
stats, store, conn = _run([DOC], state_rows=[("K1", "stalehash")])
|
||
assert stats["indexed"] == 1
|
||
deletes = " ".join(
|
||
str(c.args[0]) for c in conn.execute.call_args_list
|
||
)
|
||
assert "item_key" in deletes # DELETE ... cmetadata->>'item_key'
|
||
|
||
def test_force_reembeds_unchanged(self):
|
||
h = content_hash(DOC.text)
|
||
stats, store, _ = _run([DOC], state_rows=[("K1", h)], force=True)
|
||
assert stats["indexed"] == 1
|
||
|
||
def test_empty_doc_counts_skipped(self):
|
||
empty = Doc(key="K2", text=" ", metadata={})
|
||
stats, store, _ = _run([empty], state_rows=[])
|
||
assert stats == {"indexed": 0, "skipped": 1, "chunks": 0}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `uv run pytest tests/llm/test_index.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'llm.index'`
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
`src/llm/index.py`:
|
||
|
||
```python
|
||
"""Incremental, resumable embedding indexer.
|
||
|
||
Per doc: compare ``content_hash`` against ``index_state``; skip when
|
||
unchanged, else delete the item's old chunks (by ``cmetadata->>
|
||
'item_key'``), embed via the host pool, upsert with deterministic ids,
|
||
and record the new hash — one transaction per doc, so a killed run
|
||
resumes exactly where it stopped.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Iterable
|
||
|
||
from sqlalchemy import create_engine, text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
from llm.chunk import Doc, chunk_doc, content_hash
|
||
from llm.config import LlmConfig, pg_url
|
||
from llm.migrate import ensure_hnsw, migrate
|
||
from llm.pool import HostPool, PoolEmbeddings, embed_texts
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
def _engine(cfg: LlmConfig) -> Engine:
|
||
return create_engine(pg_url(cfg))
|
||
|
||
|
||
def vectorstore(collection: str, cfg: LlmConfig, pool: HostPool):
|
||
from langchain_postgres import PGVector
|
||
|
||
return PGVector(
|
||
embeddings=PoolEmbeddings(pool, cfg.embed_model),
|
||
collection_name=collection,
|
||
connection=pg_url(cfg),
|
||
embedding_length=cfg.embed_dim,
|
||
use_jsonb=True,
|
||
)
|
||
|
||
|
||
def _state(engine: Engine, collection: str) -> dict[str, str]:
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(
|
||
text(
|
||
"SELECT item_key, content_hash FROM index_state "
|
||
"WHERE collection = :c"
|
||
),
|
||
{"c": collection},
|
||
).fetchall()
|
||
return dict(rows)
|
||
|
||
|
||
def index_docs(
|
||
docs: Iterable[Doc],
|
||
*,
|
||
collection: str,
|
||
cfg: LlmConfig,
|
||
pool: HostPool,
|
||
force: bool = False,
|
||
) -> dict:
|
||
engine = _engine(cfg)
|
||
migrate(engine)
|
||
pool.check(cfg.embed_model)
|
||
store = vectorstore(collection, cfg, pool)
|
||
seen = _state(engine, collection)
|
||
stats = {"indexed": 0, "skipped": 0, "chunks": 0}
|
||
|
||
for doc in docs:
|
||
chunks = chunk_doc(doc)
|
||
if not chunks:
|
||
stats["skipped"] += 1
|
||
continue
|
||
h = content_hash(doc.text)
|
||
if not force and seen.get(doc.key) == h:
|
||
stats["skipped"] += 1
|
||
continue
|
||
vectors = embed_texts(
|
||
pool, cfg.embed_model, [c.text for c in chunks]
|
||
)
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"DELETE FROM langchain_pg_embedding "
|
||
"WHERE cmetadata->>'item_key' = :k"
|
||
),
|
||
{"k": doc.key},
|
||
)
|
||
store.add_embeddings(
|
||
texts=[c.text for c in chunks],
|
||
embeddings=vectors,
|
||
metadatas=[c.metadata for c in chunks],
|
||
ids=[c.id for c in chunks],
|
||
)
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO index_state "
|
||
"(item_key, collection, content_hash, chunk_count) "
|
||
"VALUES (:k, :c, :h, :n) "
|
||
"ON CONFLICT (item_key, collection) DO UPDATE SET "
|
||
"content_hash = :h, chunk_count = :n, indexed_at = now()"
|
||
),
|
||
{"k": doc.key, "c": collection, "h": h, "n": len(chunks)},
|
||
)
|
||
stats["indexed"] += 1
|
||
stats["chunks"] += len(chunks)
|
||
if stats["indexed"] % 100 == 0:
|
||
log.info("indexed %(indexed)s (+%(chunks)s chunks)", stats)
|
||
|
||
ensure_hnsw(engine, cfg.embed_dim)
|
||
return stats
|
||
```
|
||
|
||
Implementation notes to verify against the installed `langchain_postgres`
|
||
version while coding: the delete-before-add is belt-and-braces (ids are
|
||
deterministic and `add_embeddings` upserts on id-conflict, but a *changed*
|
||
doc can shrink its chunk count, stranding stale ids — hence the metadata
|
||
delete). If `PGVector` in the pinned version lacks `embedding_length`,
|
||
drop the kwarg and rely on `ensure_hnsw`'s `ALTER ... TYPE vector(dim)`.
|
||
The first `DELETE` before any `add_embeddings` will also fail if
|
||
`langchain_pg_embedding` doesn't exist yet — create the store tables first
|
||
via `PGVector.create_tables_if_not_exists()` (or catch
|
||
`ProgrammingError` and skip); check which the pinned version offers.
|
||
|
||
`src/cli/llm.py`:
|
||
|
||
```python
|
||
"""stack llm — local RAG over comments + corpus (P33: index only)."""
|
||
|
||
import typer
|
||
|
||
app = typer.Typer(no_args_is_help=True)
|
||
|
||
|
||
@app.command()
|
||
def index(
|
||
collection: str = typer.Option(
|
||
"comments", help="Which collection: comments | corpus."
|
||
),
|
||
docket: str = typer.Option("", help="Limit comments to one docket id."),
|
||
force: bool = typer.Option(False, help="Re-embed even when unchanged."),
|
||
limit: int = typer.Option(0, help="Stop after N docs (0 = all)."),
|
||
) -> None:
|
||
"""Embed comments/corpus into pgvector (incremental, resumable)."""
|
||
import itertools
|
||
|
||
from conf.connect import bib
|
||
|
||
from llm import config as llm_config
|
||
from llm.index import index_docs
|
||
from llm.pool import HostPool
|
||
from llm.source import iter_comment_docs, iter_corpus_docs
|
||
|
||
cfg = llm_config.load()
|
||
store = bib()
|
||
if collection == "comments":
|
||
docs = iter_comment_docs(store, docket=docket)
|
||
elif collection == "corpus":
|
||
docs = iter_corpus_docs(store)
|
||
else:
|
||
raise typer.BadParameter("collection must be 'comments' or 'corpus'")
|
||
if limit:
|
||
docs = itertools.islice(docs, limit)
|
||
stats = index_docs(
|
||
docs,
|
||
collection=collection,
|
||
cfg=cfg,
|
||
pool=HostPool.from_config(cfg),
|
||
force=force,
|
||
)
|
||
typer.echo(
|
||
f"indexed={stats['indexed']} skipped={stats['skipped']} "
|
||
f"chunks={stats['chunks']}"
|
||
)
|
||
```
|
||
|
||
`src/cli/__init__.py` — add with the other imports/registrations
|
||
(alphabetical with the existing `add_typer` calls):
|
||
|
||
```python
|
||
from cli.llm import app as llm_app
|
||
|
||
app.add_typer(llm_app, name="llm", help="Local RAG — index, search, tag.")
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `uv run pytest tests/llm/ -v` → PASS. Also `uv run pytest tests/cli/ -q` (the CLI registry usually has a help-text test — update it if it asserts the command list). Ruff check + format.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/llm/index.py tests/llm/test_index.py src/cli/llm.py src/cli/__init__.py
|
||
git commit -m "feat(llm): incremental pgvector indexer + stack llm index CLI (refs #567)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Pilot-docket run + model bake-off + spec update (#564, #567)
|
||
|
||
**Files:**
|
||
- Create: `dev/scripts/llm_bakeoff.py`
|
||
- Modify: `docs/superpowers/specs/2026-07-16-llm-module-design.md` (record results), `stack.toml` (if models change)
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything above, live services.
|
||
- Produces: an indexed pilot docket in pgvector; a bake-off report + model decision recorded in the spec.
|
||
|
||
- [ ] **Step 1: Index the pilot docket (smallest first)**
|
||
|
||
```bash
|
||
uv run stack llm index --collection comments --docket CMS-2017-0092
|
||
# kill it partway (Ctrl-C) once, rerun, confirm skipped > 0 and it completes:
|
||
uv run stack llm index --collection comments --docket CMS-2017-0092
|
||
```
|
||
|
||
Expected second run: `indexed=<remainder> skipped=<already-done> …` — resumability proven. Then verify retrieval directly:
|
||
|
||
```bash
|
||
PW=$(grep '^LLM_DB_PASSWORD=' .env | cut -d= -f2-)
|
||
LLM_DB_PASSWORD=$PW uv run python -c "
|
||
import llm
|
||
from llm.index import vectorstore
|
||
from llm.pool import HostPool
|
||
cfg = llm.load()
|
||
pool = HostPool.from_config(cfg); pool.check(cfg.embed_model)
|
||
vs = vectorstore('comments', cfg, pool)
|
||
for doc, score in vs.similarity_search_with_score('telehealth originating site', k=3):
|
||
print(round(score, 3), doc.metadata['item_key'], doc.page_content[:80])
|
||
"
|
||
```
|
||
|
||
Expected: 3 topically-relevant comment chunks.
|
||
|
||
- [ ] **Step 2: Write the bake-off harness**
|
||
|
||
`dev/scripts/llm_bakeoff.py` — self-contained, writes a markdown report to stdout. Retrieval proxy task: for N sampled extracted comments, query = the comment's bib abstract, relevant = its own chunks; score recall@5 + MRR per embed model, plus embed throughput. Generation task: run a fixed closed-vocab tagging prompt over M sample comments per instruct model; print outputs + tokens/s for human review.
|
||
|
||
```python
|
||
"""Bake off embed + instruct models for the llm module (#564).
|
||
|
||
Usage:
|
||
LLM_DB_PASSWORD=... uv run python dev/scripts/llm_bakeoff.py \
|
||
--docket CMS-2017-0092 --sample 100
|
||
|
||
Embed metric: for each sampled comment, embed its chunks under each
|
||
candidate model into an in-memory matrix; query with the comment's
|
||
abstract; recall@5 = fraction of comments whose own chunk ranks top-5,
|
||
plus MRR and chunks/sec. Generation: tag 15 sample comments with each
|
||
candidate against a 12-tag toy vocab; print outputs + tokens/s for
|
||
eyeball review. No pgvector writes — pure in-memory numpy.
|
||
"""
|
||
|
||
import argparse
|
||
import itertools
|
||
import json
|
||
import time
|
||
|
||
import httpx
|
||
import numpy as np
|
||
|
||
from conf.connect import bib
|
||
from llm import config as llm_config
|
||
from llm.chunk import chunk_doc
|
||
from llm.pool import HostPool, embed_texts
|
||
from llm.source import iter_comment_docs
|
||
|
||
EMBED_CANDIDATES = ["nomic-embed-text", "bge-m3"]
|
||
GEN_CANDIDATES = ["llama3.1:8b", "qwen2.5:7b-instruct", "qwen2.5:14b-instruct-q4_K_M"]
|
||
TOY_VOCAB = [
|
||
"telehealth", "e/m-coding", "payment-rates", "quality-measures",
|
||
"drug-pricing", "supervision", "rural-access", "documentation-burden",
|
||
"site-neutrality", "aco-policy", "opioids", "scope-of-practice",
|
||
]
|
||
PROMPT = (
|
||
"You are tagging a public comment on a CMS rule. Choose up to 3 tags "
|
||
"from exactly this list, JSON array only, no prose: {vocab}\n\n"
|
||
"COMMENT:\n{body}\n\nTAGS:"
|
||
)
|
||
|
||
|
||
def embed_bakeoff(pool, docs, sample):
|
||
picked = [d for d in itertools.islice(docs, sample) if d.metadata]
|
||
for model in EMBED_CANDIDATES:
|
||
pool.check(model)
|
||
chunks, owners, queries = [], [], []
|
||
store = bib()
|
||
for i, d in enumerate(picked):
|
||
cs = chunk_doc(d)[:4]
|
||
chunks += [c.text for c in cs]
|
||
owners += [i] * len(cs)
|
||
queries.append(store.get(d.key).abstract or d.text[:300])
|
||
t0 = time.time()
|
||
mat = np.array(embed_texts(pool, model, chunks))
|
||
rate = len(chunks) / (time.time() - t0)
|
||
qs = np.array(embed_texts(pool, model, queries))
|
||
mat = mat / np.linalg.norm(mat, axis=1, keepdims=True)
|
||
qs = qs / np.linalg.norm(qs, axis=1, keepdims=True)
|
||
owners_arr = np.array(owners)
|
||
hits, rr = 0, 0.0
|
||
for i, q in enumerate(qs):
|
||
order = np.argsort(mat @ q)[::-1]
|
||
ranks = np.where(owners_arr[order] == i)[0]
|
||
if len(ranks):
|
||
rr += 1 / (ranks[0] + 1)
|
||
hits += ranks[0] < 5
|
||
n = len(qs)
|
||
print(f"| {model} | {hits / n:.2%} | {rr / n:.3f} | {rate:.0f} c/s |")
|
||
|
||
|
||
def gen_bakeoff(pool, docs, hosts):
|
||
picked = list(itertools.islice(docs, 15))
|
||
for model in GEN_CANDIDATES:
|
||
try:
|
||
pool.check(model)
|
||
except RuntimeError as exc:
|
||
print(f"\n### {model}: SKIPPED — {exc}")
|
||
continue
|
||
print(f"\n### {model}")
|
||
for d in picked:
|
||
body = d.text[:4000]
|
||
t0 = time.time()
|
||
with pool.acquire() as host, httpx.Client(timeout=300) as c:
|
||
r = c.post(f"{host}/api/generate", json={
|
||
"model": model, "stream": False,
|
||
"prompt": PROMPT.format(vocab=json.dumps(TOY_VOCAB), body=body),
|
||
})
|
||
out = r.json()
|
||
tps = out.get("eval_count", 0) / max(out.get("eval_duration", 1) / 1e9, 0.001)
|
||
print(f"- {d.metadata['comment_id']} ({tps:.0f} tok/s): "
|
||
f"{out['response'].strip()[:120]}")
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--docket", default="CMS-2017-0092")
|
||
ap.add_argument("--sample", type=int, default=100)
|
||
args = ap.parse_args()
|
||
cfg = llm_config.load()
|
||
pool = HostPool.from_config(cfg)
|
||
print("| model | recall@5 | MRR | throughput |")
|
||
print("|---|---|---|---|")
|
||
embed_bakeoff(pool, iter_comment_docs(bib(), docket=args.docket), args.sample)
|
||
gen_bakeoff(pool, iter_comment_docs(bib(), docket=args.docket), cfg.ollama_hosts)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
(dev/scripts are exempt from the coverage bar — confirm with `grep -n "source" pyproject.toml`: coverage source is `src/` only.)
|
||
|
||
- [ ] **Step 3: Run the bake-off**
|
||
|
||
```bash
|
||
docker exec ollama ollama pull bge-m3
|
||
docker exec ollama ollama pull qwen2.5:7b-instruct
|
||
docker exec ollama ollama pull qwen2.5:14b-instruct-q4_K_M # ~9 GB — fits the 3060, tight
|
||
uv run python dev/scripts/llm_bakeoff.py --docket CMS-2017-0092 --sample 100 | tee /tmp/bakeoff.md
|
||
```
|
||
|
||
Review the table + generation outputs. Decide embed model (recall/MRR, dims, speed) and instruct model (tag validity — outputs parse as JSON arrays drawn from the vocab — and tok/s).
|
||
|
||
- [ ] **Step 4: Record the decision**
|
||
|
||
- If the winner differs from the defaults, update `stack.toml` `[llm]` `embed_model` / `embed_dim` / `instruct_model` and re-run `uv run stack llm index --collection comments --docket CMS-2017-0092 --force` (changed model ⇒ changed vectors).
|
||
- Append a `## Model bake-off (P33 #564)` section to `docs/superpowers/specs/2026-07-16-llm-module-design.md` with the results table, generation notes, per-host sizing (3060 vs 4090/5080), and the decision.
|
||
|
||
- [ ] **Step 5: Full-suite verification, commit, close out**
|
||
|
||
```bash
|
||
uv run pytest tests/ -q -n auto # entire suite green
|
||
uv run ruff check src/ tests/ dev/scripts/llm_bakeoff.py
|
||
git add dev/scripts/llm_bakeoff.py docs/superpowers/specs/2026-07-16-llm-module-design.md stack.toml
|
||
git commit -m "feat(llm): model bake-off + pilot docket indexed; record decision (refs #564 #567)"
|
||
```
|
||
|
||
Then comment results on #564 and close #563–#568 as their deliverables land (issues auto-close only via `closes #N` in merged commit messages; otherwise close via the tracker with a comment linking the commit).
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- **Spec coverage:** #563 → Task 1; #565 → Task 2; #564 → Tasks 3 + 8; #566 → Tasks 5–6; #568 → Task 4; #567 → Tasks 7 + 8 (pilot). P33's "v1 gate: one docket end-to-end before corpus-wide run" → Task 8 Step 1.
|
||
- **Known verify-at-execution points** (flagged inline, not placeholders): `bib.item.Item` constructor shape (Task 6), `extract_attachment` return shape (Task 6), `langchain_postgres` `embedding_length`/`create_tables_if_not_exists` availability (Task 7), `api.auth bootstrap --only` support (Task 2).
|
||
- **Type consistency:** `LlmConfig` fields, `Doc`/`Chunk`, `HostPool.acquire`/`check`, `embed_texts`, `index_docs` signatures are used identically across Tasks 4–8.
|