Skip to content

chore(phase-0): integration tier, embedding-status fix, evaluation corpus - #10

Draft
VampiricCyborg wants to merge 14 commits into
mainfrom
chore/phase-0-baseline
Draft

VampiricCyborg wants to merge 14 commits into
mainfrom
chore/phase-0-baseline

Conversation

@VampiricCyborg

Copy link
Copy Markdown
Owner

Phase 0 — baseline: integration tier, migration fix, evaluation corpus

Groundwork that phase 8 builds on. Fourteen commits: an integration test tier against
real PostgreSQL, the d5e2c8a7f1b3 follow-up migration, a pinned 27-document
evaluation corpus with hand-written slices, the root CLAUDE.md, and the AUTH_SECRET
startup guard.

Do not merge yet

This branch carries the AUTH_SECRET guard as 838b75b, a cherry-pick of a1cd80e
with a byte-identical diff to backend/app/core/config.py. main does not have it.

I checked the live Railway service (DocuQuery-, environment production) rather
than the committed config:

AUTH_SECRET not set
DEBUG false

railway.json runs alembic upgrade head && uvicorn ... on every boot, so merging
this is the deploy. With DEBUG=false and no AUTH_SECRET, Settings() raises
in _reject_insecure_auth_secret before create_app() wires a single route: the API
would fail to boot and the healthcheck would fail three restarts.

Set AUTH_SECRET in Railway first, then merge. Note that doing so invalidates
every session cookie currently outstanding — everyone signed in gets logged out once.

python -c "import secrets; print(secrets.token_urlsafe(48))"

Separately, and more urgent than this PR

DEBUG=false with AUTH_SECRET unset means production is already signing session
cookies with DEV_AUTH_SECRET = "dev-only-change-me" — a constant in a public
repository. That is the condition the guard was written to prevent, and it is live
right now on main, which does not have the guard. Anyone who reads the repo can mint
a valid session cookie for any user id.

The guard in this PR is the fix, but setting the variable is what actually closes it,
and that does not need to wait for this merge.

What is in here

  • Integration tier (tests/conftest.py, three test modules): opt-in via
    TEST_DATABASE_URL, skipped entirely when unset. Exercises tenant isolation, the
    auth routes and the migration chain against real SQL rather than mocks.
  • d5e2c8a7f1b3: a9f3d6c1b5e8 cleared every embedding without resetting
    documents.status, leaving rows marked indexed whose chunks cannot be matched.
    This marks them failed; scripts/reindex.py rebuilds them out of band. The
    original migration is not edited, because it has already run.
  • Evaluation corpus: 27 public documents pinned by manifest with sha256, plus
    hand-written unanswerable / multi-hop / follow-up slices. The unanswerable slice is
    mechanically verified against the corpus text; the other two are explicitly marked
    as not verified.
  • Root CLAUDE.md: the standing rules for this repository.

Tests

pytest -q passes with the integration tier enabled against pgvector/pgvector:pg16.

VampiricCyborg and others added 14 commits September 20, 2026 13:31
The audit still listed sentence-transformers as the embedding dependency. That
stack was removed on purpose -- loading PyTorch exhausted memory on Railway's
container and crashed the service on boot -- and replaced with onnxruntime plus
tokenizers against a quantised INT8 model. Anyone reading the old table would
have reinstated the exact dependency that caused the outage, so the rewrite says
plainly that it must not come back and why.

Four packages the code does import were missing entirely (onnxruntime,
tokenizers, numpy, psutil), and the counts underneath them were wrong. The seven
packages that are genuinely required but never imported by name -- drivers, the
server, plugins -- now have their own table explaining why grepping for imports
will not find them, instead of being scattered through footnotes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setting state in an effect body trips react-hooks/set-state-in-effect, which
makes `npm run lint` exit 1. Adjusting the count during render instead also
removes the frame where the browser paints the previous line's length before
snapping back to zero.

