Repository navigation
Running
How to run Paper Trail — on your laptop, or for real.
You need Python 3.12+, uv, and Node for the frontend. Nothing else — no cloud account, no API keys.
git clone https://github.com/unnobatroo/paper-trail
cd paper-trail
uv sync # backend deps
uv run uvicorn paper_trail.api.app:app --app-dir src # API on :8000
cd web
cp .env.local.example .env.local
npm install && npm run dev # UI on :3000The tests are a good sanity check, and they're fully offline by design:
uv run pytest # ~70 tests, no keys, no network, seconds
cd web && npm test # 17 frontend testsBy default a first real evidence search downloads the MiniLM model
(~220 MB into data/models/) and queries DuckDuckGo. For fully offline
work — or tests — set these in .env:
PAPER_TRAIL_EMBED_MODEL=hashing # deterministic stub, results meaningless
PAPER_TRAIL_RERANKER=none
PAPER_TRAIL_SEARCH=fixture # replays cached hits from data/fixtures/The embedder and the extraction LLM can run on any box that speaks the
OpenAI API (llama.cpp, vLLM, TEI, Ollama). Point the app at it in .env:
PAPER_TRAIL_EMBED_MODEL=openai:bge-m3 # or just bge-m3
PAPER_TRAIL_EMBED_BASE_URL=http://my-box:8081/v1
PAPER_TRAIL_EMBED_API_KEY= # only if the server wants one
PAPER_TRAIL_LLM_BASE_URL=http://my-box:8080/v1
PAPER_TRAIL_LLM_API_KEY=local
PAPER_TRAIL_LLM_MODEL=qwen2.5-vl-7b-instruct # llama.cpp ignores the name
PAPER_TRAIL_LLM_WINDOW=1 # pages per callThings to know:
- Vectors are cached per model name (
openai:bge-m3), so switching models means re-embedding: clear thefetched/cache or the old model'schunksrows. The same goes for pointing the base URL at a server with different weights under the same model name. - The pgvector column is
vector(1024). Shorter vectors are padded, wider ones fail loudly on write, so pick a model with 1024 dims or fewer (BGE-M3 is exactly 1024). - The default window is 4 pages per LLM call. A small model on a shared
box may time out on that;
PAPER_TRAIL_LLM_WINDOW=1sends one page per call. If a multi-page call times out, the extractor retries that window page by page. - Reranking stays off (
PAPER_TRAIL_RERANKER=none) unless you also configure one, so matches are ranked by text similarity only.
Run the files in supabase/migrations/ in the SQL editor (001 through
006, in order), put SUPABASE_URL and SUPABASE_KEY (service role) in
.env, and push the seed PDFs once with
uv run python scripts/push_data.py. From then on, all state — rows,
fetched pages, embeddings, documents, jobs — lives in Supabase and
nothing is stored in the repo or on local disk.
Going the other way, uv run python scripts/pull_data.py mirrors the
source PDFs, the benchmark files, and the official_sources registry
into the local SQLite — so an offline dev run searches the same
catalogue as production.
The live instance runs on the cheapest thing that works:
-
Frontend — Vercel, root directory
web/, one env var (NEXT_PUBLIC_API_URL→ the API). Pushes tomaindeploy automatically.NEXT_PUBLIC_REVIEW_KEYexists but is for private deployments only — it ships in the JS bundle. -
API — Azure App Service on the free tier, deployed as a zip.
The durable jobs table is what makes this viable: if the instance
restarts mid-job, the sweeper marks it and it can be retried.
Set
PAPER_TRAIL_REVIEW_KEYhere if strangers shouldn't be able to click Confirm — the UI then asks reviewers for it via a dialog in the top bar. - Data — Supabase (free tier): Postgres, pgvector, Storage, jobs table.
The API's Dockerfile also works on any container host (Render, Fly,
Railway) — same env vars, same behaviour. The only thing to remember
is PAPER_TRAIL_MODEL_CACHE: point it at persistent disk
(/home/data/models on App Service) or the model re-downloads on every
restart.
One caveat worth knowing: MiniLM on 1 GB of RAM is workable but snug.
Keep job concurrency low (the runner ships capped at two workers) and
don't enable the local reranker — it's another ~1 GB. If quality ever
justifies the cost, drop in a JINA_API_KEY and the default auto
mode picks up the hosted reranker on the next restart;
PAPER_TRAIL_EMBED_MODEL=jina moves embeddings up there too, and the
F1 box barely notices either way.
-
scripts/reset_demo_state.py— wipes domain data, keeps caches, so a fresh demo doesn't re-fetch or re-embed anything. -
requirements.txtis generated for the container build — regenerate after dependency changes:uv export --format requirements-txt --no-hashes --no-dev --no-group ml
Repository · Live · GPL-3.0 · Nothing reaches the record without a human click