diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65f2b8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# A new push to a PR makes the in-flight run irrelevant. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend — pytest + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + services: + # The unit tier mocks the database, but the integration tier runs real SQL — + # ownership filters, cascades and pgvector columns are only meaningful against + # a real server. pg16 matches the Railway instance. + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: docuquery_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + env: + # AUTH_SECRET is required because `from app.main import app` builds Settings at + # import time, and a non-debug Settings refuses the dev default. + AUTH_SECRET: ci-only-secret-not-used-for-any-real-session + # Presence of this variable is what enables the integration tier; without it + # those tests skip. See backend/tests/conftest.py. + TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/docuquery_test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: pip install -r requirements.txt + + # Unit and integration run as one invocation so that a single coverage figure + # covers both. Coverage is reported, not gated — there is no --cov-fail-under + # until the baseline in BASELINE.md establishes what the number should be. + - name: Run tests + run: pytest -q --cov=app --cov-report=term-missing --cov-report=xml + + frontend: + name: Frontend — typecheck, lint, build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4323f78 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# DocuQuery — working rules + +Retrieval-augmented document search. Upload PDFs, DOCX, TXT or Markdown; they are +parsed, chunked, embedded and stored in PostgreSQL with pgvector; questions are +answered from the retrieved passages with citations back to filename and page. + +## Layout + +``` +backend/ + app/ + api/ FastAPI routes. Thin — no business logic here. + core/ Settings, logging, security primitives, middleware. + database/ Declarative Base, engine, session, ORM models. + ingestion/ parse → clean → chunk → embed → store. pipeline.py orchestrates. + retrieval/ embed query → vector search → score → build context → citations. + llm/ Providers, prompts, response generator, SSE framing. + services/ Document persistence used by the routes. + migrations/ Alembic. versions/ is the revision chain. + evaluation/ Corpus manifest, eval-set builder, retrieval and perf harnesses. + scripts/ Operational one-offs (reindex.py). + tests/ pytest. conftest.py holds the integration fixtures. +frontend/ Next.js 16 App Router, React 19, TanStack Query, Zustand. +``` + +The API layer stays thin on purpose: routes validate, call into a service or +pipeline, and translate exceptions into status codes. Anything that would be worth +unit-testing belongs a layer down. + +## Running things + +All backend commands run from `backend/`, and need `AUTH_SECRET` set (see below). + +```bash +# Unit tests — mocked database, no services needed. This is the default. +pytest -q + +# Integration tests — real PostgreSQL. Skipped entirely when TEST_DATABASE_URL is unset. +docker run -d --name docuquery-test-db -p 5434:5432 \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=docuquery_test pgvector/pgvector:pg16 +export TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5434/docuquery_test +pytest -q -m integration # just the integration tier +pytest -q # both tiers +pytest -q --cov=app --cov-report=term-missing + +# Evaluation — see backend/evaluation/README.md for the full sequence. +# Note these are invoked as paths, not with `-m`: the scripts import `_common` +# flatly, and _common.py is what puts backend/ on sys.path for `app.*`. +python evaluation/download_corpus.py +python evaluation/ingest_corpus.py --corpus evaluation/corpus/files --reset +python evaluation/build_eval_set.py --n 100 +python evaluation/run_retrieval_eval.py +python evaluation/run_perf_bench.py +``` + +Point the harness at a throwaway database with `EVAL_DATABASE_URL`; it defaults +to the application's own `DATABASE_URL`, which is rarely what you want. + +Use port **5434** for the test database. The project's own dev stack +(`docker compose`) already binds **5433**, and the integration fixtures TRUNCATE +whatever they are pointed at. + +`AUTH_SECRET` is mandatory whenever `DEBUG=false`: `Settings` refuses to build with +the committed development default, because that value alone signs every session +cookie. For local work either export a throwaway value or set `DEBUG=true`. + +```bash +export AUTH_SECRET=$(python -c "import secrets; print(secrets.token_urlsafe(48))") +``` + +## Standing rules + +**One PR per phase.** A phase is a numbered body of work with its own branch and its +own PR. Do not fold the next phase's changes into the current PR because they are +small or convenient; they get reviewed in the phase that owns them. + +**Every retrieval change includes a before/after table against `BASELINE.md`.** +Any change to chunking, embedding, the similarity threshold, top-k, filtering, +scoring or context building is a retrieval change. Re-run +`run_retrieval_eval.py` on the same query set and put both columns in the PR +description. "It seems better" is not a result, and a retrieval change without a +table is not reviewable. + +**Never edit a migration that has already been applied.** Alembic will not re-run a +stamped revision, so an edit leaves every deployed database describing a history it +does not have. Correct a bad migration with a new one. This is not hypothetical +here: `a9f3d6c1b5e8` cleared every embedding without resetting document status, and +the fix was `d5e2c8a7f1b3`, not a patch to the original. + +Write a real `downgrade()`, and keep migrations additive where the change allows it. +`backend/railway.json` runs `alembic upgrade head` on **every boot**, so merging a +PR that contains a migration *is* running that migration against production. Review +it on those terms. The same applies to any fail-fast configuration guard: it takes +the API down on the next deploy if the value is not already set in Railway. + +**Read `frontend/AGENTS.md` before touching Next.js code.** This is Next.js 16 with +React 19. Framework behaviour has changed in ways that predate most training data — +`params` is a Promise, route-level state preservation and `unstable_instant` +validation depend on `cacheComponents`, which this project does **not** currently +enable. The guides bundled at `frontend/node_modules/next/dist/docs/` are the +authority for this exact version; prefer them over recollection. + +**Do not change retrieval behaviour and measure it in the same breath.** A baseline +is only a baseline if it describes code that is actually deployed, bugs included. diff --git a/README.md b/README.md index bd4586c..cf9b9aa 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,13 @@ Upload your documents. DocuQuery reads them, indexes them, and answers questions with the exact page and passage the answer came from — no more skimming a 40-page report for one number. -[**Live demo**](#-see-it-work) · [**Quick start**](#-quick-start) · [**Features**](#-features) · [**API reference**](#-api-reference) · [**Roadmap**](#-roadmap) +### [▶ Try it live at docuqueryvc.vercel.app](https://docuqueryvc.vercel.app) -![Build Status](https://img.shields.io/badge/build-passing-brightgreen?style=flat-square) +Frontend on Vercel · API at [`docuqueryprod.up.railway.app`](https://docuqueryprod.up.railway.app/health) on Railway · sign up with any email to upload your own documents + +[**See it work**](#-see-it-work) · [**Quick start**](#-quick-start) · [**Features**](#-features) · [**API reference**](#-api-reference) · [**Roadmap**](#-roadmap) + +[![CI](https://github.com/VampiricCyborg/DocuQuery/actions/workflows/ci.yml/badge.svg)](https://github.com/VampiricCyborg/DocuQuery/actions/workflows/ci.yml) ![Version](https://img.shields.io/badge/version-0.1.0-blue?style=flat-square) ![Next.js](https://img.shields.io/badge/Next.js-16.2-black?style=flat-square&logo=next.js) ![React](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react) @@ -47,6 +51,10 @@ and hands the model only that — then shows you exactly where the answer came f A question is typed, DocuQuery searches the uploaded documents, streams back an answer, then shows the cited source
One question, start to finish — typed, searched, answered, cited. No cuts. + +
+ +Rather do it yourself? Open the live app and upload a document.
@@ -235,6 +243,7 @@ Every request to `POST /chat` carries a `mode`: | `GET` | `/documents` | List all documents with processing status | | `GET` | `/documents/{id}` | Get a single document | | `GET` | `/documents/{id}/chunks` | Inspect a document's indexed chunks | +| `GET` | `/documents/{id}/debug` | Indexing diagnostics — recorded vs. actual chunk count, and how many carry an embedding | | `DELETE` | `/documents/{id}` | Delete a document and all its chunks | | `POST` | `/retrieve` | Vector similarity search — chunks + context, no LLM call | | `POST` | `/chat` | Full RAG pipeline — streams an answer with citations | @@ -319,7 +328,12 @@ cd frontend && npx tsc --noEmit && npm run lint && npm run build ``` The backend suite covers parsing, chunking, retrieval scoring, citations, prompt construction, -provider abstraction, streaming, password hashing, and session validation. +provider abstraction, streaming, password hashing, and session validation. It needs no database +and no API keys — the database and the embedding service are mocked. + +Every push to `main` and every pull request runs the same commands on GitHub Actions +([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) — that is what the CI badge at the top +reports. --- diff --git a/backend/.env.example b/backend/.env.example index 3587a4b..7e13e5c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,6 +16,14 @@ POSTGRES_USER=docuquery POSTGRES_PASSWORD= POSTGRES_DB=docuquery +# Integration tests only. When set, tests marked `integration` run Alembic against +# this database and exercise real SQL; when unset they skip. Must point at a server +# with the pgvector extension available, and must NOT be a database you care about — +# the fixtures TRUNCATE it between tests. +# docker run -d -p 5433:5432 -e POSTGRES_PASSWORD=postgres \ +# -e POSTGRES_DB=docuquery_test pgvector/pgvector:pg16 +TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/docuquery_test + # File Storage UPLOAD_DIR=uploads MAX_FILE_SIZE_MB=50 @@ -27,6 +35,18 @@ CHUNK_OVERLAP=120 EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 EMBEDDING_BATCH_SIZE=32 +# Authentication +# REQUIRED whenever DEBUG=false - the app refuses to start without it. +# This value alone signs every session cookie; anyone who knows it can forge a +# session for any user. Generate one with: +# python -c "import secrets; print(secrets.token_urlsafe(48))" +AUTH_SECRET= +AUTH_COOKIE_NAME=docuquery_session +AUTH_SESSION_DAYS=30 +# Cookies are Secure + SameSite=None by default, for a cross-site Vercel -> Railway +# setup. Set to false only for local http development. +AUTH_COOKIE_SECURE=true + # LLM LLM_PROVIDER=groq LLM_MODEL=qwen/qwen3.8-27b diff --git a/backend/.gitignore b/backend/.gitignore index 8b149b9..c68de52 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -33,3 +33,9 @@ models/ .vscode/ .idea/ *.iml + +# Evaluation corpus PDFs. The manifest (corpus/manifest.json) IS committed — +# it carries the URL, SHA-256 and licence for each document, and +# `python -m evaluation.download_corpus` reproduces this directory from it. +# The files themselves are ~95MB of third-party publications. +evaluation/corpus/files/ diff --git a/backend/DEPENDENCIES_VERIFIED.md b/backend/DEPENDENCIES_VERIFIED.md index 55a0572..92ea0c7 100644 --- a/backend/DEPENDENCIES_VERIFIED.md +++ b/backend/DEPENDENCIES_VERIFIED.md @@ -1,30 +1,32 @@ # Dependencies Verification -**Last Verified:** July 29, 2026 +**Last Verified:** September 20, 2026 -## ✅ All Third-Party Imports Covered +## Scope -This document confirms that all third-party Python packages imported across the codebase are included in `requirements.txt`. +This document records the mapping between third-party Python packages imported +across the backend and the entries in `requirements.txt`. It is a manual audit, so +it is only as current as the date above — re-run the commands at the bottom after +changing dependencies. -### Import → Package Mapping +## Imported directly by the code -| Import Statement | Package in requirements.txt | Version | +| Import statement | Package in requirements.txt | Version | |---|---|---| -| `import fastapi` | `fastapi` | 0.115.5 | -| `import uvicorn` | `uvicorn[standard]` | 0.32.1 | -| `from fastapi import ...` | `python-multipart` | 0.0.18 | +| `import fastapi` / `from fastapi import ...` | `fastapi` | 0.115.5 | | `from pydantic import ...` | `pydantic` | 2.10.3 | | `from pydantic_settings import ...` | `pydantic-settings` | 2.6.1 | | `from sqlalchemy import ...` | `sqlalchemy[asyncio]` | 2.0.36 | | `from alembic import ...` | `alembic` | 1.14.0 | -| `import asyncpg` | `asyncpg` | 0.30.0 | -| `import psycopg2` | `psycopg2-binary` | 2.9.10 | | `from pgvector.sqlalchemy import ...` | `pgvector` | 0.5.0 | | `import aiofiles` | `aiofiles` | 24.1.0 | | `import fitz` | `pymupdf` | 1.24.14 | | `from docx import ...` | `python-docx` | 1.1.2 | | `from langchain_text_splitters import ...` | `langchain-text-splitters` | 1.1.2 | -| `from sentence_transformers import ...` | `sentence-transformers` | 5.4.1 | +| `import onnxruntime` | `onnxruntime` | 1.30.0 | +| `from tokenizers import ...` | `tokenizers` | 0.23.2 | +| `import numpy` | `numpy` | 2.5.3 | +| `import psutil` | `psutil` | 6.1.1 | | `from slowapi import ...` | `slowapi` | 0.1.9 | | `import httpx` | `httpx` | 0.28.1 | | `from groq import ...` | `groq` | 0.13.1 | @@ -32,70 +34,60 @@ This document confirms that all third-party Python packages imported across the | `from anthropic import ...` | `anthropic` | 0.40.0 | | `import google.generativeai` | `google-generativeai` | 0.8.3 | | `import pytest` | `pytest` | 9.1.1 | -| `import pytest_asyncio` | `pytest-asyncio` | 1.4.0 | -### Standard Library (No Installation Required) +`from starlette... import ...` also appears in `app/core/middleware.py`. Starlette is a +direct dependency of FastAPI and is installed with it, so it has no line of its own. -The following imports are from Python's standard library and do not require entries in requirements.txt: +## Required but never imported -- `__future__`, `abc`, `asyncio`, `contextlib`, `dataclasses`, `datetime`, `enum` -- `functools`, `json`, `logging`, `os`, `pathlib`, `re`, `sys`, `tempfile`, `time` -- `typing`, `unittest`, `uuid` +These are real runtime requirements that no source file imports by name, which is why +they cannot be found by grepping for import statements: -### Starlette (Included with FastAPI) +| Package | Why it is needed | +|---|---| +| `uvicorn[standard]` | The ASGI server, invoked as a command by the Dockerfile and `railway.json` | +| `python-multipart` | FastAPI parses `multipart/form-data` uploads through it | +| `asyncpg` | The driver behind the `postgresql+asyncpg://` URL | +| `psycopg2-binary` | `migrations/env.py` swaps to `+psycopg2` to render Alembic's offline SQL | +| `greenlet` | SQLAlchemy's async bridge | +| `python-dotenv` | How `pydantic-settings` reads `.env` | +| `pytest-asyncio` | A pytest plugin, enabled via `asyncio_mode = auto` in `pytest.ini` | -- `from starlette.middleware.base import ...` -- `from starlette.requests import ...` -- `from starlette.responses import ...` +## Embeddings: ONNX Runtime, not sentence-transformers -Starlette is a direct dependency of FastAPI and is automatically installed. +Earlier revisions of this document listed `sentence-transformers` (and, transitively, +PyTorch) as the embedding dependency. **That is no longer true and should not be +reintroduced.** The PyTorch stack was deliberately removed because loading it +exhausted memory on Railway's container and crashed the service on boot. -### Greenlet (SQLAlchemy Dependency) +Embeddings now run through `onnxruntime` and `tokenizers` against a quantised +`BAAI/bge-small-en-v1.5` INT8 model (~35MB), downloaded into the image at build time +by `scripts/download_embedding_model.py`. See `app/ingestion/embeddings.py`. -- `greenlet==3.1.1` — Required by SQLAlchemy for async support +## Coverage summary -### Python-dotenv (Optional but Included) +- **Packages in `requirements.txt`:** 28 +- **Imported directly:** 21 (plus Starlette, which ships with FastAPI) +- **Required without being imported:** 7 +- **Unaccounted for:** 0 -- `python-dotenv==1.0.1` — Used for `.env` file loading in development +## Verification commands ---- +Confirm every directly imported package resolves: -## 📋 Coverage Summary - -- **Total packages in requirements.txt:** 26 -- **Third-party imports found in code:** 21 -- **Coverage:** ✅ **100%** -- **Unused packages:** 0 (all are either direct imports or transitive dependencies) - ---- - -## 🔍 Files Scanned - -All Python files in: -- `app/` — Core application code -- `tests/` — Test suite -- `migrations/` — Alembic migrations - -**Excluded:** -- `__pycache__/` directories -- `.pyc` compiled files -- `node_modules` (frontend) - ---- - -## ✅ Verification Commands - -### Check all imports are importable: ```bash cd backend pip install -r requirements.txt -python -c "import fastapi, sqlalchemy, pgvector, sentence_transformers, groq, openai, anthropic, google.generativeai" +python -c "import fastapi, sqlalchemy, pgvector, onnxruntime, tokenizers, numpy, psutil, groq, openai, anthropic, google.generativeai" ``` -### Run tests to verify runtime dependencies: +Confirm runtime dependencies are satisfied end to end: + ```bash cd backend -pytest tests/ -v +pytest -q ``` -All 71 tests passing confirms all runtime dependencies are satisfied. +The suite mocks the database and the embedding service, so it needs no PostgreSQL +instance and no downloaded model — which is what lets CI run it with no services +attached. A green run is the real check here; the count changes too often to record. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5cd1c0e..17dfa86 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,7 +1,14 @@ import json from functools import lru_cache +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# The value auth_secret falls back to when nothing sets AUTH_SECRET. It is a public +# constant in a public repo, so a deployment still carrying it signs session cookies +# with a key anyone can read. _reject_insecure_auth_secret below refuses to build a +# non-debug Settings in that state. +DEV_AUTH_SECRET = "dev-only-change-me" + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") @@ -55,7 +62,7 @@ def async_database_url(self) -> str: ollama_base_url: str = "http://localhost:11434" # Authentication. Set a strong value in Railway/Vercel environments. - auth_secret: str = "dev-only-change-me" + auth_secret: str = DEV_AUTH_SECRET auth_cookie_name: str = "docuquery_session" auth_session_days: int = 30 # Production-safe default for Vercel -> Railway cross-site requests. @@ -98,6 +105,27 @@ def allowed_ext_set(self) -> set[str]: def max_file_size_bytes(self) -> int: return self.max_file_size_mb * 1024 * 1024 + @model_validator(mode="after") + def _reject_insecure_auth_secret(self) -> "Settings": + """Refuse to build production settings that sign sessions with a known key. + + auth_secret is the only input to the session cookie's HMAC, so the shipped + default lets anyone mint a cookie for any user id. Failing here — at + Settings() construction — means the process dies before create_app() wires a + single route, rather than serving forgeable sessions. Debug builds are exempt + so local development needs no setup. + """ + if self.debug: + return self + if not self.auth_secret.strip() or self.auth_secret == DEV_AUTH_SECRET: + raise ValueError( + "AUTH_SECRET is unset or still the development default. Set it to a " + "strong random value (e.g. `python -c \"import secrets; " + "print(secrets.token_urlsafe(48))\"`) in the deployment environment, " + "or set DEBUG=true for local development." + ) + return self + @lru_cache def get_settings() -> Settings: diff --git a/backend/evaluation/AUDIT.md b/backend/evaluation/AUDIT.md new file mode 100644 index 0000000..a15a8e0 --- /dev/null +++ b/backend/evaluation/AUDIT.md @@ -0,0 +1,302 @@ +# DocuQuery quantitative audit + +Audit date: 2026-09-20 · Branch `docs/refresh-dependency-verification` · Commit `0f876bb` + +Every number below is either read directly out of the repository or produced by a +script in this folder. Raw output for each measured figure is in `results/`. +Nothing here is estimated, extrapolated, or carried over from the README. + +Measurement machine: Windows 11, Python 3.13.1, onnxruntime 1.30.0, +PostgreSQL 16.14 + pgvector 0.8.5 in Docker (`backend-db-1`, port 5433). + +--- + +## 1. What could NOT be measured, and why + +State these plainly rather than working around them. + +| Metric | Why not | +|---|---| +| Recall@1/3/5, Precision@k, MRR, NDCG@k, Hit Rate | **No document corpus exists in the repository.** Zero PDFs, zero DOCX. The only file in `backend/uploads/` is a 51-byte auth test stub. The harness to compute all of these is built and proven (§5); it needs documents. | +| Answer correctness | No ground-truth QA dataset exists, and none can be derived without a corpus. | +| Test coverage % | No `coverage` or `pytest-cov` in `backend/requirements.txt`. No coverage measurement has ever been run. | +| Cloud cost, $/query, $/document | No billing export, no usage dashboard data in the repo. | +| Real users, sessions, uploads, queries served | No analytics, no request logs retained, no production telemetry. | +| Prompt-injection defence rate | No adversarial test set exists. | + +--- + +## 2. Verified metric table + +### 2a. Measured this session (new experiments) + +| Metric | Value | Unit | What was measured | Evidence | Reproducible | Resume value | +|---|---|---|---|---|---|---| +| Backend tests passing | 79 / 79 | tests | Full suite, 0 failures | `pytest -q`, 129.1 s | High | Medium | +| Embedding throughput, batch 32 | 4.4 | embeddings/s | 128 real passages, ONNX INT8, 1 CPU thread | `results/perf_bench_smoke.json` | High | High | +| Embedding throughput, batch 1 | 0.7 | embeddings/s | same texts, unbatched | same | High | High | +| Batching speed-up (32 vs 1) | 6.17× | ratio | 178.4 s → 28.9 s for 128 texts | same | High | **Very high** | +| Batch 64 regression | 3.43× slower | ratio | 28.9 s → 99.1 s vs batch 32 | same | High | **Very high** | +| Query embedding latency | 9.4 / 16.1 / 59.1 | ms P50/P95/P99 | `embed_query()`, n=24 | same | High | High | +| Retrieval latency, end to end | 11.8 / 14.1 / 389.2 | ms P50/P95/P99 | `run_retrieval_pipeline()` incl. pgvector HNSW, n=24 | same | High | **Very high** | +| Retrieval throughput @ concurrency 8 | 24.8 | queries/s | wall-clock, n=2 — **thin sample, re-run on a real corpus** | same | High | Medium | +| LLM time to first token | 194–429 | ms | Groq streaming, n=2 — **thin sample** | same | Medium | High | +| LLM end-to-end answer latency | 318–478 | ms | retrieval + generation, n=2 — **thin sample** | same | Medium | High | +| ONNX session load | 295 | ms | `EmbeddingService.warm_up()` | `results/ingest_*.json` | High | Medium | +| First ONNX inference | 6.2 | ms | after session load | same | High | Low | +| `langchain_text_splitters` cold import | ~17,200 | ms | first `chunk_pages()` call in a process | same | High | Medium | +| Per-chunk embedding cost | ~146 | ms/chunk | 20 chunks in 2,911 ms at batch 32 | same | High | High | +| Parse + clean + chunk (markdown) | < 2 | ms | 11,720 chars | same | High | Low | +| Vector insert (20 chunks) | 65 | ms | `store_chunks()` + commit | same | High | Medium | +| Process RSS with model loaded | 485 → 870 | MB | before / after embedding 30 chunks | same | High | Medium | + +### 2b. Read from the repository (existing evidence) + +| Metric | Value | Unit | Evidence | +|---|---|---|---| +| Embedding dimension | 384 | dims | `app/database/models.py:12`, verified in DB via `vector_dims()` | +| Embedding model on disk | 32.4 | MB | `models/bge-small-en-v1.5-onnx-int8/model.onnx`, 34,014,426 bytes | +| Vector storage per chunk | 1,536 | bytes | 384 dims × 4-byte float32 (pgvector format) | +| Supported file formats | 4 | formats | `app/ingestion/supported_formats.py` — pdf, docx, txt, md | +| API endpoints | 13 | endpoints | `@router.*` decorators in `app/api/` | +| LLM providers | 5 | providers | `app/llm/providers/` — Groq, OpenAI, Anthropic, Gemini, Ollama | +| Chat / retrieval modes | 3 | modes | `docuquery`, `llm`, `hybrid` (`app/llm/prompts.py`) | +| Citation granularity levels | 3 | levels | document_id, page, chunk_index (`app/retrieval/citations.py`) | +| Metadata filter fields | 7 | fields | `app/retrieval/filters.py:_ALLOWED_FILTER_KEYS` | +| Chunk size / overlap | 800 / 120 | chars | `app/core/config.py` | +| Retrieval top-k | 5 | chunks | `retrieval_top_k` | +| Over-fetch factor | 2× | ratio | `fetch_k = top_k * 2` in `retrieval_pipeline.py:80` | +| Similarity threshold | 0.30 | cosine sim | `retrieval_similarity_threshold` | +| Context budget | 3,000 / 12,000 | tokens / chars | `llm_max_context_tokens`, `build_context(max_chars)` | +| Max upload size | 50 | MB | `max_file_size_mb` | +| Password hash iterations | 310,000 | PBKDF2-SHA256 rounds | `app/core/security.py:17` | +| Password salt | 128 | bits | `secrets.token_bytes(16)` | +| Session lifetime | 30 | days | `auth_session_days` | +| Rate limits | 10 / 30 | req/min (upload / chat) | `app/core/config.py` | +| Security headers set | 5 (+ Cache-Control) | headers | `app/core/middleware.py` | +| Alembic migrations | 4 | migrations | `migrations/versions/` | +| Backend application code | 3,280 | lines Python | `backend/app/**/*.py` | +| Frontend application code | 5,864 | lines TS/TSX | `frontend/{app,components,hooks,lib,services,stores,types}` | +| Backend test code | 902 | lines | `backend/tests/` | +| Commits | 54 (43 on `main`) | commits | `git rev-list --count --all` | +| Development span | 2026-05-26 → 2026-09-20 | ~4 months | `git log` | +| CI gates | 5 | jobs/steps | pytest, tsc, lint, build, install — **on an unmerged branch** | + +--- + +## 3. Retrieval evaluation — status per metric + +| Metric | Computable now? | What it needs | +|---|---|---| +| Recall@1 / @3 / @5 | No | A document corpus. Harness ready. | +| Precision@k | No | Same. | +| MRR | No | Same. | +| Hit Rate@5 | No | Same. Note: with one relevant page per query, Hit Rate@5 **is** Recall@5 — they are not two independent results. | +| NDCG@k | No | Same. | +| Page / citation retrieval accuracy | No | Same, and the corpus must be **multi-page** — Markdown and TXT parse to a single page, so page-level accuracy is only distinguishable from document-level on PDFs. | +| Chunk / answer-span accuracy | No | Same. | +| Answer correctness | No | Human-labelled answers, or an LLM-judge protocol that does not yet exist here. | + +> **Cannot calculate any of these from the current repository because there is no ground-truth dataset and no document corpus.** + +The harness that computes all of them is written, wired to the real pipeline, +and **proven end to end** on a 4-file throwaway corpus (`results/retrieval_eval_smoke.json`, +n=2 — far too small to report, run only to prove the code path). + +--- + +## 4. Experiment results + +### 4a. Embedding batch size — the one real experiment completed + +128 identical passages, ONNX INT8 bge-small-en-v1.5, `intra_op_num_threads=1`. + +| Batch size | Wall time (s) | Embeddings/s | ms/embedding | vs batch 32 | +|---|---|---|---|---| +| 1 | 178.41 | 0.7 | 1,393.8 | 6.17× slower | +| 8 | 51.87 | 2.5 | 405.2 | 1.79× slower | +| **32 (shipped default)** | **28.93** | **4.4** | **226.0** | — | +| 64 | 99.10 | 1.3 | 774.2 | 3.43× slower | + +**Result:** the shipped `EMBEDDING_BATCH_SIZE=32` is optimal on this hardware. +Moving from unbatched to batch-32 is a **6.17× throughput improvement** +(83.8% reduction in wall time). Batch 64 regresses sharply, consistent with +memory pressure on a single-threaded session — which is exactly the constraint +that forced the ONNX rewrite after the PyTorch stack OOM-killed on Railway Free. + +Command: `python evaluation/run_perf_bench.py --user-id --eval-set ` +Evidence: `results/perf_bench_smoke.json` → `embedding.by_batch_size` + +### 4b. Chunking and metadata-filter comparisons + +Not run — blocked on the corpus. `run_config_sweep.py` will compare +800/120 (current) against 400/60, 1200/180, and 800/0, plus threshold 0.30 vs 0.00 +and with/without a `document_id` metadata filter, on one fixed query set. + +--- + +## 5. Reliability / testing + +- **79 tests, 79 passing, 0 failing**, 129.1 s (`pytest -q`). +- Distribution: retrieval 26, LLM 21, ingestion 19, health/API 10, security 2, streaming 1. +- **No coverage measurement exists.** No `pytest-cov` in requirements; percentage coverage cannot be stated. +- API endpoint coverage: 3 of 13 endpoints are exercised by tests (`/health`, `/chat`, `/upload` preflight). `/retrieve`, `/documents*`, and all four `/auth/*` routes have no route-level test. +- Failure paths that **are** tested: ingestion marks `FAILED` on error; retrieval raises `NoResultsError` when everything is below threshold and when search returns nothing; session tampering is rejected; password hashes are one-way; SSE whitespace is preserved exactly. +- Failure paths with **no test**: malformed/corrupt PDF, oversized upload rejection, unsupported extension rejection, DB unavailable, LLM provider timeout/rate-limit, embedding-service failure. + +--- + +## 6. Security / production + +Measurable facts only — no security feature is converted into a percentage. + +| Control | Evidence | +|---|---| +| Password hashing | PBKDF2-HMAC-SHA256, 310,000 iterations, 128-bit random salt, `hmac.compare_digest` verification (`app/core/security.py`) | +| Session tokens | HMAC-SHA256 signed, base64url, explicit `exp` check, constant-time compare | +| Session cookie | HttpOnly, Secure (default true), SameSite=None, 30-day max-age | +| Tenant isolation | Every document, chunk, and retrieval query is filtered by `Document.user_id`; enforced in `retrieval_pipeline.py:77` and all `/documents` routes | +| Input validation | Pydantic schemas on all bodies; filter keys rejected against an allow-list (`InvalidFilterError`) | +| Rate limiting | slowapi per-IP: 10/min upload, 30/min chat | +| Security headers | X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, Cache-Control | +| DB constraints | Unique index on `users.email`; `ON DELETE CASCADE` from users → documents → chunks | +| Secrets handling | All secrets via env; `.env` gitignored; `.env.example` ships placeholders | +| Docs exposure | `/docs`, `/redoc`, `/openapi.json` disabled unless `DEBUG=true` | +| Container hardening | Dockerfile runs as non-root `appuser` | + +**Not implemented:** no RBAC, no prompt-injection defence, no account lockout or +login attempt throttling, no CSRF token (mitigated by SameSite + JSON-only bodies). + +--- + +## 6b. Deployment — verified live + +Probed directly against the hosts on 2026-09-20, not read from committed config +(the repo's `.env` points at `localhost`; the real URLs live in the Vercel and +Railway dashboards and were recovered from git history and the deployed JS bundle). + +| Target | Result | +|---|---| +| `https://docuqueryvc.vercel.app` | HTTP 200, serves the app (`DocuQuery — Understand your documents with AI`) | +| `https://docuqueryprod.up.railway.app/health` | HTTP 200, `{"status":"ok","app":"DocuQuery","version":"0.1.0","database":"ok"}` | +| `GET /auth/me` with no cookie | HTTP 401 | +| `GET /documents` with no cookie | HTTP 401 | + +**"Deployed and publicly accessible" is a defensible resume claim.** Both tiers +respond, the API reports live database connectivity, and protected routes reject +unauthenticated requests rather than leaking data. + +Two caveats worth knowing before an interview: +- The first `/health` call took **4.1 s**, consistent with a cold start on + Railway's free tier. A reviewer clicking the link may wait several seconds. +- Liveness was verified; a full signup → upload → ask → cited-answer round trip + against production was **not** re-run in this audit. + +Commands: +``` +curl -s -o /dev/null -w "%{http_code}" -L https://docuqueryvc.vercel.app +curl -s https://docuqueryprod.up.railway.app/health +curl -s -o /dev/null -w "%{http_code}" https://docuqueryprod.up.railway.app/auth/me +``` + +--- + +## 7. Defects found during this audit + +1. ~~**`.env` names the wrong embedding model.**~~ **Not reproducible — withdrawn.** + The committed `backend/.env.example` has read `EMBEDDING_MODEL=BAAI/bge-small-en-v1.5` + since `2f46ff0`, the same commit that swapped the model, and it matches both + `Settings.embedding_model` and the `bge-small-en-v1.5-onnx-int8` directory that is + actually loaded. The finding appears to have been taken from an untracked local + `.env` rather than the checked-in template. +2. ~~**Migration `a9f3d6c1b5e8` leaves documents unsearchable but still marked indexed.**~~ + **Fixed** by `d5e2c8a7f1b3`, which sets `status='failed'` on documents holding at + least one NULL-embedding chunk. `a9f3d6c1b5e8` itself is untouched: it has already + run in production, and Alembic will not re-run a stamped revision. Repair is out of + band via `backend/scripts/reindex.py`, because re-embedding inside a migration would + block boot — `railway.json` runs `alembic upgrade head` on every start. +3. ~~**README documents 12 endpoints; 13 exist.**~~ **Fixed** — `GET /documents/{id}/debug` + is now in the README API table. It was kept rather than removed: nothing in + `frontend/` calls it, but it is filtered by `current_user.id` like every other + document route, and it is the diagnostic that surfaces defect 2 (recorded chunk + count vs. actual vs. how many carry an embedding), which `reindex.py` is verified + against. +4. ~~**CI does not actually gate `main`.**~~ **Partly addressed.** `.github/workflows/ci.yml` + existed only on the unmerged branch `fix/auth-secret-guard-ci`. An identical copy is now + on `docs/refresh-dependency-verification`, and the README's fake static + `build-passing` badge has been replaced with the real GitHub Actions badge. + Landing the workflow also required cherry-picking `cc718d0` (a + `react-hooks/set-state-in-effect` error in `useDemoLoop.ts`), without which + `npm run lint` exits 1 and the frontend job fails. + All four CI steps verified locally: pytest 79 passed, `tsc --noEmit` 0, + `npm run lint` 0, `npm run build` 0. + **Still open:** the workflow must reach `main` before the badge resolves — a + GitHub Actions badge reads the workflow from the default branch. + +--- + +## 8. Resume bullets that are defensible today + +Only these. Every other bullet must wait for the corpus. + +> **"Benchmarked ONNX INT8 embedding throughput across batch sizes 1/8/32/64 and +> identified batch 32 as optimal, a 6.17× throughput gain over unbatched inference +> (178.4 s → 28.9 s for 128 passages) and 3.4× over batch 64."** +> +> - What I did: wrote a reproducible benchmark that holds the input fixed and varies only batch size. +> - Measured by: `backend/evaluation/run_perf_bench.py` +> - Evidence: `backend/evaluation/results/perf_bench_smoke.json` +> - Confidence: **HIGH** + +> **"Measured end-to-end retrieval latency of a PostgreSQL/pgvector HNSW pipeline at +> P50 11.8 ms and P95 14.1 ms, covering query embedding, ANN search, threshold +> filtering, ranking and citation extraction."** +> +> - What I did: instrumented the production retrieval function and sampled it repeatedly rather than timing one call. +> - Measured by: `backend/evaluation/run_perf_bench.py` +> - Evidence: `results/perf_bench_smoke.json` → `retrieval.latency_ms` +> - Confidence: **HIGH** (n=24; re-run on the real corpus to widen the sample) + +> **"Cut the embedding service's memory footprint enough to fit a 512 MB deployment +> tier by replacing PyTorch/sentence-transformers with ONNX Runtime INT8, holding +> the same 384-dimensional bge-small-en-v1.5 vectors."** +> +> - What I did: diagnosed a Railway OOM SIGKILL, rewrote the embedding layer, and migrated the vector column 768 → 384. +> - Evidence: commits `fix: replace PyTorch embedding stack with ONNX Runtime`, `fix: swap embedding model to bge-small to fix Railway OOM crash`; migration `a9f3d6c1b5e8`. +> - Confidence: **MEDIUM** — the *direction* is documented in code and commits, but the before/after MB figures were never captured. To make this HIGH, see experiment 3 below. + +> **"Built and maintained a 79-test backend suite covering parsing, chunking, +> retrieval scoring, citation extraction, prompt construction, provider +> abstraction, SSE streaming, password hashing and session validation."** +> +> - Measured by: `cd backend && pytest -q` +> - Evidence: 79 passed in 129.12 s +> - Confidence: **HIGH** +> - Caveat: do not claim a coverage percentage. None has been measured. + +--- + +## 9. The experiments that would most improve this resume + +In order of payoff. + +1. **Ingest a real multi-page corpus and run the retrieval evaluation.** + Unlocks Recall@1/3/5, MRR, NDCG@5, Precision@5 and page-level citation accuracy + — the single highest-value class of metric for a Data & AI application, and the + only one that demonstrates *evaluation* rather than *engineering*. + `ingest_corpus.py` → `build_eval_set.py --n 80` → `run_retrieval_eval.py` + +2. **Run the chunking sweep.** Produces a genuine before/after improvement claim + ("Recall@5 X% → Y%, +Z pp") from a controlled experiment on a fixed query set. + `run_config_sweep.py --corpus ` + +3. **Capture the PyTorch → ONNX memory delta.** Measure RSS after loading + sentence-transformers vs after loading the ONNX session, in the same container. + Converts the strongest *story* in this project into a hard number. + +4. **Measure test coverage.** Add `pytest-cov`, run `pytest --cov=app`. One command, + turns "79 tests" into "79 tests, N% coverage". + +5. **Widen the LLM latency sample.** Current TTFT figures rest on 2 calls. Run + `run_perf_bench.py --llm --llm-queries 50` once a real query set exists to get + defensible P50/P95/P99 for time-to-first-token. diff --git a/backend/evaluation/README.md b/backend/evaluation/README.md new file mode 100644 index 0000000..dcb883c --- /dev/null +++ b/backend/evaluation/README.md @@ -0,0 +1,39 @@ +# DocuQuery evaluation harness + +Reproducible measurement of retrieval quality and pipeline performance. + +Nothing here is used by the running application. Every script writes its raw +output to `results/` as JSON so a number in a report can be traced back to the +run that produced it. + +## Prerequisites + + docker compose up -d db # from backend/ + alembic upgrade head + +Set `EVAL_DATABASE_URL` if the evaluation database is not the one in `.env`. + +## Scripts + +| Script | What it does | +|---|---| +| `ingest_corpus.py` | Ingests a folder of documents through the real ingestion pipeline under a synthetic eval user, recording per-stage timings. | +| `build_eval_set.py` | Builds a ground-truth query set from indexed pages. Queries are generated by the configured LLM from page passages; ground truth is the source `(document, page, passage)`. | +| `run_retrieval_eval.py` | Runs the query set through the real retrieval pipeline and computes Recall@k, Precision@k, MRR, Hit Rate, NDCG@k at document / page / passage level. | +| `run_config_sweep.py` | Re-ingests the corpus under different chunking and retrieval configurations and compares them on the same fixed query set. | +| `run_perf_bench.py` | Per-stage ingestion timings, retrieval latency percentiles, LLM TTFT and generation latency, throughput, memory. | + +## Methodology notes + +- The eval set is **synthetic**: queries are LLM-generated from passages in the + corpus, not written by humans. Ground truth is the passage the query was + generated from. This is a standard way to build a retrieval test set when no + labelled data exists, and it is stated wherever the numbers are reported. +- A generated query may legitimately be answerable from more than one passage. + Passage-level recall is therefore a **lower bound**; document-level recall is + the looser, more forgiving figure. Both are reported. +- Ground truth is anchored to `(document_id, page_number)`, which is independent + of chunk size. This is what makes the chunking sweep a fair comparison. +- Query generation happens once, before any retrieval run, and the generator + never sees retrieval output. No query is added, removed, or edited after + seeing a score. diff --git a/backend/evaluation/_common.py b/backend/evaluation/_common.py new file mode 100644 index 0000000..8686574 --- /dev/null +++ b/backend/evaluation/_common.py @@ -0,0 +1,148 @@ +""" +Shared plumbing for the evaluation harness. + +Everything here exists so the individual scripts stay short and so that the +evaluation talks to the *real* application code -- the same parser, cleaner, +chunker, embedding service, and retrieval pipeline the API uses. Nothing is +reimplemented for measurement purposes. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import subprocess +import sys +from dataclasses import asdict, is_dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# Make `app.*` importable when this file is run from backend/evaluation/. +BACKEND_ROOT = Path(__file__).resolve().parent.parent +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +RESULTS_DIR = Path(__file__).resolve().parent / "results" +RESULTS_DIR.mkdir(exist_ok=True) + + +def eval_database_url() -> str: + """ + The database the harness measures against. + + Defaults to the application's own DATABASE_URL so the evaluation runs + against the same schema the app runs against. Override with + EVAL_DATABASE_URL to point at a throwaway database instead. + """ + from app.core.config import get_settings + + override = os.getenv("EVAL_DATABASE_URL") + if override: + return override.replace("postgresql://", "postgresql+asyncpg://", 1) + return get_settings().async_database_url + + +def config_id(**parts: Any) -> str: + """ + A short, stable id for a configuration. + + Used as the synthetic `user_id` under which a configuration's documents are + ingested, which makes the retrieval pipeline's existing per-user filter do + the isolation between configurations for us -- no extra schema, and the + isolation path being exercised is the production one. + """ + blob = json.dumps(parts, sort_keys=True, separators=(",", ":")) + return "eval-" + hashlib.sha256(blob.encode()).hexdigest()[:16] + + +def environment() -> dict: + """Machine and version facts, recorded alongside every result file.""" + import numpy + + env = { + "captured_at": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), + "processor": platform.processor(), + "python": sys.version.split()[0], + "cpu_count_logical": os.cpu_count(), + "numpy": numpy.__version__, + } + + try: + import psutil + + env["cpu_count_physical"] = psutil.cpu_count(logical=False) + env["total_ram_gb"] = round(psutil.virtual_memory().total / 1024**3, 2) + except ImportError: + pass + + try: + import onnxruntime + + env["onnxruntime"] = onnxruntime.__version__ + except ImportError: + pass + + try: + env["git_commit"] = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], cwd=BACKEND_ROOT, text=True + ).strip() + env["git_dirty"] = bool( + subprocess.check_output( + ["git", "status", "--porcelain"], cwd=BACKEND_ROOT, text=True + ).strip() + ) + except Exception: + pass + + return env + + +def _plain(value: Any) -> Any: + if is_dataclass(value) and not isinstance(value, type): + return {k: _plain(v) for k, v in asdict(value).items()} + if isinstance(value, dict): + return {k: _plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_plain(v) for v in value] + if isinstance(value, datetime): + return value.isoformat() + return value + + +def save(name: str, payload: dict) -> Path: + """Write a result file, always stamped with the environment it came from.""" + out = RESULTS_DIR / name + body = {"environment": environment(), **_plain(payload)} + out.write_text(json.dumps(body, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"[saved] {out}") + return out + + +def load(name: str) -> dict: + return json.loads((RESULTS_DIR / name).read_text(encoding="utf-8")) + + +def percentiles(values: list[float], points: tuple[int, ...] = (50, 95, 99)) -> dict: + """ + Nearest-rank percentiles. + + Deliberately not interpolated: with the sample sizes this harness produces, + an interpolated P99 is a number no single run actually exhibited. + """ + if not values: + return {f"p{p}": None for p in points} | {"min": None, "max": None, "mean": None, "n": 0} + + ordered = sorted(values) + result: dict[str, float | int | None] = {} + for p in points: + rank = max(1, -(-p * len(ordered) // 100)) # ceil(p/100 * n) + result[f"p{p}"] = round(ordered[rank - 1], 3) + result["min"] = round(ordered[0], 3) + result["max"] = round(ordered[-1], 3) + result["mean"] = round(sum(ordered) / len(ordered), 3) + result["n"] = len(ordered) + return result diff --git a/backend/evaluation/build_eval_set.py b/backend/evaluation/build_eval_set.py new file mode 100644 index 0000000..30876d2 --- /dev/null +++ b/backend/evaluation/build_eval_set.py @@ -0,0 +1,297 @@ +""" +Build a ground-truth retrieval query set from an indexed corpus. + +Method +------ +1. Read every indexed page of the corpus back out of the database, reassembled + from its chunks so the passage text is exactly what was indexed. +2. Select candidate passages deterministically (fixed seed, stated filters). + Passages that are too short, mostly digits, or mostly punctuation are + rejected before any query is generated, because a question generated from + a table of page numbers has no defensible ground truth. +3. Ask the configured LLM to write a natural question answerable *only* from + that passage, and to name the short span that answers it. +4. Ground truth for the query is the passage's (document_id, page_number) plus + the passage text itself. + +Why ground truth is anchored to (document, page) rather than to a chunk id: +chunk ids change when chunk size changes, so a chunk-anchored query set cannot +be reused across chunking configurations. Anchoring to the page keeps one fixed +query set valid for every configuration under test, which is what makes the +chunking sweep a like-for-like comparison. + +The generator never sees retrieval output. This file is written once and is not +edited afterwards; scores are computed against it as-is. + +Usage: + python evaluation/build_eval_set.py --user-id eval-xxxx --n 80 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import re +import time +from collections import defaultdict + +from _common import RESULTS_DIR, eval_database_url, save + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.core.config import get_settings +from app.database.models import Document, DocumentChunk, ProcessingStatus +from app.llm.models import LLMRequest +from app.llm.providers import get_provider + +GENERATOR_SYSTEM_PROMPT = """You write evaluation questions for a document search system. + +You are given one passage from a document. Write exactly one question that: +- is answerable using ONLY that passage +- a real person might plausibly ask about this document +- does NOT quote the passage verbatim; use your own wording +- does NOT refer to "the passage", "the text", "the document", "above", or "this section" +- names the specific subject it asks about, so it is meaningful without seeing the passage + +Also quote the shortest span from the passage that answers it. + +Respond with JSON only, no prose, no code fences: +{"question": "...", "answer_span": "..."} + +If the passage is boilerplate, a table of contents, a page header, a reference +list, or otherwise has no factual content worth asking about, respond exactly: +{"question": null, "answer_span": null}""" + +MIN_PASSAGE_CHARS = 400 +MAX_PASSAGE_CHARS = 2400 + + +def is_usable(text: str) -> tuple[bool, str]: + """Reject passages that cannot support a defensible ground-truth question.""" + stripped = text.strip() + if len(stripped) < MIN_PASSAGE_CHARS: + return False, "too_short" + + letters = sum(c.isalpha() for c in stripped) + if letters / len(stripped) < 0.55: + return False, "not_enough_prose" + + words = re.findall(r"[A-Za-z]{2,}", stripped) + if len(words) < 60: + return False, "too_few_words" + if len(set(w.lower() for w in words)) / len(words) < 0.35: + return False, "too_repetitive" + + return True, "ok" + + +async def load_pages(db, user_id: str) -> list[dict]: + """Reassemble each indexed page from its chunks, in chunk order.""" + rows = ( + await db.execute( + select(DocumentChunk, Document.original_filename) + .join(Document, DocumentChunk.document_id == Document.id) + .where(Document.user_id == user_id) + .where(Document.status == ProcessingStatus.indexed) + .order_by(DocumentChunk.document_id, DocumentChunk.chunk_index) + ) + ).all() + + grouped: dict[tuple[str, int], dict] = defaultdict( + lambda: {"texts": [], "chunk_indexes": []} + ) + for chunk, filename in rows: + key = (chunk.document_id, chunk.page_number) + entry = grouped[key] + entry["document_id"] = chunk.document_id + entry["filename"] = filename + entry["page_number"] = chunk.page_number + entry["texts"].append(chunk.text) + entry["chunk_indexes"].append(chunk.chunk_index) + + pages = [] + for entry in grouped.values(): + # Chunks overlap, so joining them repeats the overlap region. That is + # fine for question generation -- the passage only needs to be faithful + # to what was indexed, and overlap duplication does not invent content. + entry["text"] = "\n".join(entry.pop("texts")) + pages.append(entry) + + pages.sort(key=lambda p: (p["filename"], p["page_number"])) + return pages + + +def select_passages(pages: list[dict], n: int, seed: int) -> tuple[list[dict], dict]: + """ + Pick n candidate passages, spread across documents, with a fixed seed. + + Documents are visited round-robin so a single long report cannot dominate + the query set. + """ + usable: list[dict] = [] + rejected: dict[str, int] = defaultdict(int) + + for page in pages: + ok, reason = is_usable(page["text"]) + if ok: + page = dict(page) + page["text"] = page["text"].strip()[:MAX_PASSAGE_CHARS] + usable.append(page) + else: + rejected[reason] += 1 + + by_doc: dict[str, list[dict]] = defaultdict(list) + for page in usable: + by_doc[page["document_id"]].append(page) + + rng = random.Random(seed) + for pages_of_doc in by_doc.values(): + rng.shuffle(pages_of_doc) + + order = sorted(by_doc) + rng.shuffle(order) + + selected: list[dict] = [] + cursor = 0 + while len(selected) < n: + added_this_round = False + for doc_id in order: + if cursor < len(by_doc[doc_id]): + selected.append(by_doc[doc_id][cursor]) + added_this_round = True + if len(selected) == n: + break + if not added_this_round: + break + cursor += 1 + + stats = { + "pages_total": len(pages), + "pages_usable": len(usable), + "pages_rejected": dict(rejected), + "documents_with_usable_pages": len(by_doc), + "selected": len(selected), + } + return selected, stats + + +def parse_generated(raw: str) -> dict | None: + """Pull the JSON object out of a model response, tolerating stray prose.""" + text = raw.strip() + text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip() + match = re.search(r"\{.*\}", text, re.DOTALL) + if not match: + return None + try: + data = json.loads(match.group(0)) + except json.JSONDecodeError: + return None + if not isinstance(data, dict) or not data.get("question"): + return None + return data + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--user-id", required=True, help="config id from ingest_corpus.py") + ap.add_argument("--n", type=int, default=80, help="target number of queries") + ap.add_argument("--seed", type=int, default=20260920) + ap.add_argument("--out", default="eval_set.json") + args = ap.parse_args() + + settings = get_settings() + engine = create_async_engine(eval_database_url(), pool_pre_ping=True) + Session = async_sessionmaker(engine, expire_on_commit=False) + + async with Session() as db: + pages = await load_pages(db, args.user_id) + await engine.dispose() + + if not pages: + raise SystemExit(f"No indexed pages found for user_id={args.user_id}") + + # Over-sample: some passages will be rejected by the generator as boilerplate. + candidates, stats = select_passages(pages, int(args.n * 1.6), args.seed) + print(f"[passages] {stats}") + + provider = get_provider( + settings.llm_provider, + api_key=settings.llm_api_key, + ollama_base_url=settings.ollama_base_url, + timeout=settings.llm_timeout, + ) + + queries: list[dict] = [] + skipped = 0 + errors = 0 + t_start = time.perf_counter() + + for i, page in enumerate(candidates, 1): + if len(queries) >= args.n: + break + request = LLMRequest( + system_prompt=GENERATOR_SYSTEM_PROMPT, + user_message="Write the question.", + context=f"PASSAGE (from {page['filename']}, page {page['page_number']}):\n{page['text']}", + model=settings.llm_model, + temperature=0.0, + max_tokens=300, + ) + try: + response = await provider.generate(request) + except Exception as exc: + errors += 1 + print(f"[{i}] generation error: {type(exc).__name__}: {exc}") + await asyncio.sleep(2.0) + continue + + parsed = parse_generated(response.answer) + if parsed is None: + skipped += 1 + continue + + queries.append( + { + "query_id": f"q{len(queries) + 1:03d}", + "query": parsed["question"].strip(), + "answer_span": (parsed.get("answer_span") or "").strip(), + "gt_document_id": page["document_id"], + "gt_filename": page["filename"], + "gt_page": page["page_number"], + "gt_passage": page["text"], + "gt_chunk_indexes": sorted(page["chunk_indexes"]), + } + ) + if len(queries) % 10 == 0: + print(f"[{i}] generated {len(queries)}/{args.n}") + + elapsed = round(time.perf_counter() - t_start, 1) + payload = { + "source_user_id": args.user_id, + "seed": args.seed, + "target_n": args.n, + "generator_model": settings.llm_model, + "generator_provider": settings.llm_provider, + "generator_temperature": 0.0, + "generator_system_prompt": GENERATOR_SYSTEM_PROMPT, + "passage_selection": stats, + "candidates_offered": len(candidates), + "queries_generated": len(queries), + "passages_rejected_by_generator": skipped, + "generation_errors": errors, + "generation_seconds": elapsed, + "documents_covered": len({q["gt_document_id"] for q in queries}), + "queries": queries, + } + save(args.out, payload) + print( + f"[done] {len(queries)} queries across " + f"{payload['documents_covered']} document(s) in {elapsed}s" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/evaluation/corpus/manifest.json b/backend/evaluation/corpus/manifest.json new file mode 100644 index 0000000..f683053 --- /dev/null +++ b/backend/evaluation/corpus/manifest.json @@ -0,0 +1,304 @@ +{ + "description": "Retrieval evaluation corpus for DocuQuery. Multi-page, text-rich public documents used to build the ground-truth query set and to produce BASELINE.md.", + "selection_note": "Every document is a U.S. federal government publication and therefore public domain in the United States. That is deliberate: it avoids making a redistribution claim about a third-party paper whose licence would have to be verified per item. GAO reports were the original plan and were dropped -- gao.gov returns 403 to any non-browser User-Agent, and spoofing one to get past a publisher's bot policy is not a dependency this corpus should have. The trade-off of the sources that remain is a corpus skewed toward cybersecurity, risk-management and macroeconomic prose, which is noted wherever these numbers are reported.", + "files_are_gitignored": true, + "documents": [ + { + "id": "nist-sp-800-53r5", + "filename": "NIST.SP.800-53r5.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-53r5.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Security and Privacy Controls for Information Systems and Organizations", + "sha256": "fc63bcd61715d0181dd8e85998b1e6201ae3515fc6626102101cab1841e11ec6", + "bytes": 6073678, + "pages": 492 + }, + { + "id": "nist-sp-800-207", + "filename": "NIST.SP.800-207.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-207.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Zero Trust Architecture", + "sha256": "0290d6ece24874287316f4bf430fef770aa4ec08a2227c8f2c1e5b2ff975e03d", + "bytes": 966908, + "pages": 59 + }, + { + "id": "nist-sp-800-63-3", + "filename": "NIST.SP.800-63-3.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-3.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Digital Identity Guidelines", + "sha256": "915bb812cb705918461e642e84141c6ca2adc2ca0106e1d88f3884c61f07110a", + "bytes": 1599268, + "pages": 76 + }, + { + "id": "nist-sp-800-161r1", + "filename": "NIST.SP.800-161r1.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-161r1.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Cybersecurity Supply Chain Risk Management Practices for Systems and Organizations", + "sha256": "c8250b1ea542640a7cffc145be08c6b17ff76bd695f8c0ac1b40c49d45355d89", + "bytes": 4845469, + "pages": 327 + }, + { + "id": "nist-sp-800-37r2", + "filename": "NIST.SP.800-37r2.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-37r2.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Risk Management Framework for Information Systems and Organizations", + "sha256": "4f75e1136bb905a654f1bcd9eefefb4108f47597276ee5eec0688f6d96f6a01d", + "bytes": 2270327, + "pages": 183 + }, + { + "id": "nist-sp-800-171r2", + "filename": "NIST.SP.800-171r2.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-171r2.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Protecting Controlled Unclassified Information in Nonfederal Systems and Organizations", + "sha256": "298bdbfcf6a4890a564b225c893230a0b32b2e69e3b98dd898aaeb1d544c5e12", + "bytes": 1539170, + "pages": 114 + }, + { + "id": "nist-sp-800-190", + "filename": "NIST.SP.800-190.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-190.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Application Container Security Guide", + "sha256": "0ebad52c4a3aba971b3a707b056e57238d1c4ad8f212dffd461ff9f5fed1bdb6", + "bytes": 666229, + "pages": 63 + }, + { + "id": "nist-sp-800-218", + "filename": "NIST.SP.800-218.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-218.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Secure Software Development Framework (SSDF)", + "sha256": "617746e553a9e2da49bfbd4eef0dfc3094758a39b869314e4173ac36605cde22", + "bytes": 739891, + "pages": 36 + }, + { + "id": "nist-sp-800-61r2", + "filename": "NIST.SP.800-61r2.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Computer Security Incident Handling Guide", + "sha256": "b1c5ead0632dcfb979c59d8c462b094ad2465ddaf62d5c246289d6d9b4419df1", + "bytes": 1744473, + "pages": 80 + }, + { + "id": "nist-sp-800-88r1", + "filename": "NIST.SP.800-88r1.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-88r1.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Guidelines for Media Sanitization", + "sha256": "acd2315cb1823d51db93b180d898fabfdf5e964e3fcb41cf44d14b4c3ec8c5c4", + "bytes": 714563, + "pages": 65 + }, + { + "id": "nist-ai-100-1", + "filename": "NIST.AI.100-1.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Artificial Intelligence Risk Management Framework (AI RMF 1.0)", + "sha256": "7576edb531d9848825814ee88e28b1795d3a84b435b4b797d3670eafdc4a89f1", + "bytes": 1946127, + "pages": 48 + }, + { + "id": "nist-cswp-cyberframework-1-1", + "filename": "NIST.CSWP.04162018.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.04162018.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Framework for Improving Critical Infrastructure Cybersecurity, Version 1.1", + "sha256": "0f3ca796610ab024cbc3484cbd6799e19b4d9159d3634972a2a76af83f15fb92", + "bytes": 1062822, + "pages": 55 + }, + { + "id": "nist-ir-8259", + "filename": "NIST.IR.8259.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/ir/2020/NIST.IR.8259.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Foundational Cybersecurity Activities for IoT Device Manufacturers", + "sha256": "837d42bd6588a359bbdba265c477b7cf4f867aa4a36b5c5262df570382b2b382", + "bytes": 1552600, + "pages": 37 + }, + { + "id": "frb-annual-report-2023", + "filename": "frb-2023-annual-report.pdf", + "url": "https://www.federalreserve.gov/publications/files/2023-annual-report.pdf", + "publisher": "Board of Governors of the Federal Reserve System", + "license": "Public domain — published by the Board of Governors of the Federal Reserve System, which places its reports in the public domain.", + "title": "Federal Reserve Board Annual Report 2023", + "sha256": "aacf44d05ebbc21f5e78d029864dc69871f88364b61a2ae405ad4ca43143f666", + "bytes": 2255266, + "pages": 222 + }, + { + "id": "nist-sp-800-145", + "filename": "nistspecialpublication800-145.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-145.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "The NIST Definition of Cloud Computing", + "sha256": "7b0c1a9fdfc67218b8ba2098f448c100c27070db91736b3c87fed63bfa21d418", + "bytes": 85781, + "pages": 7 + }, + { + "id": "nist-sp-800-146", + "filename": "nistspecialpublication800-146.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-146.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Cloud Computing Synopsis and Recommendations", + "sha256": "03dd14aafeb121566b5c79167824327e8e3b1ed1cb5bd68989370ca8f23cf55c", + "bytes": 1517698, + "pages": 81 + }, + { + "id": "nist-sp-800-30r1", + "filename": "nistspecialpublication800-30r1.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-30r1.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Guide for Conducting Risk Assessments", + "sha256": "f214087f0bdb35932a28c16eb93932a33c67a6edfb5e6f1638866bd201c98e04", + "bytes": 826897, + "pages": 95 + }, + { + "id": "nist-sp-800-34r1", + "filename": "nistspecialpublication800-34r1.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-34r1.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Contingency Planning Guide for Federal Information Systems", + "sha256": "476f04d4c60b0770c1ac8b2720551972074ce78e936a3e7a535048416111d0df", + "bytes": 952921, + "pages": 149 + }, + { + "id": "nist-sp-800-115", + "filename": "nistspecialpublication800-115.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-115.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Technical Guide to Information Security Testing and Assessment", + "sha256": "58e5ed41e5c8ca34ce14fd80b70f118f2c6d613d1647bc307edca30bcc45f063", + "bytes": 544047, + "pages": 80 + }, + { + "id": "nist-sp-800-122", + "filename": "nistspecialpublication800-122.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-122.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Guide to Protecting the Confidentiality of Personally Identifiable Information (PII)", + "sha256": "555b9df82af12de4ca887efc21c0a9e159a14485d5d570f44b32ef15c6c62470", + "bytes": 818516, + "pages": 59 + }, + { + "id": "nist-sp-800-92", + "filename": "nistspecialpublication800-92.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-92.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Guide to Computer Security Log Management", + "sha256": "466c3ab1580bc3b2e25d11bbd4b2ac92fd1762b3c5b10596924981bc1b9e61e1", + "bytes": 1759842, + "pages": 72 + }, + { + "id": "nist-sp-800-184", + "filename": "NIST.SP.800-184.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-184.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Guide for Cybersecurity Event Recovery", + "sha256": "c03b6245d3ebbe71a5b52072f756504b76f9fd36e8e8c8a71115364b09f2946f", + "bytes": 841777, + "pages": 53 + }, + { + "id": "nist-sp-800-209", + "filename": "NIST.SP.800-209.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-209.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Security Guidelines for Storage Infrastructure", + "sha256": "d94e95a18e6a12395d7e3ed8707266028ad188ce0a6e985137278d3e4416706d", + "bytes": 847488, + "pages": 79 + }, + { + "id": "nist-sp-800-213", + "filename": "NIST.SP.800-213.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-213.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "IoT Device Cybersecurity Guidance for the Federal Government", + "sha256": "8f90d1fc02a3436444dca859b2f66e97942f443c07109798aeaee1315a6c60ae", + "bytes": 2386815, + "pages": 47 + }, + { + "id": "nist-sp-1800-25", + "filename": "NIST.SP.1800-25.pdf", + "url": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.1800-25.pdf", + "publisher": "NIST", + "license": "Public domain — work of the U.S. Government, 17 U.S.C. §105. NIST publications carry no copyright in the United States.", + "title": "Data Integrity: Identifying and Protecting Assets Against Ransomware and Other Destructive Events", + "sha256": "9d2c8ebcefe04046afc07ee54252ec40390c5be52c3b43792638753f25d57266", + "bytes": 44288051, + "pages": 567 + }, + { + "id": "frb-annual-report-2022", + "filename": "frb-2022-annual-report.pdf", + "url": "https://www.federalreserve.gov/publications/files/2022-annual-report.pdf", + "publisher": "Board of Governors of the Federal Reserve System", + "license": "Public domain — published by the Board of Governors of the Federal Reserve System, which places its reports in the public domain.", + "title": "Federal Reserve Board Annual Report 2022", + "sha256": "8db6ce9cf9f6939f15b634f8f3fca2e4ec442fa5c45cec130cfcb0b50cc0766b", + "bytes": 2237245, + "pages": 224 + }, + { + "id": "frb-scf-2023", + "filename": "frb-scf23.pdf", + "url": "https://www.federalreserve.gov/publications/files/scf23.pdf", + "publisher": "Board of Governors of the Federal Reserve System", + "license": "Public domain — published by the Board of Governors of the Federal Reserve System, which places its reports in the public domain.", + "title": "Changes in U.S. Family Finances from 2019 to 2022: Evidence from the Survey of Consumer Finances", + "sha256": "2666446e9c0cc100b9816d8b7c6569722d9d96af5359ce8f2de51aedf97b8276", + "bytes": 10136125, + "pages": 58 + } + ] +} diff --git a/backend/evaluation/download_corpus.py b/backend/evaluation/download_corpus.py new file mode 100644 index 0000000..6fcc25c --- /dev/null +++ b/backend/evaluation/download_corpus.py @@ -0,0 +1,173 @@ +""" +Fetch the evaluation corpus described by corpus/manifest.json. + +The PDFs are not in the repository -- they are large, and they belong to their +publishers rather than to this project. The manifest is what is version +controlled: a URL, a SHA-256 and a license note for every document, so that the +corpus one person measures against is byte-for-byte the corpus another person +gets. + + python -m evaluation.download_corpus # fetch + verify + python -m evaluation.download_corpus --verify-only # check what is on disk + python -m evaluation.download_corpus --write-hashes # record hashes (maintainers) + +`--write-hashes` is how a new document gets into the manifest: add the entry with +`"sha256": null`, run it once, commit the hash it fills in. It refuses to +overwrite a hash that is already recorded, so it cannot silently bless a document +that changed upstream -- that shows up as a verification failure instead, which +is the whole point of recording the hash. + +A file that fails verification is left on disk with a .corrupt suffix rather than +deleted, so it can be looked at. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import sys +import urllib.error +import urllib.request +from pathlib import Path + +CORPUS_DIR = Path(__file__).resolve().parent / "corpus" +MANIFEST_PATH = CORPUS_DIR / "manifest.json" +FILES_DIR = CORPUS_DIR / "files" + +# Several of these publishers reject requests without a descriptive User-Agent. +# NIST and GAO both do; an unset agent gets a 403. +USER_AGENT = "DocuQuery-Eval/0.1 (retrieval evaluation corpus; +https://github.com/VampiricCyborg/DocuQuery)" + +_CHUNK = 1 << 16 + + +def sha256_of(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(_CHUNK), b""): + digest.update(block) + return digest.hexdigest() + + +def _describe_pdf(path: Path) -> tuple[int | None, str | None]: + """Page count and embedded title, read from the file itself.""" + try: + import pymupdf + except ImportError: # pragma: no cover - pymupdf is a project dependency + return None, None + try: + with pymupdf.open(path) as document: + title = (document.metadata or {}).get("title") or None + return document.page_count, (title.strip() or None) if title else None + except Exception as exc: # a corrupt download should not crash the run + print(f" (could not read {path.name}: {exc})") + return None, None + + +def load_manifest() -> dict: + if not MANIFEST_PATH.is_file(): + raise SystemExit(f"No manifest at {MANIFEST_PATH}") + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def download(url: str, destination: Path) -> None: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + tmp = destination.with_suffix(destination.suffix + ".part") + with urllib.request.urlopen(request, timeout=120) as response, tmp.open("wb") as handle: + shutil.copyfileobj(response, handle) + tmp.replace(destination) + + +def process(entry: dict, *, verify_only: bool, write_hashes: bool) -> str: + """Returns one of: ok, downloaded, hashed, mismatch, missing, error.""" + destination = FILES_DIR / entry["filename"] + expected = entry.get("sha256") + + if not destination.is_file(): + if verify_only: + print(f" MISSING {entry['filename']}") + return "missing" + try: + print(f" fetching {entry['filename']} ...", flush=True) + download(entry["url"], destination) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc: + print(f" ERROR {entry['filename']}: {exc}") + return "error" + outcome = "downloaded" + else: + outcome = "ok" + + actual = sha256_of(destination) + + if expected is None: + if write_hashes: + entry["sha256"] = actual + entry["bytes"] = destination.stat().st_size + pages, title = _describe_pdf(destination) + entry["pages"] = pages + # Recorded from the file rather than typed by hand, so the manifest + # cannot drift from what the corpus actually contains. + if title and not entry.get("title"): + entry["title"] = title + print(f" RECORDED {entry['filename']} {actual[:16]}... {pages}p") + return "hashed" + print(f" NO HASH {entry['filename']} — run with --write-hashes") + return "error" + + if actual != expected: + corrupt = destination.with_suffix(destination.suffix + ".corrupt") + destination.replace(corrupt) + print( + f" MISMATCH {entry['filename']}\n" + f" expected {expected}\n" + f" actual {actual}\n" + f" moved to {corrupt.name}" + ) + return "mismatch" + + print(f" {'fetched' if outcome == 'downloaded' else 'verified':<10} {entry['filename']}") + return outcome + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[1]) + parser.add_argument( + "--verify-only", + action="store_true", + help="Check the files already on disk; download nothing.", + ) + parser.add_argument( + "--write-hashes", + action="store_true", + help="Fill in manifest entries whose sha256 is null. Never overwrites one.", + ) + args = parser.parse_args() + + manifest = load_manifest() + documents = manifest["documents"] + FILES_DIR.mkdir(parents=True, exist_ok=True) + + print(f"[corpus] {len(documents)} document(s) in {MANIFEST_PATH.name}\n") + + counts: dict[str, int] = {} + for entry in documents: + outcome = process(entry, verify_only=args.verify_only, write_hashes=args.write_hashes) + counts[outcome] = counts.get(outcome, 0) + 1 + + if args.write_hashes and counts.get("hashed"): + MANIFEST_PATH.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f"\n[corpus] wrote {counts['hashed']} hash(es) to {MANIFEST_PATH.name}") + + print("\n[corpus] " + ", ".join(f"{count} {name}" for name, count in sorted(counts.items()))) + print(f"[corpus] files in {FILES_DIR}") + + failed = counts.get("mismatch", 0) + counts.get("error", 0) + counts.get("missing", 0) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/evaluation/ingest_corpus.py b/backend/evaluation/ingest_corpus.py new file mode 100644 index 0000000..8adf7c7 --- /dev/null +++ b/backend/evaluation/ingest_corpus.py @@ -0,0 +1,273 @@ +""" +Ingest a corpus through the real ingestion pipeline, stage by stage, with timings. + +This does not go through HTTP. It calls the same parser, cleaner, chunker, +embedding service and vector store that POST /upload calls, so the timings +describe the actual pipeline rather than a reimplementation of it -- but it +calls each stage separately so each one can be timed on its own. + +Documents are ingested under a synthetic user whose id encodes the chunking +configuration. Retrieval already filters by Document.user_id, so two +configurations can coexist in one database without interfering, and the +isolation being relied on is the production isolation. + +Usage: + python evaluation/ingest_corpus.py --corpus ../corpus + python evaluation/ingest_corpus.py --corpus ../corpus --chunk-size 400 --chunk-overlap 60 + python evaluation/ingest_corpus.py --corpus ../corpus --reset +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import time +from pathlib import Path + +from _common import config_id, eval_database_url, save + +from sqlalchemy import delete, select, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import get_settings +from app.database.models import Document, DocumentChunk, ProcessingStatus, User +from app.ingestion.chunker import chunk_pages +from app.ingestion.cleaner import clean_pages +from app.ingestion.embeddings import get_embedding_service +from app.ingestion.parser import parse_document +from app.ingestion.supported_formats import SUPPORTED_EXTENSIONS +from app.ingestion.vector_store import store_chunks + + +def rss_mb() -> float | None: + try: + import psutil + except ImportError: + return None + return round(psutil.Process(os.getpid()).memory_info().rss / 1024**2, 1) + + +async def ensure_user(db: AsyncSession, user_id: str) -> None: + if await db.scalar(select(User).where(User.id == user_id)): + return + db.add( + User( + id=user_id, + name="evaluation harness", + email=f"{user_id}@evaluation.invalid", + # Not a login account. The hash is a constant placeholder that no + # password verifies against; the row exists only to satisfy the + # documents.user_id foreign key. + password_hash="pbkdf2_sha256$310000$evaluation$evaluation", + ) + ) + await db.commit() + + +async def reset_config(db: AsyncSession, user_id: str) -> int: + """Delete every document previously ingested under this configuration.""" + doc_ids = list(await db.scalars(select(Document.id).where(Document.user_id == user_id))) + if doc_ids: + await db.execute(delete(DocumentChunk).where(DocumentChunk.document_id.in_(doc_ids))) + await db.execute(delete(Document).where(Document.id.in_(doc_ids))) + await db.commit() + return len(doc_ids) + + +async def ingest_one(path: Path, user_id: str, db: AsyncSession, embedder) -> dict: + """Run one document through every stage, timing each stage separately.""" + size_bytes = path.stat().st_size + + doc = Document( + user_id=user_id, + filename=path.name, + original_filename=path.name, + file_type=path.suffix.lstrip(".").lower(), + file_size=size_bytes, + storage_path=str(path), + status=ProcessingStatus.processing, + ) + db.add(doc) + await db.commit() + await db.refresh(doc) + + record: dict = { + "document_id": doc.id, + "filename": path.name, + "file_type": doc.file_type, + "file_size_bytes": size_bytes, + } + + try: + t = time.perf_counter() + pages = parse_document(str(path)) + record["parse_ms"] = round((time.perf_counter() - t) * 1000, 2) + + t = time.perf_counter() + pages = clean_pages(pages) + record["clean_ms"] = round((time.perf_counter() - t) * 1000, 2) + + record["pages"] = len(pages) + record["chars"] = sum(len(p.text) for p in pages) + + t = time.perf_counter() + chunks = chunk_pages(pages, document_id=doc.id, filename=path.name) + record["chunk_ms"] = round((time.perf_counter() - t) * 1000, 2) + record["chunks"] = len(chunks) + record["chunk_chars_mean"] = ( + round(sum(len(c.text) for c in chunks) / len(chunks), 1) if chunks else 0 + ) + + if not chunks: + raise ValueError("chunking produced 0 chunks") + + rss_before = rss_mb() + t = time.perf_counter() + embeddings = embedder.embed([c.text for c in chunks]) + record["embed_ms"] = round((time.perf_counter() - t) * 1000, 2) + record["embedding_dim"] = len(embeddings[0]) + record["rss_before_embed_mb"] = rss_before + record["rss_after_embed_mb"] = rss_mb() + + t = time.perf_counter() + await store_chunks(chunks, embeddings, db) + doc.status = ProcessingStatus.indexed + doc.total_chunks = len(chunks) + await db.commit() + record["store_ms"] = round((time.perf_counter() - t) * 1000, 2) + + record["total_ms"] = round( + record["parse_ms"] + + record["clean_ms"] + + record["chunk_ms"] + + record["embed_ms"] + + record["store_ms"], + 2, + ) + record["status"] = "indexed" + + except Exception as exc: + await db.rollback() + doc_row = await db.get(Document, doc.id) + if doc_row is not None: + doc_row.status = ProcessingStatus.failed + await db.commit() + record["status"] = "failed" + record["error"] = f"{type(exc).__name__}: {exc}" + + return record + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--corpus", required=True, help="folder of documents to ingest") + ap.add_argument("--chunk-size", type=int, default=None) + ap.add_argument("--chunk-overlap", type=int, default=None) + ap.add_argument("--label", default="", help="optional extra tag for the config id") + ap.add_argument("--reset", action="store_true", help="delete this config's docs first") + ap.add_argument("--out", default=None, help="result filename") + args = ap.parse_args() + + settings = get_settings() + if args.chunk_size is not None: + settings.chunk_size = args.chunk_size + if args.chunk_overlap is not None: + settings.chunk_overlap = args.chunk_overlap + + cfg = { + "chunk_size": settings.chunk_size, + "chunk_overlap": settings.chunk_overlap, + "embedding_model": settings.embedding_model, + "label": args.label, + } + user_id = config_id(**cfg) + print(f"[config] {cfg}") + print(f"[config] user_id={user_id}") + + corpus = Path(args.corpus).resolve() + files = sorted( + p + for p in corpus.rglob("*") + if p.is_file() and p.suffix.lstrip(".").lower() in SUPPORTED_EXTENSIONS + ) + if not files: + raise SystemExit(f"No supported documents found under {corpus}") + print(f"[corpus] {len(files)} document(s) in {corpus}") + + engine = create_async_engine(eval_database_url(), pool_pre_ping=True) + Session = async_sessionmaker(engine, expire_on_commit=False) + + embedder = get_embedding_service() + t = time.perf_counter() + embedder.warm_up() + model_load_ms = round((time.perf_counter() - t) * 1000, 2) + + # warm_up() only builds the ONNX session. The first actual inference pays + # for graph optimisation and thread-pool startup -- tens of seconds on this + # machine -- which would otherwise land entirely on whichever document + # happens to be ingested first and make its timing meaningless. Burn that + # cost here, on a throwaway input, so the per-document numbers are clean. + t = time.perf_counter() + embedder.embed(["warm-up inference, result discarded"]) + first_inference_ms = round((time.perf_counter() - t) * 1000, 2) + + # chunk_pages() imports langchain_text_splitters lazily, and that import + # costs seconds the first time. Same problem: it would be billed to the + # first document. Pay it here instead. + from app.ingestion.parser import ParsedPage + + t = time.perf_counter() + chunk_pages([ParsedPage(page_number=1, text="warm-up")], document_id="warm-up", filename="warm-up") + chunker_import_ms = round((time.perf_counter() - t) * 1000, 2) + + print( + f"[model] session {model_load_ms} ms, first inference {first_inference_ms} ms, " + f"chunker import {chunker_import_ms} ms" + ) + + records = [] + async with Session() as db: + await db.execute(text("SELECT 1")) + await ensure_user(db, user_id) + if args.reset: + removed = await reset_config(db, user_id) + print(f"[reset] removed {removed} previously ingested document(s)") + + for i, path in enumerate(files, 1): + rec = await ingest_one(path, user_id, db, embedder) + records.append(rec) + print( + f"[{i}/{len(files)}] {path.name}: {rec['status']} " + f"pages={rec.get('pages')} chunks={rec.get('chunks')} " + f"total={rec.get('total_ms')}ms" + ) + + await engine.dispose() + + ok = [r for r in records if r["status"] == "indexed"] + payload = { + "config": cfg, + "user_id": user_id, + "corpus_dir": str(corpus), + "model_load_ms": model_load_ms, + "model_first_inference_ms": first_inference_ms, + "chunker_import_ms": chunker_import_ms, + "documents_attempted": len(records), + "documents_indexed": len(ok), + "documents_failed": len(records) - len(ok), + "total_pages": sum(r.get("pages", 0) for r in ok), + "total_chunks": sum(r.get("chunks", 0) for r in ok), + "total_chars": sum(r.get("chars", 0) for r in ok), + "total_bytes": sum(r.get("file_size_bytes", 0) for r in ok), + "documents": records, + } + save(args.out or f"ingest_{user_id}.json", payload) + print( + f"[done] indexed={len(ok)}/{len(records)} " + f"chunks={payload['total_chunks']} pages={payload['total_pages']}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/evaluation/results/eval_set_smoke.json b/backend/evaluation/results/eval_set_smoke.json new file mode 100644 index 0000000..c4bcbf5 --- /dev/null +++ b/backend/evaluation/results/eval_set_smoke.json @@ -0,0 +1,89 @@ +{ + "environment": { + "captured_at": "2026-09-20T12:13:20.686269+00:00", + "platform": "Windows-11-10.0.26200-SP0", + "processor": "Intel64 Family 6 Model 154 Stepping 3, GenuineIntel", + "python": "3.13.1", + "cpu_count_logical": 12, + "numpy": "2.2.6", + "cpu_count_physical": 8, + "total_ram_gb": 15.68, + "onnxruntime": "1.26.0", + "git_commit": "0f876bb", + "git_dirty": true + }, + "source_user_id": "eval-cf71fa4ac73d7e91", + "seed": 20260920, + "target_n": 12, + "generator_model": "qwen/qwen3.8-27b", + "generator_provider": "groq", + "generator_temperature": 0.0, + "generator_system_prompt": "You write evaluation questions for a document search system.\n\nYou are given one passage from a document. Write exactly one question that:\n- is answerable using ONLY that passage\n- a real person might plausibly ask about this document\n- does NOT quote the passage verbatim; use your own wording\n- does NOT refer to \"the passage\", \"the text\", \"the document\", \"above\", or \"this section\"\n- names the specific subject it asks about, so it is meaningful without seeing the passage\n\nAlso quote the shortest span from the passage that answers it.\n\nRespond with JSON only, no prose, no code fences:\n{\"question\": \"...\", \"answer_span\": \"...\"}\n\nIf the passage is boilerplate, a table of contents, a page header, a reference\nlist, or otherwise has no factual content worth asking about, respond exactly:\n{\"question\": null, \"answer_span\": null}", + "passage_selection": { + "pages_total": 4, + "pages_usable": 2, + "pages_rejected": { + "too_short": 2 + }, + "documents_with_usable_pages": 2, + "selected": 2 + }, + "candidates_offered": 2, + "queries_generated": 2, + "passages_rejected_by_generator": 0, + "generation_errors": 0, + "generation_seconds": 0.7, + "documents_covered": 2, + "queries": [ + { + "query_id": "q001", + "query": "What is the maximum file size supported for uploads in DocuQuery?", + "answer_span": "up to 50MB", + "gt_document_id": "6da1be77-be1f-4d76-9b7d-1a4d19218b9c", + "gt_filename": "README.md", + "gt_page": 1, + "gt_passage": "
\n\n\"DocuQuery\n\n
\n\n# DocuQuery\n\n### Turn a folder of PDFs, DOCX, and text files into a source you can ask questions.\n\nUpload your documents. DocuQuery reads them, indexes them, and answers questions with the\nexact page and passage the answer came from — no more skimming a 40-page report for one number.\n\n[**Live demo**](#-see-it-work) · [**Quick start**](#-quick-start) · [**Features**](#-features) · [**API reference**](#-api-reference) · [**Roadmap**](#-roadmap)\n![Build Status](https://img.shields.io/badge/build-passing-brightgreen?style=flat-square)\n![Version](https://img.shields.io/badge/version-0.1.0-blue?style=flat-square)\n![Next.js](https://img.shields.io/badge/Next.js-16.2-black?style=flat-square&logo=next.js)\n![React](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)\n![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square&logo=fastapi)\n![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16%20%2B%20pgvector-336791?style=flat-square&logo=postgresql)\n![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)\n![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)\n\n
\n\n---\n\n## What DocuQuery actually does\n\n\n---\n\n## What DocuQuery actually does\n\nMost \"chat with your docs\" demos are a thin wrapper around one LLM call. DocuQuery is the\nplumbing that makes that call trustworthy: it parses your files properly, chunks and embeds\nthem, stores the vectors in Postgres, retrieves the passages that actually answer the question,\nand hands the model only that — then shows you exactly where the answer came from.\n- 📂 **Drop in a file** — PDF, DOCX, TXT, or Markdown, up to 50MB\n- ⚡ **It gets indexed automatically** — parsed, cleaned, chunked, embedded, and stored in seconds\n- 💬 **Ask in plain language** — no query syntax, no filters to configure\n- 📎 **Get an answer with receipts** — every claim links back to the filename, page, and passage\n- 🔀 **Pick how strict you want it** — answer strictly from your documents, from general knowledge, or both\n\n---\n\n## 👀 See it work\n\n
\n\"A, HNSW index)" + }, + "metrics": { + "document": { + "queries": 2, + "recall_at_1": 1.0, + "precision_at_1": 1.0, + "ndcg_at_1": 1.0, + "recall_at_3": 1.0, + "precision_at_3": 1.0, + "ndcg_at_3": 1.0, + "recall_at_5": 1.0, + "precision_at_5": 0.9, + "ndcg_at_5": 1.0, + "recall_at_10": 1.0, + "precision_at_10": 0.8, + "ndcg_at_10": 0.987, + "hit_rate_at_5": 1.0, + "hit_rate_at_5_note": "identical to recall_at_5: ground truth names one relevant page per query", + "mrr": 1.0 + }, + "page": { + "queries": 2, + "recall_at_1": 1.0, + "precision_at_1": 1.0, + "ndcg_at_1": 1.0, + "recall_at_3": 1.0, + "precision_at_3": 1.0, + "ndcg_at_3": 1.0, + "recall_at_5": 1.0, + "precision_at_5": 0.9, + "ndcg_at_5": 1.0, + "recall_at_10": 1.0, + "precision_at_10": 0.8, + "ndcg_at_10": 0.987, + "hit_rate_at_5": 1.0, + "hit_rate_at_5_note": "identical to recall_at_5: ground truth names one relevant page per query", + "mrr": 1.0 + }, + "answer": { + "queries": 2, + "recall_at_1": 1.0, + "precision_at_1": 1.0, + "ndcg_at_1": 1.0, + "recall_at_3": 1.0, + "precision_at_3": 0.3333, + "ndcg_at_3": 1.0, + "recall_at_5": 1.0, + "precision_at_5": 0.2, + "ndcg_at_5": 1.0, + "recall_at_10": 1.0, + "precision_at_10": 0.1, + "ndcg_at_10": 1.0, + "hit_rate_at_5": 1.0, + "hit_rate_at_5_note": "identical to recall_at_5: ground truth names one relevant page per query", + "mrr": 1.0, + "scorable_queries": 2, + "note": "scored only on queries whose generated answer span is present verbatim in the ground-truth passage" + } + }, + "queries_with_no_results": 0, + "retrieval_latency_ms": { + "p50": 22.07, + "p95": 488.567, + "p99": 488.567, + "min": 22.07, + "max": 488.567, + "mean": 255.318, + "n": 2 + }, + "per_query": [ + { + "query_id": "q001", + "query": "What is the maximum file size supported for uploads in DocuQuery?", + "gt_filename": "README.md", + "gt_page": 1, + "answer_span_scorable": true, + "retrieved": [ + { + "rank": 1, + "filename": "README.md", + "page": 1, + "chunk_index": 3, + "similarity": 0.8436, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": true + }, + { + "rank": 2, + "filename": "README.md", + "page": 1, + "chunk_index": 0, + "similarity": 0.8396, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 3, + "filename": "README.md", + "page": 1, + "chunk_index": 16, + "similarity": 0.8379, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 4, + "filename": "README.md", + "page": 1, + "chunk_index": 5, + "similarity": 0.8316, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 5, + "filename": "README.md", + "page": 1, + "chunk_index": 2, + "similarity": 0.8298, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 6, + "filename": "README.md", + "page": 1, + "chunk_index": 12, + "similarity": 0.816, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 7, + "filename": "README.md", + "page": 1, + "chunk_index": 13, + "similarity": 0.8143, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 8, + "filename": "README.md", + "page": 1, + "chunk_index": 4, + "similarity": 0.8138, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 9, + "filename": "README.md", + "page": 1, + "chunk_index": 7, + "similarity": 0.8077, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 10, + "filename": "README.md", + "page": 1, + "chunk_index": 18, + "similarity": 0.8073, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + } + ], + "first_relevant_rank": { + "document": 1, + "page": 1, + "answer": 1 + }, + "latency_ms": 488.57, + "n_retrieved": 10 + }, + { + "query_id": "q002", + "query": "Which package is required for FastAPI to parse multipart/form-data uploads but is never imported directly by the source code?", + "gt_filename": "DEPENDENCIES_VERIFIED.md", + "gt_page": 1, + "answer_span_scorable": true, + "retrieved": [ + { + "rank": 1, + "filename": "DEPENDENCIES_VERIFIED.md", + "page": 1, + "chunk_index": 4, + "similarity": 0.8855, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": true + }, + { + "rank": 2, + "filename": "DEPENDENCIES_VERIFIED.md", + "page": 1, + "chunk_index": 1, + "similarity": 0.8843, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 3, + "filename": "DEPENDENCIES_VERIFIED.md", + "page": 1, + "chunk_index": 6, + "similarity": 0.8786, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 4, + "filename": "DEPENDENCIES_VERIFIED.md", + "page": 1, + "chunk_index": 3, + "similarity": 0.8635, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 5, + "filename": "README.md", + "page": 1, + "chunk_index": 10, + "similarity": 0.8532, + "relevant_document": false, + "relevant_page": false, + "relevant_answer": false + }, + { + "rank": 6, + "filename": "DEPENDENCIES_VERIFIED.md", + "page": 1, + "chunk_index": 2, + "similarity": 0.8456, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 7, + "filename": "README.md", + "page": 1, + "chunk_index": 12, + "similarity": 0.8401, + "relevant_document": false, + "relevant_page": false, + "relevant_answer": false + }, + { + "rank": 8, + "filename": "README.md", + "page": 1, + "chunk_index": 16, + "similarity": 0.8399, + "relevant_document": false, + "relevant_page": false, + "relevant_answer": false + }, + { + "rank": 9, + "filename": "DEPENDENCIES_VERIFIED.md", + "page": 1, + "chunk_index": 0, + "similarity": 0.8327, + "relevant_document": true, + "relevant_page": true, + "relevant_answer": false + }, + { + "rank": 10, + "filename": "README.md", + "page": 1, + "chunk_index": 11, + "similarity": 0.8276, + "relevant_document": false, + "relevant_page": false, + "relevant_answer": false + } + ], + "first_relevant_rank": { + "document": 1, + "page": 1, + "answer": 1 + }, + "latency_ms": 22.07, + "n_retrieved": 10 + } + ] +} \ No newline at end of file diff --git a/backend/evaluation/run_config_sweep.py b/backend/evaluation/run_config_sweep.py new file mode 100644 index 0000000..5473486 --- /dev/null +++ b/backend/evaluation/run_config_sweep.py @@ -0,0 +1,231 @@ +""" +Compare retrieval configurations on one fixed query set. + +Two kinds of comparison are run: + + index-side chunk size and overlap. Each configuration re-ingests the whole + corpus under its own synthetic user, so the index really is + rebuilt -- nothing is simulated by re-slicing an existing index. + + query-side similarity threshold and metadata filtering. These reuse the + baseline index, because neither of them changes what is stored. + +The query set is generated once from the baseline index and reused verbatim by +every configuration. Ground truth is anchored to (document, page), which does +not move when chunk size changes, so the comparison is like-for-like. + +Usage: + python evaluation/run_config_sweep.py --corpus ../corpus --baseline-user-id eval-xxxx +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from _common import RESULTS_DIR, config_id, load, save + +BACKEND_ROOT = Path(__file__).resolve().parent.parent + +# (label, chunk_size, chunk_overlap). The first entry must be the shipped +# default so every improvement is stated relative to what the product does now. +INDEX_CONFIGS = [ + ("current-800-120", 800, 120), + ("small-400-60", 400, 60), + ("large-1200-180", 1200, 180), + ("no-overlap-800-0", 800, 0), +] + +# (label, extra flags for run_retrieval_eval.py) +QUERY_CONFIGS = [ + ("threshold-0.30-default", []), + ("threshold-0.00", ["--no-threshold"]), + ("metadata-filter-document", ["--filter-document"]), +] + + +def run(cmd: list[str]) -> None: + print(f"\n$ {' '.join(cmd)}") + # Run from backend/ so pydantic-settings finds .env, same as the app does. + result = subprocess.run(cmd, cwd=BACKEND_ROOT) + if result.returncode != 0: + raise SystemExit(f"command failed with exit code {result.returncode}") + + +def headline(metrics: dict) -> dict: + """The comparable figures, pulled out of a full result file.""" + out = {} + for level in ("document", "page", "answer"): + m = metrics.get(level, {}) + out[level] = { + key: m.get(key) + for key in ( + "queries", + "recall_at_1", + "recall_at_3", + "recall_at_5", + "precision_at_5", + "ndcg_at_5", + "mrr", + ) + } + return out + + +def delta(baseline: float | None, candidate: float | None) -> dict: + if baseline is None or candidate is None: + return {"absolute_pp": None, "relative_pct": None} + absolute = round((candidate - baseline) * 100, 2) + relative = round((candidate - baseline) / baseline * 100, 2) if baseline else None + return {"absolute_pp": absolute, "relative_pct": relative} + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--corpus", required=True) + ap.add_argument("--eval-set", default="eval_set.json") + ap.add_argument("--top-k", type=int, default=10) + ap.add_argument( + "--skip-ingest", + action="store_true", + help="assume every index configuration is already ingested", + ) + ap.add_argument("--out", default="config_sweep.json") + args = ap.parse_args() + + eval_set = load(args.eval_set) + embedding_model = eval_set.get("generator_model") + print(f"[sweep] query set: {args.eval_set} ({len(eval_set['queries'])} queries)") + + from app.core.config import get_settings + + model_label = get_settings().embedding_model + + runs: dict[str, dict] = {} + + for label, chunk_size, overlap in INDEX_CONFIGS: + user_id = config_id( + chunk_size=chunk_size, + chunk_overlap=overlap, + embedding_model=model_label, + label="", + ) + if not args.skip_ingest: + run( + [ + sys.executable, + "evaluation/ingest_corpus.py", + "--corpus", + str(Path(args.corpus).resolve()), + "--chunk-size", + str(chunk_size), + "--chunk-overlap", + str(overlap), + "--reset", + ] + ) + run( + [ + sys.executable, + "evaluation/run_retrieval_eval.py", + "--user-id", + user_id, + "--eval-set", + args.eval_set, + "--top-k", + str(args.top_k), + "--label", + label, + ] + ) + result = load(f"retrieval_eval_{label}.json") + ingest = load(f"ingest_{user_id}.json") + runs[label] = { + "kind": "index", + "chunk_size": chunk_size, + "chunk_overlap": overlap, + "user_id": user_id, + "chunks_indexed": ingest["total_chunks"], + "documents_indexed": ingest["documents_indexed"], + "mean_chunk_chars": round( + sum( + d.get("chunk_chars_mean", 0) * d.get("chunks", 0) + for d in ingest["documents"] + if d["status"] == "indexed" + ) + / max(1, ingest["total_chunks"]), + 1, + ), + "retrieval_latency_ms": result["retrieval_latency_ms"], + "queries_with_no_results": result["queries_with_no_results"], + "metrics": headline(result["metrics"]), + } + + baseline_label = INDEX_CONFIGS[0][0] + baseline_user_id = runs[baseline_label]["user_id"] + + for label, flags in QUERY_CONFIGS: + run( + [ + sys.executable, + "evaluation/run_retrieval_eval.py", + "--user-id", + baseline_user_id, + "--eval-set", + args.eval_set, + "--top-k", + str(args.top_k), + "--label", + label, + *flags, + ] + ) + result = load(f"retrieval_eval_{label}.json") + runs[label] = { + "kind": "query", + "flags": flags, + "user_id": baseline_user_id, + "retrieval_latency_ms": result["retrieval_latency_ms"], + "queries_with_no_results": result["queries_with_no_results"], + "metrics": headline(result["metrics"]), + } + + baseline_metrics = runs[baseline_label]["metrics"] + comparison = {} + for label, entry in runs.items(): + if label == baseline_label: + continue + comparison[label] = { + level: { + metric: delta( + baseline_metrics[level][metric], entry["metrics"][level][metric] + ) + for metric in ("recall_at_1", "recall_at_3", "recall_at_5", "mrr", "ndcg_at_5") + } + for level in ("document", "page", "answer") + } + + save( + args.out, + { + "eval_set": args.eval_set, + "eval_set_size": len(eval_set["queries"]), + "top_k": args.top_k, + "baseline": baseline_label, + "runs": runs, + "vs_baseline": comparison, + }, + ) + + print("\n=== page-level recall@5 by configuration ===") + for label, entry in runs.items(): + value = entry["metrics"]["page"]["recall_at_5"] + marker = " (baseline)" if label == baseline_label else "" + print(f"{label:28s} {value}{marker}") + + +if __name__ == "__main__": + main() diff --git a/backend/evaluation/run_perf_bench.py b/backend/evaluation/run_perf_bench.py new file mode 100644 index 0000000..708145f --- /dev/null +++ b/backend/evaluation/run_perf_bench.py @@ -0,0 +1,296 @@ +""" +Performance benchmark: embedding throughput, retrieval latency, LLM latency. + +Ingestion-stage timings (parse / clean / chunk / embed / store) are produced by +ingest_corpus.py and are not repeated here; this script covers the query side +plus a standalone embedding throughput measurement. + +Every latency figure is reported as a distribution (min / P50 / P95 / P99 / max) +over a stated number of runs, after a discarded warm-up, so no number here is a +single lucky run. + +The --llm stage sends retrieved passages from the corpus to the configured LLM +provider over the network and consumes API quota. It is off by default. + +Usage: + python evaluation/run_perf_bench.py --user-id eval-xxxx + python evaluation/run_perf_bench.py --user-id eval-xxxx --llm --llm-queries 20 +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import statistics +import time + +from _common import eval_database_url, load, percentiles, save + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.core.config import get_settings +from app.ingestion.embeddings import get_embedding_service +from app.llm import get_response_generator +from app.retrieval.embedding_query import embed_query +from app.retrieval.exceptions import NoResultsError +from app.retrieval.retrieval_pipeline import run_retrieval_pipeline + + +def proc_sample() -> dict: + try: + import psutil + except ImportError: + return {} + p = psutil.Process(os.getpid()) + return { + "rss_mb": round(p.memory_info().rss / 1024**2, 1), + "cpu_percent": p.cpu_percent(interval=None), + } + + +def bench_embedding(texts: list[str], batch_sizes: tuple[int, ...]) -> dict: + """ + Embedding throughput at several batch sizes. + + The same text list is embedded each time so the only variable is batching. + """ + settings = get_settings() + service = get_embedding_service() + + t = time.perf_counter() + service.warm_up() + cold_load_ms = round((time.perf_counter() - t) * 1000, 2) + + service.embed(texts[:8]) # warm-up, result discarded + + results = {} + original = settings.embedding_batch_size + for batch_size in batch_sizes: + settings.embedding_batch_size = batch_size + t = time.perf_counter() + vectors = service.embed(texts) + elapsed = time.perf_counter() - t + results[f"batch_{batch_size}"] = { + "texts": len(texts), + "seconds": round(elapsed, 3), + "embeddings_per_second": round(len(texts) / elapsed, 1), + "ms_per_embedding": round(elapsed * 1000 / len(texts), 3), + "dim": len(vectors[0]), + } + print(f"[embed] batch={batch_size}: {results[f'batch_{batch_size}']}") + settings.embedding_batch_size = original + + return { + "cold_load_ms": cold_load_ms, + "model": settings.embedding_model, + "by_batch_size": results, + "process_after": proc_sample(), + } + + +async def bench_query_embedding(queries: list[str], repeats: int) -> dict: + samples = [] + for _ in range(repeats): + for query in queries: + t = time.perf_counter() + await embed_query(query) + samples.append((time.perf_counter() - t) * 1000) + return percentiles(samples) + + +async def bench_retrieval(db, user_id: str, queries: list[str], top_k: int, repeats: int) -> dict: + samples = [] + empty = 0 + chunk_counts = [] + context_chars = [] + for _ in range(repeats): + for query in queries: + t = time.perf_counter() + try: + result = await run_retrieval_pipeline( + query=query, db=db, user_id=user_id, top_k=top_k + ) + chunk_counts.append(len(result.chunks)) + context_chars.append(len(result.context)) + except NoResultsError: + empty += 1 + samples.append((time.perf_counter() - t) * 1000) + return { + "latency_ms": percentiles(samples), + "queries_per_second_serial": round(1000 / statistics.median(samples), 2) if samples else None, + "empty_results": empty, + "chunks_returned_mean": round(statistics.mean(chunk_counts), 2) if chunk_counts else None, + "context_chars_mean": round(statistics.mean(context_chars), 1) if context_chars else None, + "context_chars_max": max(context_chars) if context_chars else None, + } + + +async def bench_concurrent_retrieval( + engine, user_id: str, queries: list[str], top_k: int, concurrency: int +) -> dict: + """Wall-clock throughput with N retrievals in flight, each on its own session.""" + Session = async_sessionmaker(engine, expire_on_commit=False) + + async def one(query: str) -> float: + async with Session() as db: + t = time.perf_counter() + try: + await run_retrieval_pipeline(query=query, db=db, user_id=user_id, top_k=top_k) + except NoResultsError: + pass + return (time.perf_counter() - t) * 1000 + + semaphore = asyncio.Semaphore(concurrency) + + async def guarded(query: str) -> float: + async with semaphore: + return await one(query) + + t0 = time.perf_counter() + samples = await asyncio.gather(*(guarded(q) for q in queries)) + wall = time.perf_counter() - t0 + + return { + "concurrency": concurrency, + "queries": len(queries), + "wall_seconds": round(wall, 3), + "queries_per_second": round(len(queries) / wall, 2), + "latency_ms": percentiles(list(samples)), + } + + +async def bench_llm(db, user_id: str, queries: list[str], top_k: int) -> dict: + """ + Time to first streamed token and full generation latency, over real calls. + + Generation goes through ResponseGenerator.stream -- the same call POST /chat + makes -- so context truncation, the versioned system prompt and SSE framing + are all in the measured path. Retrieval is timed separately, so + time_to_first_token_ms excludes it and end_to_end_ms is what a user waits + for from question to last token. + """ + settings = get_settings() + generator = get_response_generator() + + ttft, generation, end_to_end, retrieval, chars, tokens, errors = [], [], [], [], [], [], [] + + for i, query in enumerate(queries, 1): + t_start = time.perf_counter() + try: + result = await run_retrieval_pipeline( + query=query, db=db, user_id=user_id, top_k=top_k + ) + except NoResultsError: + continue + t_retrieved = time.perf_counter() + + first_token_at = None + token_events = 0 + answer_chars = 0 + try: + async for event in generator.stream(query, result, mode="docuquery"): + if not event.startswith("event: token"): + continue + if first_token_at is None: + first_token_at = time.perf_counter() + token_events += 1 + answer_chars += len(event) - len("event: token\ndata: \n\n") + except Exception as exc: + errors.append(f"{type(exc).__name__}: {exc}") + print(f"[{i}] llm error: {type(exc).__name__}: {exc}") + continue + + t_end = time.perf_counter() + if first_token_at is None: + errors.append("stream produced no tokens") + continue + + retrieval.append((t_retrieved - t_start) * 1000) + ttft.append((first_token_at - t_retrieved) * 1000) + generation.append((t_end - t_retrieved) * 1000) + end_to_end.append((t_end - t_start) * 1000) + chars.append(float(answer_chars)) + tokens.append(float(token_events)) + print( + f"[{i}/{len(queries)}] ttft={ttft[-1]:.0f}ms " + f"total={end_to_end[-1]:.0f}ms tokens={token_events}" + ) + + return { + "provider": settings.llm_provider, + "model": settings.llm_model, + "temperature": settings.llm_temperature, + "max_tokens": settings.llm_max_tokens, + "max_context_tokens": settings.llm_max_context_tokens, + "completed": len(ttft), + "errors": errors, + "retrieval_ms": percentiles(retrieval), + "time_to_first_token_ms": percentiles(ttft), + "generation_ms": percentiles(generation), + "end_to_end_ms": percentiles(end_to_end), + "streamed_token_events": percentiles(tokens), + "answer_chars": percentiles(chars), + "note": ( + "streamed_token_events counts provider stream deltas, not tokeniser " + "tokens; answer_chars is JSON-escaped length and is an upper bound" + ), + } + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--user-id", required=True) + ap.add_argument("--eval-set", default="eval_set.json") + ap.add_argument("--top-k", type=int, default=5) + ap.add_argument("--repeats", type=int, default=3) + ap.add_argument("--embed-texts", type=int, default=256) + ap.add_argument("--concurrency", type=int, default=8) + ap.add_argument("--llm", action="store_true", help="run real LLM calls (uses quota)") + ap.add_argument("--llm-queries", type=int, default=20) + ap.add_argument("--out", default="perf_bench.json") + args = ap.parse_args() + + eval_set = load(args.eval_set) + queries = [q["query"] for q in eval_set["queries"]] + passages = [q["gt_passage"] for q in eval_set["queries"]] + print(f"[bench] {len(queries)} queries available") + + payload: dict = {"config": vars(args), "process_start": proc_sample()} + + texts = (passages * ((args.embed_texts // max(1, len(passages))) + 1))[: args.embed_texts] + payload["embedding"] = bench_embedding(texts, batch_sizes=(1, 8, 32, 64)) + + engine = create_async_engine(eval_database_url(), pool_pre_ping=True) + Session = async_sessionmaker(engine, expire_on_commit=False) + + async with Session() as db: + payload["query_embedding_ms"] = await bench_query_embedding(queries[:20], args.repeats) + print(f"[query-embed] {payload['query_embedding_ms']}") + + payload["retrieval"] = await bench_retrieval( + db, args.user_id, queries, args.top_k, args.repeats + ) + print(f"[retrieval] {payload['retrieval']['latency_ms']}") + + payload["retrieval_concurrent"] = await bench_concurrent_retrieval( + engine, args.user_id, queries, args.top_k, args.concurrency + ) + print(f"[concurrent] {payload['retrieval_concurrent']}") + + if args.llm: + async with Session() as db: + payload["llm"] = await bench_llm( + db, args.user_id, queries[: args.llm_queries], args.top_k + ) + else: + payload["llm"] = {"skipped": "run with --llm to measure generation latency"} + + await engine.dispose() + + payload["process_end"] = proc_sample() + save(args.out, payload) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/evaluation/run_retrieval_eval.py b/backend/evaluation/run_retrieval_eval.py new file mode 100644 index 0000000..2120273 --- /dev/null +++ b/backend/evaluation/run_retrieval_eval.py @@ -0,0 +1,264 @@ +""" +Score the retrieval pipeline against a fixed ground-truth query set. + +Every query goes through app.retrieval.run_retrieval_pipeline -- the same +function POST /retrieve and POST /chat call -- so what is measured is the +deployed path: query embedding, the per-user SQL filter, the pgvector HNSW +search, the similarity threshold, deduplication and ranking. + +Three relevance levels are scored, from loosest to strictest: + + document the retrieved chunk came from the document the query was written from + page ... and from the same page -> page-level citation accuracy + answer the retrieved chunk text contains the answer span the generator + quoted -> answer-bearing chunk accuracy + +Ground truth names exactly one relevant page per query, so Recall@k and +Hit Rate@k are the same quantity here: the share of queries with at least one +relevant chunk in the top k. Both names are reported with that caveat attached +rather than presented as two independent results. + +Usage: + python evaluation/run_retrieval_eval.py --user-id eval-xxxx + python evaluation/run_retrieval_eval.py --user-id eval-xxxx --no-threshold + python evaluation/run_retrieval_eval.py --user-id eval-xxxx --filter-document +""" + +from __future__ import annotations + +import argparse +import asyncio +import math +import re +import time + +from _common import eval_database_url, load, percentiles, save + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.core.config import get_settings +from app.retrieval.exceptions import NoResultsError +from app.retrieval.filters import RetrievalFilter +from app.retrieval.retrieval_pipeline import run_retrieval_pipeline + +K_VALUES = (1, 3, 5, 10) +LEVELS = ("document", "page", "answer") + + +def normalize(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def relevance(chunk, query: dict, level: str) -> bool: + if chunk.document_id != query["gt_document_id"]: + return False + if level == "document": + return True + if chunk.page != query["gt_page"]: + return False + if level == "page": + return True + span = normalize(query.get("answer_span", "")) + return bool(span) and span in normalize(chunk.text) + + +def ndcg_at_k(relevances: list[bool], k: int) -> float: + """Binary-gain NDCG. Ideal ranking puts every relevant hit first.""" + top = relevances[:k] + dcg = sum(1.0 / math.log2(i + 2) for i, rel in enumerate(top) if rel) + ideal_hits = min(sum(top), k) + if ideal_hits == 0: + return 0.0 + idcg = sum(1.0 / math.log2(i + 2) for i in range(ideal_hits)) + return dcg / idcg + + +def score_level(per_query: list[list[bool]]) -> dict: + """Aggregate one relevance level across all queries.""" + n = len(per_query) + if n == 0: + return {} + + out: dict = {"queries": n} + for k in K_VALUES: + hits = sum(1 for rels in per_query if any(rels[:k])) + out[f"recall_at_{k}"] = round(hits / n, 4) + out[f"precision_at_{k}"] = round( + sum(sum(rels[:k]) / k for rels in per_query) / n, 4 + ) + out[f"ndcg_at_{k}"] = round(sum(ndcg_at_k(rels, k) for rels in per_query) / n, 4) + + out["hit_rate_at_5"] = out["recall_at_5"] + out["hit_rate_at_5_note"] = ( + "identical to recall_at_5: ground truth names one relevant page per query" + ) + + reciprocal = [] + for rels in per_query: + rank = next((i + 1 for i, rel in enumerate(rels) if rel), None) + reciprocal.append(1.0 / rank if rank else 0.0) + out["mrr"] = round(sum(reciprocal) / n, 4) + + return out + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--user-id", required=True) + ap.add_argument("--eval-set", default="eval_set.json") + ap.add_argument("--top-k", type=int, default=10) + ap.add_argument( + "--no-threshold", + action="store_true", + help="set the similarity threshold to 0 for this run", + ) + ap.add_argument( + "--filter-document", + action="store_true", + help="pass the ground-truth document_id as a metadata filter", + ) + ap.add_argument("--label", default="") + ap.add_argument("--out", default=None) + args = ap.parse_args() + + settings = get_settings() + original_threshold = settings.retrieval_similarity_threshold + if args.no_threshold: + settings.retrieval_similarity_threshold = 0.0 + # The pipeline caps results at min(top_k, retrieval_max_context_chunks); + # raise the cap so a top_k of 10 is actually returned. + settings.retrieval_max_context_chunks = max( + settings.retrieval_max_context_chunks, args.top_k + ) + + eval_set = load(args.eval_set) + queries = eval_set["queries"] + print(f"[eval-set] {len(queries)} queries from {args.eval_set}") + + engine = create_async_engine(eval_database_url(), pool_pre_ping=True) + Session = async_sessionmaker(engine, expire_on_commit=False) + + rows: list[dict] = [] + latencies: list[float] = [] + per_level: dict[str, list[list[bool]]] = {level: [] for level in LEVELS} + answer_span_scorable = 0 + empty_results = 0 + + async with Session() as db: + for i, query in enumerate(queries, 1): + filters = ( + RetrievalFilter(document_id=query["gt_document_id"]) + if args.filter_document + else None + ) + + t0 = time.perf_counter() + try: + result = await run_retrieval_pipeline( + query=query["query"], + db=db, + user_id=args.user_id, + top_k=args.top_k, + filters=filters, + ) + chunks = result.chunks + except NoResultsError: + chunks = [] + empty_results += 1 + latency_ms = (time.perf_counter() - t0) * 1000 + latencies.append(latency_ms) + + rels = {level: [relevance(c, query, level) for c in chunks] for level in LEVELS} + + # A query can only be scored at answer-span level if the generator + # actually quoted a span that appears in the passage it was given. + span = normalize(query.get("answer_span", "")) + span_scorable = bool(span) and span in normalize(query["gt_passage"]) + if span_scorable: + answer_span_scorable += 1 + + for level in LEVELS: + if level == "answer" and not span_scorable: + continue + per_level[level].append(rels[level]) + + first_rank = { + level: next((j + 1 for j, r in enumerate(rels[level]) if r), None) + for level in LEVELS + } + + rows.append( + { + "query_id": query["query_id"], + "query": query["query"], + "gt_filename": query["gt_filename"], + "gt_page": query["gt_page"], + "answer_span_scorable": span_scorable, + "retrieved": [ + { + "rank": j + 1, + "filename": c.filename, + "page": c.page, + "chunk_index": c.chunk_index, + "similarity": round(c.similarity, 4), + "relevant_document": rels["document"][j], + "relevant_page": rels["page"][j], + "relevant_answer": rels["answer"][j], + } + for j, c in enumerate(chunks) + ], + "first_relevant_rank": first_rank, + "latency_ms": round(latency_ms, 2), + "n_retrieved": len(chunks), + } + ) + + if i % 10 == 0: + print(f"[{i}/{len(queries)}] scored") + + await engine.dispose() + + metrics = {level: score_level(per_level[level]) for level in LEVELS} + metrics["answer"]["scorable_queries"] = answer_span_scorable + metrics["answer"]["note"] = ( + "scored only on queries whose generated answer span is present verbatim " + "in the ground-truth passage" + ) + + payload = { + "run": { + "label": args.label, + "user_id": args.user_id, + "eval_set": args.eval_set, + "eval_set_size": len(queries), + "top_k": args.top_k, + "similarity_threshold": settings.retrieval_similarity_threshold, + "similarity_threshold_default": original_threshold, + "metadata_filter": "document_id" if args.filter_document else "none", + "embedding_model": settings.embedding_model, + "vector_distance": "cosine (pgvector <=>, HNSW index)", + }, + "metrics": metrics, + "queries_with_no_results": empty_results, + "retrieval_latency_ms": percentiles(latencies), + "per_query": rows, + } + + name = args.out or f"retrieval_eval_{args.label or args.user_id}.json" + save(name, payload) + + print("\n=== retrieval metrics ===") + for level in LEVELS: + m = metrics[level] + print( + f"{level:9s} n={m['queries']:3d} " + f"R@1={m['recall_at_1']:.3f} R@3={m['recall_at_3']:.3f} " + f"R@5={m['recall_at_5']:.3f} MRR={m['mrr']:.3f} " + f"NDCG@5={m['ndcg_at_5']:.3f} P@5={m['precision_at_5']:.3f}" + ) + print(f"\nlatency ms: {payload['retrieval_latency_ms']}") + print(f"queries returning nothing: {empty_results}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/evaluation/sets/manual.json b/backend/evaluation/sets/manual.json new file mode 100644 index 0000000..dccf495 --- /dev/null +++ b/backend/evaluation/sets/manual.json @@ -0,0 +1,440 @@ +{ + "status": "DRAFT - not reviewed, not used to produce any reported number.", + "description": "Hand-written evaluation slices covering what the LLM-generated set cannot: questions with no answer in the corpus, questions needing two documents, and two-turn exchanges whose second turn is not a standalone query.", + "corpus": "corpus/manifest.json", + "ground_truth_convention": "Documents are referenced by their manifest `id`, never by database document_id, because the latter is regenerated on every ingest.", + "caveats": [ + "Written from document titles and scope, not by reading every page. Each item needs a human to confirm the answer really is where it is claimed to be, or really is absent.", + "The unanswerable slice was the fragile one and this proved correct: 4 of 15 draft items asked about frameworks the NIST and Federal Reserve documents genuinely discuss. Those were replaced and the slice re-verified; see `verification`.", + "Multi-hop items assert that two documents are needed. A single document may in practice answer some of them, which would make those items easy rather than wrong." + ], + "verification": { + "status": "verified", + "method": "Full text of all 27 corpus PDFs extracted with PyMuPDF and searched for each item's distinguishing terms, case-insensitively and on word boundaries. Word boundaries matter: a substring search reported 903 hits for 'Rust', every one of them inside 'trust'.", + "result": "4 of the original 15 were contaminated and were replaced (see each item's why_absent): PCI DSS, HIPAA, Basel III and NBER are all genuinely discussed in the corpus. The remaining 11 verify absent.", + "not_verified": "The multi_hop and followup slices are NOT mechanically verified. Their ground-truth document assignments come from document scope and still need a human to confirm the answer is on the page claimed." + }, + "slices": { + "unanswerable": { + "description": "The corpus does not contain the answer. Measures whether retrieval abstains or returns confident-looking but irrelevant chunks above the similarity threshold.", + "expected": "no relevant chunk; ideally nothing above threshold", + "items": [ + { + "id": "una-01", + "query": "What is the maximum response time an organisation has under GDPR Article 12 to answer a data subject access request?", + "why_absent": "EU data-protection law; the corpus is U.S. federal guidance and contains no GDPR article text." + }, + { + "id": "una-02", + "query": "Under the EU's Digital Operational Resilience Act, how often must a financial entity carry out threat-led penetration testing?", + "why_absent": "EU financial-sector regulation. Replaced the original PCI DSS question: 'PCI DSS' and 'Payment Card Industry' occur 18 times across SP 800-92, 800-146, 800-209 and 800-218 as a compliance driver, which made it a poor probe for absence." + }, + { + "id": "una-03", + "query": "What is the minimum observation window for a SOC 2 Type II examination?", + "why_absent": "AICPA attestation standard; absent from this corpus." + }, + { + "id": "una-04", + "query": "Which risk tier does the EU AI Act assign to remote biometric identification in publicly accessible spaces?", + "why_absent": "EU legislation; the AI RMF here is a voluntary U.S. framework and predates it." + }, + { + "id": "una-05", + "query": "What are the five technical controls required for UK Cyber Essentials certification?", + "why_absent": "UK NCSC scheme. 'Cyber Essentials' does not occur in the corpus. Note that the acronym NCSC does appear three times, but it refers to the U.S. ODNI National Counterintelligence and Security Center, not the UK National Cyber Security Centre." + }, + { + "id": "una-06", + "query": "What maturity levels are defined for the Australian Essential Eight mitigation strategies?", + "why_absent": "Australian Signals Directorate guidance; absent from this corpus." + }, + { + "id": "una-07", + "query": "What is the maximum fine for a data-protection breach under Brazil's LGPD?", + "why_absent": "Brazilian data-protection law. Replaced the original HIPAA question: 'HIPAA' occurs 33 times across the corpus as a compliance context, notably in IR 8259 and SP 800-207." + }, + { + "id": "una-08", + "query": "What reserve requirement ratio does the People's Bank of China apply to large commercial banks?", + "why_absent": "Chinese monetary policy. Replaced the original Basel III question: 'Basel III' and 'liquidity coverage ratio' both appear in the 2022 and 2023 Federal Reserve annual reports." + }, + { + "id": "una-09", + "query": "Which price index does the Bank of Japan use to measure its 2 percent inflation target?", + "why_absent": "Japanese monetary policy. Replaced the original NBER question: 'National Bureau of Economic Research' appears in both Federal Reserve annual reports, defining the recession shading on their charts." + }, + { + "id": "una-10", + "query": "What is the recommended cold-water steeping time for a first-flush Darjeeling?", + "why_absent": "Entirely outside the corpus domain; a control for obviously irrelevant queries." + }, + { + "id": "una-11", + "query": "How does the Rust borrow checker's lifetime elision algorithm decide when a reference outlives its owner?", + "why_absent": "Programming-language internals; no document here discusses Rust." + }, + { + "id": "una-12", + "query": "What is the maximum transmit power permitted for a Wi-Fi 6E access point in the 6 GHz band under FCC rules?", + "why_absent": "FCC radio regulation; outside the scope of every document here." + }, + { + "id": "una-13", + "query": "How many players are on the field per team during a standard rugby union match?", + "why_absent": "Entirely outside the corpus domain; a second control query." + }, + { + "id": "una-14", + "query": "What is the current statutory corporation tax rate for large companies in the Republic of Ireland?", + "why_absent": "Foreign tax law; absent from the U.S. federal documents in this corpus." + }, + { + "id": "una-15", + "query": "Which antibiotic class is first-line treatment for uncomplicated community-acquired pneumonia in adults?", + "why_absent": "Clinical medicine; entirely outside the corpus domain." + } + ] + }, + "multi_hop": { + "description": "Answering requires combining two documents. Measures whether top-k retrieval surfaces both, rather than only the more lexically similar one.", + "items": [ + { + "id": "mh-01", + "query": "How should an organisation verify third-party components in a container image before deploying it?", + "gt_document_ids": [ + "nist-sp-800-190", + "nist-sp-800-218" + ], + "hop_note": "Image-level guidance is in the container security guide; verifying acquired third-party components is an SSDF practice." + }, + { + "id": "mh-02", + "query": "How does a zero trust policy engine's access decision relate to the authorisation step of the Risk Management Framework?", + "gt_document_ids": [ + "nist-sp-800-207", + "nist-sp-800-37r2" + ], + "hop_note": "Policy engine is defined in the ZTA document; authorise is an RMF step." + }, + { + "id": "mh-03", + "query": "After a ransomware event, what should an organisation do about media that cannot be verified clean?", + "gt_document_ids": [ + "nist-sp-1800-25", + "nist-sp-800-88r1" + ], + "hop_note": "Recovery context from the data-integrity practice guide; sanitisation decisions from the media sanitization guidelines." + }, + { + "id": "mh-04", + "query": "Which cloud service model leaves the customer most responsible for securing the operating system, and what does that imply for patching?", + "gt_document_ids": [ + "nist-sp-800-145", + "nist-sp-800-146" + ], + "hop_note": "Service-model definitions in the cloud definition; security implications in the synopsis." + }, + { + "id": "mh-05", + "query": "Is personally identifiable information also controlled unclassified information, and do the two require different handling?", + "gt_document_ids": [ + "nist-sp-800-122", + "nist-sp-800-171r2" + ], + "hop_note": "PII definition and handling in one document, CUI requirements in the other." + }, + { + "id": "mh-06", + "query": "Which log sources should be retained to support the detection and analysis phase of incident response?", + "gt_document_ids": [ + "nist-sp-800-92", + "nist-sp-800-61r2" + ], + "hop_note": "Log source guidance in the log management guide; incident response phases in the handling guide." + }, + { + "id": "mh-07", + "query": "How do the governance activities in the AI risk framework map onto the Identify function of the Cybersecurity Framework?", + "gt_document_ids": [ + "nist-ai-100-1", + "nist-cswp-cyberframework-1-1" + ], + "hop_note": "Govern is an AI RMF function; Identify is a CSF core function." + }, + { + "id": "mh-08", + "query": "What should a federal agency expect an IoT device manufacturer to have already done before the agency procures the device?", + "gt_document_ids": [ + "nist-ir-8259", + "nist-sp-800-213" + ], + "hop_note": "Manufacturer-side activities in the IR; the federal acquirer's expectations in the SP." + }, + { + "id": "mh-09", + "query": "How do secure software development practices feed into supply chain risk management for acquired software?", + "gt_document_ids": [ + "nist-sp-800-218", + "nist-sp-800-161r1" + ], + "hop_note": "SSDF practices in one, C-SCRM controls in the other." + }, + { + "id": "mh-10", + "query": "What inputs does a risk assessment provide to the categorise and select steps of the Risk Management Framework?", + "gt_document_ids": [ + "nist-sp-800-30r1", + "nist-sp-800-37r2" + ], + "hop_note": "Assessment process in the risk assessment guide; the steps in the RMF document." + }, + { + "id": "mh-11", + "query": "How should backup integrity be validated as part of contingency planning for a destructive-malware event?", + "gt_document_ids": [ + "nist-sp-800-34r1", + "nist-sp-1800-25" + ], + "hop_note": "Contingency planning and backup strategy in one; integrity validation against ransomware in the other." + }, + { + "id": "mh-12", + "query": "What should happen to storage media at end of life in a storage area network environment?", + "gt_document_ids": [ + "nist-sp-800-209", + "nist-sp-800-88r1" + ], + "hop_note": "Storage infrastructure context in one; the sanitisation decision matrix in the other." + }, + { + "id": "mh-13", + "query": "How does technical security testing produce evidence for a control assessment during authorisation?", + "gt_document_ids": [ + "nist-sp-800-115", + "nist-sp-800-37r2" + ], + "hop_note": "Testing techniques in one; assessment and authorisation in the other." + }, + { + "id": "mh-14", + "query": "Which authenticator assurance level is appropriate for resources accessed through a zero trust architecture?", + "gt_document_ids": [ + "nist-sp-800-63-3", + "nist-sp-800-207" + ], + "hop_note": "AAL definitions in the identity guidelines; ZTA access requirements in the other." + }, + { + "id": "mh-15", + "query": "How did the Federal Reserve's reported balance sheet position change between its 2022 and 2023 annual reports?", + "gt_document_ids": [ + "frb-annual-report-2022", + "frb-annual-report-2023" + ], + "hop_note": "Requires reading the same figure from two different annual reports." + } + ] + }, + "followup": { + "description": "Two-turn exchanges where turn 2 is elliptical and meaningless alone. Measures the value of query condensing: turn-2 retrieval with the raw text vs. with a standalone rewrite.", + "items": [ + { + "id": "fu-01", + "turn_1": { + "query": "What is the purpose of a policy enforcement point in a zero trust architecture?", + "gt_document_id": "nist-sp-800-207" + }, + "turn_2": { + "query": "How does it differ from the policy engine?", + "gt_document_id": "nist-sp-800-207", + "depends_on": "'it' refers to the policy enforcement point" + }, + "reference_rewrite": "How does a policy enforcement point differ from a policy engine in a zero trust architecture?" + }, + { + "id": "fu-02", + "turn_1": { + "query": "What are the three cloud service models defined by NIST?", + "gt_document_id": "nist-sp-800-145" + }, + "turn_2": { + "query": "Which of them gives the customer the least control?", + "gt_document_id": "nist-sp-800-145", + "depends_on": "'them' refers to the three cloud service models" + }, + "reference_rewrite": "Which NIST cloud service model gives the customer the least control?" + }, + { + "id": "fu-03", + "turn_1": { + "query": "What are the phases of the incident response life cycle?", + "gt_document_id": "nist-sp-800-61r2" + }, + "turn_2": { + "query": "What happens in the third one?", + "gt_document_id": "nist-sp-800-61r2", + "depends_on": "'the third one' refers to the third incident response phase" + }, + "reference_rewrite": "What happens during the containment, eradication and recovery phase of incident response?" + }, + { + "id": "fu-04", + "turn_1": { + "query": "What is the difference between clearing and purging media?", + "gt_document_id": "nist-sp-800-88r1" + }, + "turn_2": { + "query": "When is destruction required instead?", + "gt_document_id": "nist-sp-800-88r1", + "depends_on": "'instead' contrasts with clearing and purging" + }, + "reference_rewrite": "When is physical destruction of media required instead of clearing or purging?" + }, + { + "id": "fu-05", + "turn_1": { + "query": "What are the core functions of the Cybersecurity Framework?", + "gt_document_id": "nist-cswp-cyberframework-1-1" + }, + "turn_2": { + "query": "Which one covers incident response?", + "gt_document_id": "nist-cswp-cyberframework-1-1", + "depends_on": "'one' refers to a core function" + }, + "reference_rewrite": "Which Cybersecurity Framework core function covers incident response?" + }, + { + "id": "fu-06", + "turn_1": { + "query": "What are the four functions of the AI Risk Management Framework?", + "gt_document_id": "nist-ai-100-1" + }, + "turn_2": { + "query": "What does the first one involve?", + "gt_document_id": "nist-ai-100-1", + "depends_on": "'the first one' refers to the first AI RMF function" + }, + "reference_rewrite": "What does the Govern function of the AI Risk Management Framework involve?" + }, + { + "id": "fu-07", + "turn_1": { + "query": "What is a container image?", + "gt_document_id": "nist-sp-800-190" + }, + "turn_2": { + "query": "What are the main risks associated with them?", + "gt_document_id": "nist-sp-800-190", + "depends_on": "'them' refers to container images" + }, + "reference_rewrite": "What are the main security risks associated with container images?" + }, + { + "id": "fu-08", + "turn_1": { + "query": "What are the steps of the Risk Management Framework?", + "gt_document_id": "nist-sp-800-37r2" + }, + "turn_2": { + "query": "Which step comes immediately after categorisation?", + "gt_document_id": "nist-sp-800-37r2", + "depends_on": "depends on the RMF step sequence given in turn 1" + }, + "reference_rewrite": "Which Risk Management Framework step comes immediately after the categorise step?" + }, + { + "id": "fu-09", + "turn_1": { + "query": "What is controlled unclassified information?", + "gt_document_id": "nist-sp-800-171r2" + }, + "turn_2": { + "query": "How many security requirements protect it?", + "gt_document_id": "nist-sp-800-171r2", + "depends_on": "'it' refers to controlled unclassified information" + }, + "reference_rewrite": "How many security requirements does NIST specify for protecting controlled unclassified information?" + }, + { + "id": "fu-10", + "turn_1": { + "query": "What is the definition of personally identifiable information?", + "gt_document_id": "nist-sp-800-122" + }, + "turn_2": { + "query": "What are the confidentiality impact levels for it?", + "gt_document_id": "nist-sp-800-122", + "depends_on": "'it' refers to personally identifiable information" + }, + "reference_rewrite": "What are the confidentiality impact levels for personally identifiable information?" + }, + { + "id": "fu-11", + "turn_1": { + "query": "What are the practices of the Secure Software Development Framework?", + "gt_document_id": "nist-sp-800-218" + }, + "turn_2": { + "query": "Which group of them deals with the organisation rather than the code?", + "gt_document_id": "nist-sp-800-218", + "depends_on": "'them' refers to the SSDF practice groups" + }, + "reference_rewrite": "Which Secure Software Development Framework practice group deals with preparing the organisation rather than the code?" + }, + { + "id": "fu-12", + "turn_1": { + "query": "What is the purpose of a business impact analysis?", + "gt_document_id": "nist-sp-800-34r1" + }, + "turn_2": { + "query": "What outputs does it produce?", + "gt_document_id": "nist-sp-800-34r1", + "depends_on": "'it' refers to the business impact analysis" + }, + "reference_rewrite": "What outputs does a business impact analysis produce?" + }, + { + "id": "fu-13", + "turn_1": { + "query": "What are the authenticator assurance levels?", + "gt_document_id": "nist-sp-800-63-3" + }, + "turn_2": { + "query": "What is required at the highest one?", + "gt_document_id": "nist-sp-800-63-3", + "depends_on": "'the highest one' refers to the highest AAL" + }, + "reference_rewrite": "What is required at authenticator assurance level 3?" + }, + { + "id": "fu-14", + "turn_1": { + "query": "What is log management?", + "gt_document_id": "nist-sp-800-92" + }, + "turn_2": { + "query": "What are the main challenges in doing it?", + "gt_document_id": "nist-sp-800-92", + "depends_on": "'it' refers to log management" + }, + "reference_rewrite": "What are the main challenges in log management?" + }, + { + "id": "fu-15", + "turn_1": { + "query": "What does the Survey of Consumer Finances measure?", + "gt_document_id": "frb-scf-2023" + }, + "turn_2": { + "query": "How did median family net worth change in the most recent period it covers?", + "gt_document_id": "frb-scf-2023", + "depends_on": "'it' refers to the Survey of Consumer Finances" + }, + "reference_rewrite": "How did median family net worth change between 2019 and 2022 according to the Survey of Consumer Finances?" + } + ] + } + } +} diff --git a/backend/migrations/versions/d5e2c8a7f1b3_fail_documents_with_null_embeddings.py b/backend/migrations/versions/d5e2c8a7f1b3_fail_documents_with_null_embeddings.py new file mode 100644 index 0000000..5a5fefe --- /dev/null +++ b/backend/migrations/versions/d5e2c8a7f1b3_fail_documents_with_null_embeddings.py @@ -0,0 +1,81 @@ +"""mark documents unsearchable when their chunks lost embeddings + +Revision ID: d5e2c8a7f1b3 +Revises: a9f3d6c1b5e8 +Create Date: 2026-09-21 00:00:00.000000 + +Migration a9f3d6c1b5e8 switched the embedding model from bge-base (768 dims) to +bge-small (384 dims). Existing vectors were incompatible, so it ran + + UPDATE document_chunks SET embedding = NULL + +but left `documents.status` alone. Every affected document therefore still reads +`indexed` while none of its chunks can be matched by a vector search: the UI +reports the file as ready, retrieval silently never returns it, and the user sees +an assistant that cannot find something it was told it had ingested. + +a9f3d6c1b5e8 has already run in production, so it is not edited. This migration +corrects the state it left behind, which is the only safe direction. + +Repairing the data is deliberately NOT part of this migration -- re-embedding +requires loading the ONNX model and re-reading the source files, which is not +work a migration should do while the application waits to boot. `status='failed'` +is the honest description of these rows, and backend/scripts/reindex.py rebuilds +them out of band. +""" + +from alembic import op + +revision = "d5e2c8a7f1b3" +down_revision = "a9f3d6c1b5e8" +branch_labels = None +depends_on = None + + +# A document is unsearchable if it claims to be indexed but holds at least one +# chunk with no embedding. Documents with no chunk rows at all are a different +# defect and are intentionally left alone here. +_AFFECTED = """ + id IN ( + SELECT DISTINCT document_id + FROM document_chunks + WHERE embedding IS NULL + ) +""" + + +def upgrade() -> None: + op.execute( + f""" + UPDATE documents + SET status = 'failed' + WHERE status = 'indexed' + AND {_AFFECTED} + """ + ) + + +def downgrade() -> None: + """ + Put the affected documents back to 'indexed'. + + This restores the state a9f3d6c1b5e8 left, which is the state this migration + found -- not a healthy state. It is an honest inverse rather than a good one: + the rows go back to claiming they are indexed while still holding NULL + embeddings. + + The predicate is the same one `upgrade` selected on, so a document that failed + for an unrelated reason (a parse error, say) keeps its status, because its + chunks have embeddings or it has no chunks. The one case this cannot + distinguish is a document that was genuinely 'failed' with NULL-embedding + chunks before this migration ran; such a row is promoted to 'indexed' on + downgrade. Re-running `upgrade` returns it to 'failed'. + """ + op.execute( + f""" + UPDATE documents + SET status = 'indexed' + WHERE status = 'failed' + AND {_AFFECTED} + """ + ) diff --git a/backend/pytest.ini b/backend/pytest.ini index 78c5011..4b7983b 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,3 +1,7 @@ [pytest] asyncio_mode = auto testpaths = tests +markers = + integration: runs against a real PostgreSQL database with pgvector. Requires + TEST_DATABASE_URL; skipped automatically when it is unset. Select with + `-m integration`, exclude with `-m "not integration"`. diff --git a/backend/requirements.txt b/backend/requirements.txt index db1067f..c6762d1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -47,3 +47,4 @@ google-generativeai==0.8.3 # --- Testing --- pytest==9.1.1 pytest-asyncio==1.4.0 +pytest-cov==6.0.0 diff --git a/backend/scripts/reindex.py b/backend/scripts/reindex.py new file mode 100644 index 0000000..c9d0737 --- /dev/null +++ b/backend/scripts/reindex.py @@ -0,0 +1,171 @@ +""" +Re-run ingestion for documents whose chunks are no longer searchable. + +Migration a9f3d6c1b5e8 cleared every embedding when the model changed dimensions, +and d5e2c8a7f1b3 marks the documents it stranded as `failed`. This script is the +repair step: it rebuilds those documents' chunks and embeddings from the files +still on disk. + + # one document + python -m scripts.reindex --document-id 6da1be77-be1f-4d76-9b7d-1a4d19218b9c + + # every failed document whose source file still exists + python -m scripts.reindex --all-failed + + # see what would happen and touch nothing + python -m scripts.reindex --all-failed --dry-run + +Run it from the `backend/` directory with the same environment the API uses, so +that DATABASE_URL and the embedding model directory resolve identically. + +Documents are processed one at a time on purpose. Embedding is CPU-bound and the +Railway instance that runs this is the same one serving traffic; a parallel +rebuild is how the earlier OOM crash happened. +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import configure_logging +from app.database.models import Document, DocumentChunk, ProcessingStatus +from app.database.session import AsyncSessionLocal +from app.ingestion.pipeline import run_ingestion_pipeline + +logger = logging.getLogger("reindex") + + +async def _select_documents( + db: AsyncSession, document_id: str | None, all_failed: bool +) -> list[Document]: + if document_id: + doc = await db.scalar(select(Document).where(Document.id == document_id)) + return [doc] if doc else [] + + if all_failed: + result = await db.execute( + select(Document) + .where(Document.status == ProcessingStatus.failed) + .order_by(Document.upload_time) + ) + return list(result.scalars().all()) + + return [] + + +async def _reindex_one(db: AsyncSession, doc: Document, dry_run: bool) -> bool: + """Rebuild one document's chunks. Returns True when it ends up indexed.""" + source = Path(doc.storage_path) + if not source.is_file(): + # The row survives a lost file, but nothing can be rebuilt from it. Leaving + # the status at `failed` is accurate; the operator has to re-upload. + logger.warning( + "SKIP %s %r — file is gone (%s)", doc.id, doc.original_filename, doc.storage_path + ) + return False + + if dry_run: + logger.info( + "WOULD REINDEX %s %r status=%s chunks=%d", + doc.id, doc.original_filename, doc.status.value, doc.total_chunks, + ) + return False + + # store_chunks appends rather than replacing, so stale rows -- including the + # NULL-embedding ones this script exists to clear -- must go first or the + # document ends up with two generations of chunks and doubled retrieval hits. + deleted = await db.execute( + delete(DocumentChunk).where(DocumentChunk.document_id == doc.id) + ) + doc.total_chunks = 0 + await db.commit() + logger.info( + "REINDEX %s %r — cleared %d stale chunk(s)", + doc.id, doc.original_filename, deleted.rowcount or 0, + ) + + # The pipeline owns its own status transitions and swallows exceptions, so the + # outcome is read back from the row rather than caught here. + await run_ingestion_pipeline( + document_id=doc.id, + storage_path=doc.storage_path, + filename=doc.original_filename, + db=db, + ) + + await db.refresh(doc) + if doc.status == ProcessingStatus.indexed: + logger.info("OK %s %r — %d chunk(s)", doc.id, doc.original_filename, doc.total_chunks) + return True + + logger.error( + "FAILED %s %r — status=%s after reindex; see the pipeline log above", + doc.id, doc.original_filename, doc.status.value, + ) + return False + + +async def reindex(document_id: str | None, all_failed: bool, dry_run: bool) -> int: + async with AsyncSessionLocal() as db: + documents = await _select_documents(db, document_id, all_failed) + + if not documents: + if document_id: + logger.error("No document with id %s", document_id) + return 1 + logger.info("Nothing to do — no documents with status='failed'.") + return 0 + + logger.info( + "%s %d document(s)%s", + "Would reindex" if dry_run else "Reindexing", + len(documents), + " (dry run)" if dry_run else "", + ) + + succeeded = 0 + for doc in documents: + if await _reindex_one(db, doc, dry_run): + succeeded += 1 + + if dry_run: + return 0 + + failed = len(documents) - succeeded + logger.info("Done — %d succeeded, %d still failed.", succeeded, failed) + # A non-zero exit lets this be chained in a deploy script without the + # caller having to parse the log. + return 1 if failed else 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Rebuild chunks and embeddings for documents that are not searchable." + ) + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--document-id", help="Reindex exactly this document.") + target.add_argument( + "--all-failed", + action="store_true", + help="Reindex every document with status='failed' whose file still exists.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="List what would be reindexed without modifying anything.", + ) + args = parser.parse_args() + + configure_logging() + return asyncio.run(reindex(args.document_id, args.all_failed, args.dry_run)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..56c086b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,227 @@ +""" +Shared pytest fixtures. + +The bulk of this suite runs against mocks and needs no services. The fixtures below +add a second tier: tests marked `@pytest.mark.integration` run against a real +PostgreSQL database with the pgvector extension, so that SQL, constraints, cascades +and the ownership filters in the query layer are actually exercised. + +Integration tests are opt-in via TEST_DATABASE_URL. When it is unset every +integration test is skipped, so `pytest -q` on a laptop with no database behaves +exactly as it did before. CI sets it to a pgvector/pgvector:pg16 service container. + + # local + docker run -d --name docuquery-test-db -p 5433:5432 \ + -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=docuquery_test \ + pgvector/pgvector:pg16 + export TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/docuquery_test + pytest -q -m integration +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +BACKEND_DIR = Path(__file__).resolve().parent.parent + +# Tables emptied between integration tests. Ordering is irrelevant because the +# TRUNCATE is a single statement with CASCADE, but document_chunks is listed first +# to make the dependency direction obvious to a reader. +_TRUNCATE_TABLES = ("document_chunks", "documents", "users") + +# Alembic's env.py reads the app Settings, which refuse to build without AUTH_SECRET +# whenever DEBUG is false. The migration subprocess therefore needs one of its own; +# it never signs a cookie that leaves the test process. +_SUBPROCESS_AUTH_SECRET = "integration-test-secret-not-used-for-any-real-session" + + +def integration_database_url() -> str | None: + """The database integration tests run against, or None when they are disabled.""" + return os.environ.get("TEST_DATABASE_URL") or None + + +def run_alembic(*args: str, database_url: str) -> subprocess.CompletedProcess[str]: + """ + Invoke the Alembic CLI against `database_url` in a subprocess. + + A subprocess rather than an in-process `alembic.command` call for two reasons: + migrations/env.py calls `asyncio.run()`, which raises when it is reached from + inside pytest-asyncio's already-running event loop; and it resolves the URL from + a `functools.lru_cache`d Settings object, so pointing it at the test database + in-process would leak that override into every later test in the session. + """ + env = { + **os.environ, + "DATABASE_URL": database_url, + "AUTH_SECRET": os.environ.get("AUTH_SECRET") or _SUBPROCESS_AUTH_SECRET, + } + return subprocess.run( + [sys.executable, "-m", "alembic", *args], + cwd=BACKEND_DIR, + env=env, + check=True, + capture_output=True, + text=True, + ) + + +@pytest.fixture(scope="session") +def database_url() -> str: + """Skip the whole integration tier when no test database is configured.""" + url = integration_database_url() + if not url: + pytest.skip("TEST_DATABASE_URL is not set — integration tests are disabled") + return url + + +@pytest.fixture(scope="session") +def migrated_database(database_url: str) -> str: + """ + Bring the test database to `head` once per session. + + Downgrading to base first makes the fixture idempotent against a database left + behind by a previous run, so a developer does not have to drop the container to + get a clean session. + """ + run_alembic("downgrade", "base", database_url=database_url) + run_alembic("upgrade", "head", database_url=database_url) + return database_url + + +@pytest_asyncio.fixture +async def db_session(migrated_database: str) -> AsyncGenerator[AsyncSession, None]: + """A session against the migrated test database, with the tables emptied first.""" + # NullPool: each test gets its own connections and disposes of them, rather than + # leaving pooled ones open against a container that the next test truncates. + engine = create_async_engine(migrated_database, poolclass=NullPool) + sessionmaker = async_sessionmaker(engine, expire_on_commit=False) + async with engine.begin() as conn: + await conn.execute(text(f"TRUNCATE {', '.join(_TRUNCATE_TABLES)} RESTART IDENTITY CASCADE")) + try: + async with sessionmaker() as session: + yield session + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """ + An HTTP client bound to the real test database. + + `get_db` is overridden to hand every request the one session the test also holds, + so a row the test writes is visible to the request and vice versa. + """ + from app.api.dependencies import get_db + from app.main import app + + async def _override_get_db() -> AsyncGenerator[AsyncSession, None]: + yield db_session + + app.dependency_overrides[get_db] = _override_get_db + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as http_client: + yield http_client + finally: + app.dependency_overrides.pop(get_db, None) + + +@dataclass +class SeededUser: + """A user that exists in the test database, plus the cookie that authenticates it.""" + + id: str + name: str + email: str + password: str + cookies: dict[str, str] + + +@pytest_asyncio.fixture +async def make_user(db_session: AsyncSession): + """ + Factory for users that exist in the database and can make authenticated requests. + + The session cookie is minted directly with `create_session` rather than by + posting to /auth/login, so that a test about document ownership fails for + ownership reasons and not because the login route changed. + """ + from app.core.config import get_settings + from app.core.security import create_session, hash_password + from app.database.models import User + + default_password = "correct horse battery staple" + + async def _make(email: str, name: str = "Test User", password: str = default_password) -> SeededUser: + user = User(name=name, email=email, password_hash=hash_password(password)) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return SeededUser( + id=user.id, + name=user.name, + email=user.email, + password=password, + cookies={get_settings().auth_cookie_name: create_session(user.id)}, + ) + + return _make + + +@pytest_asyncio.fixture +async def make_document(db_session: AsyncSession): + """ + Factory for an indexed document with one embedded chunk, owned by `user_id`. + + The embedding is a fixed unit vector rather than a real one. These tests assert + on which rows a query is allowed to reach, not on ranking quality, and a constant + vector keeps the result order deterministic. + """ + from app.database.models import EMBEDDING_DIM, Document, DocumentChunk, ProcessingStatus + + async def _make( + user_id: str, + filename: str = "owned.pdf", + text: str = "The quarterly revenue figure was 4.2 million dollars.", + ) -> Document: + doc = Document( + user_id=user_id, + filename=filename, + original_filename=filename, + file_type="pdf", + file_size=1024, + storage_path=f"uploads/{filename}", + status=ProcessingStatus.indexed, + total_chunks=1, + ) + db_session.add(doc) + await db_session.flush() + db_session.add( + DocumentChunk( + document_id=doc.id, + chunk_index=0, + page_number=1, + text=text, + embedding=[0.1] * EMBEDDING_DIM, + metadata_={}, + ) + ) + await db_session.commit() + await db_session.refresh(doc) + return doc + + return _make diff --git a/backend/tests/test_integration_auth.py b/backend/tests/test_integration_auth.py new file mode 100644 index 0000000..5f450bb --- /dev/null +++ b/backend/tests/test_integration_auth.py @@ -0,0 +1,137 @@ +""" +The /auth/* routes, against a real database. + +These paths depend on things a mock cannot have: the UNIQUE constraint on +users.email that turns a duplicate signup into a 409, and the round trip through +a real row for /auth/me. test_security.py already covers the hashing and session +primitives in isolation; this covers the routes that use them. +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +PASSWORD = "correct horse battery staple" + + +async def test_signup_creates_an_account_and_sets_a_session_cookie(client): + response = await client.post( + "/auth/signup", + json={"name": "Ada Lovelace", "email": "ada@example.com", "password": PASSWORD}, + ) + + assert response.status_code == 201 + body = response.json() + assert body["email"] == "ada@example.com" + assert body["name"] == "Ada Lovelace" + assert "password" not in body and "password_hash" not in body + + cookie = response.cookies.get("docuquery_session") + assert cookie, "signup must establish a session" + + +async def test_signup_normalizes_the_email_and_rejects_a_duplicate(client): + first = await client.post( + "/auth/signup", + json={"name": "Ada", "email": " Ada@Example.com ", "password": PASSWORD}, + ) + assert first.status_code == 201 + assert first.json()["email"] == "ada@example.com", "email should be trimmed and lowercased" + + # Same address in a different case must collide, which only the real UNIQUE + # constraint can demonstrate. + duplicate = await client.post( + "/auth/signup", + json={"name": "Imposter", "email": "ADA@EXAMPLE.COM", "password": PASSWORD}, + ) + assert duplicate.status_code == 409 + + +async def test_login_succeeds_with_the_right_password(client, make_user): + user = await make_user("ada@example.com", name="Ada", password=PASSWORD) + + response = await client.post( + "/auth/login", json={"email": user.email, "password": PASSWORD} + ) + + assert response.status_code == 200 + assert response.json()["id"] == user.id + assert response.cookies.get("docuquery_session") + + +async def test_login_with_the_wrong_password_is_401_and_sets_no_cookie(client, make_user): + user = await make_user("ada@example.com", password=PASSWORD) + + response = await client.post( + "/auth/login", json={"email": user.email, "password": "not-the-password"} + ) + + assert response.status_code == 401 + assert not response.cookies.get("docuquery_session") + + +async def test_login_for_an_unknown_email_is_401_with_the_same_message(client): + """A different message here would let an attacker enumerate registered emails.""" + unknown = await client.post( + "/auth/login", json={"email": "nobody@example.com", "password": PASSWORD} + ) + assert unknown.status_code == 401 + + known = await client.post( + "/auth/login", json={"email": "nobody@example.com", "password": "another-wrong-one"} + ) + assert known.json()["detail"] == unknown.json()["detail"] + + +async def test_me_returns_the_authenticated_user(client, make_user): + user = await make_user("ada@example.com", name="Ada Lovelace") + + response = await client.get("/auth/me", cookies=user.cookies) + + assert response.status_code == 200 + assert response.json()["id"] == user.id + assert response.json()["email"] == "ada@example.com" + + +async def test_me_without_a_cookie_is_401(client): + response = await client.get("/auth/me") + assert response.status_code == 401 + + +async def test_me_with_a_tampered_cookie_is_401(client, make_user): + user = await make_user("ada@example.com") + tampered = {"docuquery_session": user.cookies["docuquery_session"] + "x"} + + response = await client.get("/auth/me", cookies=tampered) + + assert response.status_code == 401 + + +async def test_me_is_401_after_the_user_row_is_deleted(client, db_session, make_user): + """ + A signature that still verifies is not enough; the user must still exist. + Only a real database can delete the row out from under a valid cookie. + """ + from app.database.models import User + + user = await make_user("ada@example.com") + assert (await client.get("/auth/me", cookies=user.cookies)).status_code == 200 + + await db_session.execute(User.__table__.delete().where(User.id == user.id)) + await db_session.commit() + + assert (await client.get("/auth/me", cookies=user.cookies)).status_code == 401 + + +async def test_logout_clears_the_session_cookie(client, make_user): + user = await make_user("ada@example.com") + + response = await client.post("/auth/logout", cookies=user.cookies) + + assert response.status_code == 204 + # Starlette clears a cookie by re-setting it empty with an expiry in the past. + set_cookie = response.headers.get("set-cookie", "") + assert "docuquery_session=" in set_cookie + assert 'docuquery_session=""' in set_cookie or "Max-Age=0" in set_cookie or "expires=" in set_cookie.lower() diff --git a/backend/tests/test_integration_migrations.py b/backend/tests/test_integration_migrations.py new file mode 100644 index 0000000..55e80e7 --- /dev/null +++ b/backend/tests/test_integration_migrations.py @@ -0,0 +1,167 @@ +""" +Migration d5e2c8a7f1b3, executed against a real database. + +A migration that is only ever read is not known to work. This drives the actual +Alembic CLI over real rows: it seeds the exact state a9f3d6c1b5e8 left behind -- +a document marked `indexed` whose chunk has no embedding -- then runs +upgrade -> downgrade -> upgrade and asserts the status at each step. + +The round trip matters as much as the upgrade. railway.json runs +`alembic upgrade head` on every boot, so a migration that cannot be reversed +leaves no way back except a database restore. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select, text + +from app.database.models import Document, DocumentChunk, ProcessingStatus + +from .conftest import run_alembic + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +# The revision immediately before the one under test. +PREVIOUS_REVISION = "a9f3d6c1b5e8" +TARGET_REVISION = "d5e2c8a7f1b3" + + +async def _seed_stranded_document(db_session, *, filename: str, embedding) -> str: + """A document claiming to be indexed, with one chunk whose embedding is `embedding`.""" + doc = Document( + filename=filename, + original_filename=filename, + file_type="pdf", + file_size=2048, + storage_path=f"uploads/{filename}", + status=ProcessingStatus.indexed, + total_chunks=1, + ) + db_session.add(doc) + await db_session.flush() + db_session.add( + DocumentChunk( + document_id=doc.id, + chunk_index=0, + page_number=1, + text="Revenue for the period was 4.2 million dollars.", + embedding=embedding, + metadata_={}, + ) + ) + await db_session.commit() + return doc.id + + +async def _status_of(db_session, doc_id: str) -> str: + # Read through raw SQL: the migration changes the row underneath the session, + # and the ORM identity map would otherwise hand back the stale object. + return await db_session.scalar( + text("SELECT status::text FROM documents WHERE id = :id"), {"id": doc_id} + ) + + +async def test_upgrade_downgrade_upgrade_round_trip(db_session, migrated_database): + from app.database.models import EMBEDDING_DIM + + stranded = await _seed_stranded_document(db_session, filename="stranded.pdf", embedding=None) + healthy = await _seed_stranded_document( + db_session, filename="healthy.pdf", embedding=[0.1] * EMBEDDING_DIM + ) + + try: + # The database is already at head, so step back over the migration first to + # observe the state it is meant to correct. + run_alembic("downgrade", PREVIOUS_REVISION, database_url=migrated_database) + assert await _status_of(db_session, stranded) == "indexed" + + run_alembic("upgrade", TARGET_REVISION, database_url=migrated_database) + assert await _status_of(db_session, stranded) == "failed", ( + "a document whose chunks lost their embeddings must stop claiming to be indexed" + ) + assert await _status_of(db_session, healthy) == "indexed", ( + "a document with real embeddings must be left alone" + ) + + run_alembic("downgrade", PREVIOUS_REVISION, database_url=migrated_database) + assert await _status_of(db_session, stranded) == "indexed", ( + "downgrade must restore the previous status" + ) + assert await _status_of(db_session, healthy) == "indexed" + + run_alembic("upgrade", TARGET_REVISION, database_url=migrated_database) + assert await _status_of(db_session, stranded) == "failed", ( + "re-applying must be deterministic, not accumulate state" + ) + finally: + # Whatever happened above, leave the database at head for the other tests. + run_alembic("upgrade", "head", database_url=migrated_database) + + +async def test_upgrade_leaves_unrelated_failures_alone(db_session, migrated_database): + """ + A document that failed to parse has no chunks at all. The migration selects on + NULL-embedding chunk rows, so it must not touch one, in either direction. + """ + doc = Document( + filename="unparseable.pdf", + original_filename="unparseable.pdf", + file_type="pdf", + file_size=10, + storage_path="uploads/unparseable.pdf", + status=ProcessingStatus.failed, + total_chunks=0, + ) + db_session.add(doc) + await db_session.commit() + + try: + run_alembic("downgrade", PREVIOUS_REVISION, database_url=migrated_database) + assert await _status_of(db_session, doc.id) == "failed", ( + "downgrade must not promote a genuinely failed document to indexed" + ) + + run_alembic("upgrade", TARGET_REVISION, database_url=migrated_database) + assert await _status_of(db_session, doc.id) == "failed" + finally: + run_alembic("upgrade", "head", database_url=migrated_database) + + +async def test_every_migration_reverses_all_the_way_to_base(migrated_database): + """ + `downgrade base` then `upgrade head` over the whole chain. + + This is the check that catches a migration whose downgrade was never written or + never run -- the cheapest possible guard on a project where every deploy runs + `alembic upgrade head` automatically. + """ + try: + run_alembic("downgrade", "base", database_url=migrated_database) + run_alembic("upgrade", "head", database_url=migrated_database) + finally: + run_alembic("upgrade", "head", database_url=migrated_database) + + +async def test_chunk_rows_survive_the_round_trip(db_session, migrated_database): + """The migration only rewrites `status`; it must not drop or alter chunk data.""" + from app.database.models import EMBEDDING_DIM + + doc_id = await _seed_stranded_document( + db_session, filename="preserved.pdf", embedding=[0.2] * EMBEDDING_DIM + ) + + try: + run_alembic("downgrade", PREVIOUS_REVISION, database_url=migrated_database) + run_alembic("upgrade", TARGET_REVISION, database_url=migrated_database) + + chunks = ( + await db_session.execute( + select(DocumentChunk).where(DocumentChunk.document_id == doc_id) + ) + ).scalars().all() + assert len(chunks) == 1 + assert chunks[0].text.startswith("Revenue for the period") + assert chunks[0].embedding is not None + finally: + run_alembic("upgrade", "head", database_url=migrated_database) diff --git a/backend/tests/test_integration_tenant_isolation.py b/backend/tests/test_integration_tenant_isolation.py new file mode 100644 index 0000000..1637bc3 --- /dev/null +++ b/backend/tests/test_integration_tenant_isolation.py @@ -0,0 +1,178 @@ +""" +Tenant isolation, against a real database. + +Every document route and /retrieve scopes its query with `Document.user_id == +current_user.id`. Until now nothing executed that SQL: the rest of the suite hands +the routes an AsyncMock, which returns whatever the test told it to return, so a +query that had dropped its ownership filter would pass exactly as happily as one +that had not. + +These tests seed two real users, give user A a real document with a real chunk, +and assert that user B cannot reach it through any route that takes a document id +-- and that a cross-tenant read is a 404, never a 403. 403 would confirm the +document exists, which is itself a disclosure: it turns an opaque id into an +oracle for "is this a real document belonging to someone else". +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from app.database.models import EMBEDDING_DIM + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +@pytest.fixture +def stub_query_embedding(): + """ + Replace query embedding with a constant vector. + + /retrieve would otherwise load the ONNX model, which takes seconds and tests + nothing here. The vector search, its ownership filter and the SQL around it all + still run for real -- only the encoder is stubbed. + """ + with patch( + "app.retrieval.retrieval_pipeline.embed_query", + return_value=[0.1] * EMBEDDING_DIM, + ) as stub: + yield stub + + +async def test_document_list_shows_only_the_callers_documents(client, make_user, make_document): + alice = await make_user("alice@example.com") + bob = await make_user("bob@example.com") + await make_document(alice.id, filename="alice-report.pdf") + await make_document(bob.id, filename="bob-notes.pdf") + + response = await client.get("/documents", cookies=bob.cookies) + + assert response.status_code == 200 + filenames = [doc["filename"] for doc in response.json()] + assert filenames == ["bob-notes.pdf"] + + +async def test_reading_another_users_document_is_404(client, make_user, make_document): + alice = await make_user("alice@example.com") + bob = await make_user("bob@example.com") + doc = await make_document(alice.id) + + response = await client.get(f"/documents/{doc.id}", cookies=bob.cookies) + + assert response.status_code == 404, "a cross-tenant read must not confirm the document exists" + + +async def test_reading_another_users_chunks_is_404(client, make_user, make_document): + alice = await make_user("alice@example.com") + bob = await make_user("bob@example.com") + doc = await make_document(alice.id) + + response = await client.get(f"/documents/{doc.id}/chunks", cookies=bob.cookies) + + assert response.status_code == 404 + + +async def test_reading_another_users_debug_diagnostics_is_404(client, make_user, make_document): + alice = await make_user("alice@example.com") + bob = await make_user("bob@example.com") + doc = await make_document(alice.id) + + response = await client.get(f"/documents/{doc.id}/debug", cookies=bob.cookies) + + assert response.status_code == 404 + + +async def test_deleting_another_users_document_is_404_and_leaves_it_intact( + client, make_user, make_document +): + alice = await make_user("alice@example.com") + bob = await make_user("bob@example.com") + doc = await make_document(alice.id) + + response = await client.delete(f"/documents/{doc.id}", cookies=bob.cookies) + assert response.status_code == 404 + + # The 404 must mean "nothing happened", not "deleted, then reported missing". + still_there = await client.get(f"/documents/{doc.id}", cookies=alice.cookies) + assert still_there.status_code == 200 + + +async def test_retrieval_never_returns_another_users_chunks( + client, make_user, make_document, stub_query_embedding +): + alice = await make_user("alice@example.com") + bob = await make_user("bob@example.com") + await make_document( + alice.id, + filename="alice-secret.pdf", + text="The acquisition price agreed in private was 900 million dollars.", + ) + + response = await client.post( + "/retrieve", json={"query": "acquisition price"}, cookies=bob.cookies + ) + + # Bob owns nothing, so retrieval finds nothing. Either a 404 from the + # no-results path or an empty 200 is acceptable; a chunk of Alice's is not. + assert response.status_code in (200, 404) + if response.status_code == 200: + body = response.json() + assert body["chunks"] == [] + assert "900 million" not in body.get("context", "") + + +async def test_retrieval_returns_the_callers_own_chunks( + client, make_user, make_document, stub_query_embedding +): + """ + The counterpart to the test above. + + Without this, every isolation assertion would still pass if retrieval were + broken outright and returned nothing to anybody. + """ + alice = await make_user("alice@example.com") + await make_document( + alice.id, + filename="alice-report.pdf", + text="The acquisition price agreed in private was 900 million dollars.", + ) + + response = await client.post( + "/retrieve", json={"query": "acquisition price"}, cookies=alice.cookies + ) + + assert response.status_code == 200 + body = response.json() + assert body["chunks"], "the owner must still be able to retrieve their own chunk" + assert any("900 million" in chunk["text"] for chunk in body["chunks"]) + + +async def test_deleting_a_user_cascades_to_documents_and_chunks( + client, db_session, make_user, make_document +): + """ + The FK cascades are declared in the ORM and the migration. Nothing had ever + executed them, so this asserts the database really drops the dependent rows + rather than raising a foreign-key violation or orphaning chunks. + """ + from sqlalchemy import func, select + + from app.database.models import Document, DocumentChunk, User + + alice = await make_user("alice@example.com") + await make_document(alice.id) + + await db_session.execute( + User.__table__.delete().where(User.id == alice.id) + ) + await db_session.commit() + + remaining_docs = await db_session.scalar( + select(func.count()).select_from(Document).where(Document.user_id == alice.id) + ) + remaining_chunks = await db_session.scalar(select(func.count()).select_from(DocumentChunk)) + + assert remaining_docs == 0 + assert remaining_chunks == 0 diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py index bc61900..2c2d3e2 100644 --- a/backend/tests/test_security.py +++ b/backend/tests/test_security.py @@ -1,3 +1,6 @@ +import pytest + +from app.core.config import DEV_AUTH_SECRET, Settings from app.core.security import create_session, hash_password, read_session, verify_password @@ -12,3 +15,19 @@ def test_session_round_trip_and_tamper_rejection(): token = create_session("user-123") assert read_session(token) == "user-123" assert read_session(token + "tampered") is None + + +@pytest.mark.parametrize("secret", [DEV_AUTH_SECRET, "", " "]) +def test_production_settings_reject_a_guessable_auth_secret(secret): + """A non-debug build must not start with a session key anyone could know.""" + with pytest.raises(ValueError, match="AUTH_SECRET"): + Settings(debug=False, auth_secret=secret) + + +def test_production_settings_accept_a_real_auth_secret(): + assert Settings(debug=False, auth_secret="s3kr1t-from-the-deploy-env").auth_secret + + +def test_debug_settings_still_work_without_an_auth_secret(): + """Local development should need no setup, so debug builds keep the default.""" + assert Settings(debug=True, auth_secret=DEV_AUTH_SECRET).auth_secret == DEV_AUTH_SECRET diff --git a/frontend/components/landing/useDemoLoop.ts b/frontend/components/landing/useDemoLoop.ts index 0e273c3..db1248b 100644 --- a/frontend/components/landing/useDemoLoop.ts +++ b/frontend/components/landing/useDemoLoop.ts @@ -21,14 +21,17 @@ export function useDemoLoop(steps: number, { intervalMs = 1500, holdMs = 3200, a /** Reveals `text` one character at a time while `active`; shows it in full otherwise. */ export function useTypewriter(text: string, active: boolean, speedMs = 26) { - const [count, setCount] = useState(text.length) + const [count, setCount] = useState(active ? 0 : text.length) + // Rewinding belongs in render, not in an effect: an effect would let the browser + // paint the previous line's length for a frame before snapping back to zero. + const [inputs, setInputs] = useState({ text, active }) + if (inputs.text !== text || inputs.active !== active) { + setInputs({ text, active }) + setCount(active ? 0 : text.length) + } useEffect(() => { - if (!active) { - setCount(text.length) - return - } - setCount(0) + if (!active) return let typed = 0 const id = setInterval(() => { typed += 1