Same change as cc718d0 on fix/auth-secret-guard-ci, applied byte-for-byte so
the two branches merge without conflict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README carried a hardcoded shields.io "build passing" badge that was never
connected to anything and would have stayed green through any failure. Replace
it with the GitHub Actions badge for a workflow that actually runs the suite:
pytest on Python 3.13, then tsc, lint and build for the frontend.

The workflow is a byte-identical copy of the one on fix/auth-secret-guard-ci so
merging either branch is conflict-free. All four steps verified locally: 79
tests passed, tsc 0, lint 0, build 0.

Also put the live URLs at the top, replacing a "Live demo" link that pointed at
an in-page anchor showing a GIF rather than the running app. Both tiers were
probed before linking them: docuqueryvc.vercel.app returns 200, and
docuqueryprod.up.railway.app/health returns 200 with database: ok.

The badge stays unresolved until this workflow reaches the default branch --
GitHub reads Actions badges from the default branch, not the current one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arness

The project had no way to measure itself: no evaluation script, no benchmark,
no ground-truth data, and no document corpus. Nothing quantitative about
retrieval quality could be claimed or checked.

This adds a harness that drives the real application code -- the same parser,
chunker, embedding service and retrieval pipeline the API uses -- rather than
reimplementing any of it for measurement:

  ingest_corpus.py      per-stage ingestion timings, one synthetic user per
                        chunking config so the production per-user filter does
                        the isolation between configs
  build_eval_set.py     ground-truth queries generated from indexed passages,
                        anchored to (document, page) so one query set stays
                        valid across chunk sizes
  run_retrieval_eval.py Recall@k, Precision@k, MRR, NDCG@k at document, page
                        and answer-span level
  run_config_sweep.py   chunk size, overlap, threshold and metadata filtering
                        compared on one fixed query set
  run_perf_bench.py     embedding throughput, retrieval latency percentiles,
                        LLM time-to-first-token

AUDIT.md records what is measurable today and, just as importantly, what is
not: retrieval quality cannot be computed until a document corpus exists, and
that is stated rather than approximated. Results in results/ are from a
four-file smoke corpus and exist to prove the code path, not to be quoted --
except the embedding batch-size sweep, which is hardware-bound and stands on
its own (batch 32 is 6.17x faster than unbatched, 3.4x faster than batch 64).

The audit also turned up four defects, recorded in AUDIT.md section 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
auth_secret is the only input to the session cookie's HMAC, and it fell back
to "dev-only-change-me" -- a constant committed to a public repository. Any
deployment that never set AUTH_SECRET was signing sessions with a key anyone
could read, so anyone could mint a cookie for any user id.

Nothing forced the override, and AUTH_SECRET was absent from .env.example, so
there was not even a prompt to set it. A model validator on Settings now
rejects an empty or still-default secret whenever debug is off. It runs at
Settings() construction, which app.main performs at import, so a misconfigured
process dies before create_app() wires a single route rather than serving
forgeable sessions. Debug builds stay exempt so local development needs no
setup.

Also documents AUTH_SECRET in .env.example alongside the three other AUTH_*
variables, which were equally undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every test in the suite mocks the database. That means no SQL this application
issues has ever been executed by a test: not the per-user `WHERE user_id = ...`
filters that are the whole of the tenant isolation story, not the ON DELETE
CASCADE behaviour, not the pgvector column types, and not the migrations
themselves. A mock returns whatever it was told to return, so a query that
selects another user's rows passes just as happily as one that does not.

This adds a second tier rather than converting the existing one. Tests marked
`integration` run against a real PostgreSQL server with pgvector; everything
else keeps mocking and stays fast.

  tests/conftest.py   `migrated_database` runs Alembic to head once per session,
                      `db_session` truncates and hands out a session, and
                      `client` binds an httpx client to that same session via a
                      get_db dependency override, so a row a test writes is
                      visible to the request it then makes.
  pytest.ini          registers the `integration` marker so `-m integration`
                      and `-m "not integration"` both select meaningfully.
  ci.yml              adds a pgvector/pgvector:pg16 service container, matching
                      the Railway major version, and sets TEST_DATABASE_URL.

