factcircuit
FactCircuit: an auditable claim-and-evidence verification loop with immutable provenance, staged checks, bounded retrieval, and reproducible evaluation.
Extract structured logic models from PDFs and presentations for editing and export
AI summary · README
DataTypeScript · JavaScriptGitHub @sevsevsevOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Local Vite + React app that reliably extracts structured logic models from PDF/DOCX/PPTX via Gemini for downstream processing, supports human editing, and exports CSV / branded PDFs.
1. npm install
2. Set GEMINI_API_KEY in .env.local
3. npm run dev — Express API (:3011) + Vite (:3000)
Builds the client, then serves API + dist from Express on :3011 (override with PORT).Read the full README ↗Local Vite + React app that reliably extracts structured logic models from PDF/DOCX/PPTX via Gemini for downstream processing, supports human editing, and exports CSV / branded PDFs.
npm installGEMINI_API_KEY in .env.localnpm run dev — Express API (:3011) + Vite (:3000)npm start
Builds the client, then serves API + dist from Express on :3011 (override with PORT).
npm run dev — API + Vite clientnpm run build — client onlynpm run typecheck — tsc --noEmitnpm run replay — replay post-processing over saved model answers, offline and free (-- --score to also score it)npm run benchmark:accuracy — extract the synthetic benchmark documents and score them (-- --capture first time)npm run split:survey — every blessed snapshot cell that the run-on splitter would divide, and into whatnpm start — build + production serverBoth Gemini calls read their model from server/geminiModelConfig.ts. The default is the rolling
alias gemini-flash-latest, because a dated snapshot gets sunset for new API keys —
gemini-2.5-flash already returns 404 "no longer available to new users" on a freshly created key.
The price of a rolling alias is that Google can change what answers with no change here, and on
2026-10-07 it did: the alias moved to gemini-3.8-flash and extraction accuracy moved with it. So
two things are true of this codebase and should stay true.
The model is configuration, not code. Override either call without touching a file:
LM_EXTRACT_MODEL=gemini-3.7-flash npm run benchmark:accuracy # score a candidate
LM_EXTRACT_MODEL=gemini-3.7-flash npm run regression:check # the real set, on his machine
LM_DETECT_MODEL=gemini-3.8-flash npm run dev # page-group detection only
LM_EXTRACT_THINKING_LEVEL is there for the same reason, as an experiment knob with no shipped
effect: unset (and =default) sends no thinkingConfig at all, which is the measured default —
thinkingLevel: low scored the invented benchmark at 100% and cost two real documents accuracy
(PR #36, reverted by #37). An unrecognised value refuses to start rather than quietly running the
default and filing its numbers under the candidate's name.
A blank value means unset. CANDIDATE_MODEL_IDS in that module is the short list worth scoring
when the served model changes — a list, deliberately, because picking one model and tuning to it is
the same bet in different clothes, due again at the next rotation. Membership means "worth
measuring", never "known good".
Every result records which model produced it. served_model_id in the extraction-log CSV, the
servedModelId field on regression snapshots and in benchmark run records, and the LM_DUMP_RAW
dumps all carry response.modelVersion — what answered, as distinct from the alias we asked for.
Absent means unknown and never "the same as the alias". npm run regression:check says out loud
when a baseline was captured on a different served model than the one answering, because a diff
spanning a model change attributes nothing.
What none of this does is make extraction model-independent. The prompt is tuned against observed
model behaviour and extraction quality is a property of the model, so a model change always needs
measuring on fixtures/regression-set/ as well as the benchmark. What it removes is the cost of
measuring it.
npm run benchmark:accuracy -- --input=text-only # send no page images, text track only
npm run regression:check -- --input=text-only # same, on the real set (never blesses)
text-only asks what the page rasters buy; see scripts/inputArm.ts for what it cannot see. The
prompt variant on every row reads text-only, so its results cannot pool with the default.
Post-processing never moves an item between domains. It may fix shape (colons, inline labels,
run-on cells, exact restated duplicates) and fill an empty field from labelled source text, and it
reports anything it would once have moved as a review blocker. See normalizeExtractedLogicModel.
The same API is exposed two ways so one codebase serves both targets:
server.ts) on :3011api/ (/api/health, /api/gemini/extract,
/api/gemini/detect-logic-models, /api/convert/pptx-to-pdf)The Gemini extract and detect routes delegate to shared handlers in server/apiCore.ts, so their
request/response behavior stays identical between the two paths. /api/health and the PPTX convert
route diverge: /api/health shares only getApiKey() — server.ts's handler additionally reports
libreOfficeWasm and a 'production'|'development' mode, while api/health.ts omits
libreOfficeWasm entirely and reports mode from VERCEL_ENV || NODE_ENV || 'unknown'. The PPTX
convert route shares server/pptxConvertApi.ts's handlePptxToPdfRequest, but the two entry points
parse the request body differently (Express uses express.raw; the Vercel function hands the
handler whatever Vercel itself already parsed) — found via codebase audit
(docs/specs/codebase-audit-2026-09-19.md #18).
Setup:
GEMINI_API_KEY (Production + Preview).https<!-- -->://<your-app>/api/health returns {"ok":true,"configured":true,...}.PowerPoint needs two things on a hosted deployment, and both are easy to lose:
getLibreOfficeWasmPath() builds the path at runtime — so a bundler that ships a
function by tracing its imports leaves them out. vercel.json names them with includeFiles,
and gives that one function 2048MB: LibreOffice peaks at ~1.07GB converting a one-line document,
which is already over the 1024MB the route used to run with. 2048MB is the ceiling on a Hobby
account — a deployment that asks for more is rejected outright at build time — so a deck heavy
enough to need more than that has no headroom left to take.application/octet-stream, not the
OOXML presentation type: a serverless host parses the body by content type and hands the
function undefined for anything outside its short list. Sent honestly, the deck's bytes never
arrived at all and the route answered 400 before LibreOffice was reached. The function also
reads the request stream itself as a backstop.GET /api/convert/pptx-to-pdf answers whether that deployment can convert PowerPoint:
{"ok":true,"libreOfficeWasm":true}. It lives on the convert route rather than in /api/health
because each function is bundled separately, so only that function can answer for its own files.
Hosted limits to be aware of:
/api/gemini/extract).config/brand.tsdocs/specs/.cursor/rules/ — see AGENTS.md