The tier is opt-in through TEST_DATABASE_URL. Unset -- a laptop with no
database, which is the default -- and every integration test skips, so
`pytest -q` behaves exactly as before (84 passed). CI sets it, so CI runs them.

Alembic is driven through a subprocess rather than `alembic.command`. Two
reasons: migrations/env.py calls `asyncio.run()`, which raises when reached
from inside pytest-asyncio's running loop, and it resolves the database URL
from an lru_cache'd Settings, so an in-process override would leak into every
later test in the session.

pytest-cov is added and CI prints coverage, but nothing is gated on it. A
threshold before there is a baseline would only encode today's number as a
target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migration a9f3d6c1b5e8 switched the embedding model from bge-base to bge-small
and cleared every vector to resize the column:

    UPDATE document_chunks SET embedding = NULL

It did not touch `documents.status`. The affected documents therefore still
read `indexed` while not one of their chunks can be matched by a vector search.
The file list shows them as ready, retrieval never returns them, and the
assistant cannot find a document the user watched it ingest. The audit found
the live database in exactly this state: 5 documents at status='indexed' over
31 chunks, only 30 of which carry an embedding.

a9f3d6c1b5e8 has already run against production, so it is not edited -- Alembic
will not re-run a stamped revision, and editing it would leave every deployed
database describing a history it does not have. A new migration corrects the
state instead:

  d5e2c8a7f1b3  sets status='failed' on documents that claim to be indexed but
                own at least one NULL-embedding chunk, with a downgrade that
                restores the previous status using the same predicate.

Re-embedding is deliberately not done in the migration. It would mean loading
the ONNX model and re-reading source files while the application waits to boot,
and railway.json runs `alembic upgrade head` on every start. 'failed' is the
honest description of these rows; repairing them is a separate, out-of-band
step:

  scripts/reindex.py  rebuilds one document (--document-id) or every failed
                      document whose file still exists (--all-failed), with
                      --dry-run to preview.

The script clears existing chunk rows before re-running the pipeline, because
store_chunks appends rather than replaces -- without the delete a rebuilt
document would carry two generations of chunks and double its retrieval hits.
Documents are processed serially: embedding is CPU-bound and shares the
instance with live traffic, which is how the earlier OOM crash happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… section 7

The audit counted 13 routes against a README API table listing 12. The missing
one is `GET /documents/{id}/debug`.

It is documented rather than removed. Nothing under frontend/ calls it, which
was the argument for deleting it, but it is scoped by `current_user.id` exactly
like every other document route, so it exposes nothing the owner cannot already
read -- and it reports precisely the mismatch that defect 2 is about: recorded
chunk count, actual chunk rows, and how many of them carry an embedding. That
makes it the check an operator runs before and after scripts/reindex.py, so
deleting it would remove the tool that verifies the fix shipped alongside it.

AUDIT section 7 is updated to match reality rather than left asserting defects
that are now closed:

  1. withdrawn -- not reproducible. backend/.env.example has named bge-small
     since 2f46ff0, the commit that swapped the model, and it agrees with both
     Settings.embedding_model and the ONNX directory actually loaded. The
     finding was almost certainly read from an untracked local .env.
  2. fixed by d5e2c8a7f1b3 and scripts/reindex.py.
  3. fixed by the README row above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…al SQL

Every document route and /retrieve narrows its query with
`Document.user_id == current_user.id`. That clause is the entirety of this
application's multi-tenancy, and no test had ever executed it. The existing
suite hands the routes an AsyncMock, which returns what the test told it to
return -- a route that had lost its ownership filter would have passed
identically.

Seeds two real users in PostgreSQL, gives one a document with an embedded
chunk, and asserts the other cannot reach it through any route that takes a
document id: GET /documents (absent from the list), GET /documents/{id},
/chunks, /debug, DELETE, and POST /retrieve.

Two properties worth calling out:

  - A cross-tenant read returns 404, not 403, and the tests assert it. 403
    would confirm the document exists, turning an opaque id into an oracle for
    "does this belong to someone else".
  - DELETE is checked for having done nothing, not merely for reporting 404.
    A route that deleted first and then reported the row missing would satisfy
    the status code alone.

A positive counterpart asserts the owner still retrieves their own chunk, so
the isolation assertions cannot be satisfied by retrieval being broken for
everyone. Query embedding is stubbed there -- loading the ONNX model tests
nothing about ownership -- while the vector search and its filter run for real.

The auth tests cover what a mock cannot: the UNIQUE constraint on users.email
that makes a duplicate signup a 409 (including a differently-cased address,
which only the real index can collide), a valid cookie failing once its user
row is deleted, and identical 401 bodies for a wrong password and an unknown
email, so the pair cannot be used to enumerate registered addresses.

Also adds the ON DELETE CASCADE check: deleting a user really does remove the
documents and chunks, rather than raising a foreign-key violation or orphaning
rows. That cascade is declared in both the ORM and the initial migration and
had likewise never been executed by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A migration that is only ever read is not known to work, and this repository
has a specific reason to care: railway.json runs `alembic upgrade head` on
every boot, so a migration reaches production the moment a PR merges, and a
downgrade that was never executed is no way back at all.

These tests invoke the Alembic CLI against real data:

  - upgrade -> downgrade -> upgrade across d5e2c8a7f1b3, seeded with the exact
    state a9f3d6c1b5e8 left behind (a document marked `indexed` whose chunk has
    a NULL embedding). Asserts the status flips to `failed`, comes back to
    `indexed` on downgrade, and flips again on re-apply, so re-running is
    deterministic rather than cumulative.
  - a document with real embeddings is left untouched in both directions.
  - a genuinely failed document with no chunk rows is not promoted to `indexed`
    by the downgrade, which is the one way the shared predicate could have
    misfired.
  - chunk text and embeddings survive the round trip -- the migration rewrites
    `status` and nothing else.
  - `downgrade base` then `upgrade head` over the entire chain, which is the
    cheapest possible guard against a downgrade that was never written.

Status is read back through raw SQL because the migration changes the row
underneath the session, and the ORM identity map would otherwise return the
stale object and pass regardless.

Every test restores the database to head in a finally block, so a failure
partway through does not strand the rest of the session on an older revision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conventions that were being restated at the start of every session, or
rediscovered the hard way, now live in one file: repository layout and why the
API layer is thin, the exact commands for the unit tier, the integration tier
and the evaluation harness, and the rules that outlast any one phase.

The rules worth stating plainly:

  - One PR per phase.
  - Every retrieval change ships with a before/after table against BASELINE.md.
    Chunking, embedding, threshold, top-k, filtering, scoring and context
    building all count. "It seems better" is not a result.
  - Never edit an applied migration; correct it with a new one, write a real
    downgrade, and remember that railway.json runs `alembic upgrade head` on
    every boot -- merging a migration IS running it against production.
  - Read frontend/AGENTS.md before touching Next.js. This is Next 16 with React
    19, params is a Promise, and the bundled docs under node_modules are the
    authority for this version rather than recollection.

Two operational details that have already cost time are recorded explicitly:
the integration fixtures TRUNCATE whatever TEST_DATABASE_URL points at, so the
test container uses port 5434 while the dev stack holds 5433; and AUTH_SECRET
is required whenever DEBUG is false, with the one-liner to generate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retrieval quality could not be measured because there was no corpus. The
existing smoke results were generated from four files, two of which were this
repository's own README, which is enough to prove the harness runs and nothing
else.

corpus/manifest.json records 27 multi-page public documents -- 3,428 pages,
~95MB -- with a URL, a SHA-256 and a licence note for each.
`download_corpus.py` fetches and verifies them; the PDFs themselves are
gitignored, since they are large and belong to their publishers. The manifest
is what makes a number reproducible: two people who run the download get
byte-identical inputs, and a document changed upstream surfaces as a hash
mismatch rather than as a quietly different score.

Source selection is narrower than planned, on purpose:

  - Everything is a U.S. federal government publication (NIST x24, Federal
    Reserve x3), which is public domain under 17 U.S.C. 105. That avoids
    asserting redistribution rights over a third-party paper whose licence
    would have to be checked item by item.
  - GAO reports were the original intent and were dropped. gao.gov answers 403
    to any non-browser User-Agent; it serves the same files to a spoofed one,
    but working around a publisher's bot policy is not a dependency this
    corpus should carry.
  - arXiv was considered and rejected for the corpus proper: its default
    licence is a non-exclusive distribution licence, not CC-BY, and only some
    papers are CC-BY.

The resulting corpus skews toward cybersecurity, risk-management and
macroeconomic prose. That is a real limitation on how far any number computed
from it generalises, and it is recorded in the manifest and repeated wherever
the results are reported rather than left for a reader to infer.

`--write-hashes` fills in a null sha256 and records page count and embedded
title read from the file itself, so the manifest cannot drift from the bytes.
It refuses to overwrite an existing hash, so it can never silently bless a
document that changed upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated query set has a structural blind spot: build_eval_set.py writes
one question per passage, from that passage, so every query it produces is
single-hop, single-document, and answerable by construction. Three things that
matter in real use are therefore unmeasurable.

  unanswerable (15)  Questions the corpus cannot answer. This is the slice that
                     shows whether retrieval abstains or hands the model
                     confident-looking, topically-adjacent chunks above the
                     similarity threshold -- which is how a grounded assistant
                     ends up inventing an answer.
  multi_hop (15)     Questions needing two documents, to show whether top-k
                     surfaces both or only the more lexically similar one.
  followup (15)      Two-turn exchanges whose second turn ("what about the
                     third one?") is meaningless alone. Each carries a
                     reference standalone rewrite, so query condensing can
                     later be scored against a fixed target rather than
                     against itself.

Ground truth references documents by manifest id rather than database
document_id, which is regenerated on every ingest.

Marked DRAFT and deliberately not used: no number in this PR comes from it.
The caveats are recorded in the file rather than assumed away -- the items were
written from document scope, not by reading 3,428 pages, and the unanswerable
slice is the fragile one, because several NIST documents carry crosswalks to
external frameworks and a question meant to be unanswerable can turn out to be
partly covered by a mapping table.

Also corrects the evaluation commands in CLAUDE.md. The harness scripts import
`_common` flatly and rely on _common.py to put backend/ on sys.path, so they
run as paths; `python -m evaluation.ingest_corpus` raises ModuleNotFoundError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The draft slice was written from document titles and scope, and the file said
plainly that this was the fragile part. It was: four of the fifteen questions
asked about frameworks the corpus genuinely discusses.

Extracted the full text of all 27 PDFs and searched each item's distinguishing
terms, case-insensitively and on word boundaries. Findings:

  PCI DSS     18 occurrences across SP 800-92, 800-146, 800-209 and 800-218,
              cited as a compliance driver for logging and storage.
  HIPAA       33 occurrences, notably IR 8259 and SP 800-207.
  Basel III   present in both Federal Reserve annual reports, alongside
              'liquidity coverage ratio'.
  NBER        'National Bureau of Economic Research' appears in both annual
              reports, defining the recession shading on their charts.

Those four are replaced with topics verified absent: the EU Digital
Operational Resilience Act, Brazil's LGPD, People's Bank of China reserve
requirements, and the Bank of Japan's inflation target index. Each item now
records why it is absent, including what it replaced.

Word boundaries turned out to matter more than expected. A plain substring
search reported 903 hits for 'Rust' and would have condemned a perfectly good
question: every one of them was inside the word 'trust', which a corpus
containing SP 800-207 on Zero Trust Architecture has rather a lot of. The
reverse also appeared -- 'NCSC' matches three times, but as the U.S. ODNI
National Counterintelligence and Security Center, not the UK National Cyber
Security Centre the question is about, so that item stands with a corrected
rationale.

The multi_hop and followup slices are explicitly NOT verified this way and the
file says so. Their ground truth asserts which document holds the answer, which
a term search cannot confirm; that still needs a human read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docu-query Ready Ready Preview Sep 24, 2026 8:42am UTC

This branch was successfully deployed

1 active deployment
Preview — 83ab02d6 Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant