Turn a research PDF into a knowledge graph where every generated claim stays bound to a located quote, and hand that grounding to Claude as a callable MCP service.
Quick start · What works today · Architecture · MCP · Design system · Configuration · Contributing
Upload a paper, or paste an arXiv / PMC / DOI link. Pepiros extracts its structure, generates a summary and thematic "pillars," and backs every generated claim with a quote it can point at in the source PDF: page, rect, and match score included.
The verification is deterministic. No LLM judges another LLM:
for each claimed {ref, quote}:
chunk = resolve(ref) # stable citation id, not a vector lookup
if !chunk -> drop, log hallucinated_ref
score = token_set_ratio(quote, chunk.text)
if score >= 0.92 -> quote located
elif score >= 0.75 -> paraphrase (badged, kept)
else -> drop the anchor, strip [^eN] from the prose
On top of that sits an entailment overlap floor: every number, unit, and comparator a claim asserts must also appear in the anchored chunk's numerics. That is what catches the real failure mode, a genuine quote attached to a reversed or overstated conclusion, which fuzzy matching alone scores 1.0.
No claim is ever labelled "verified." The badge reads quote located, because a fuzzy-matched quote proves quotation provenance, not entailment. Claim and quote render side by side so the reader adjudicates. This is a deliberate, load-bearing constraint, not hedging.
plan.md is the canonical spec: architecture, data model, locked decisions, and the list of things deliberately not built. This README is the entry point.
Early build, moving fast. Honest status:
| Area | State |
|---|---|
Grounding spine (lib/grounding/*) |
Quote verification, entailment floor, reverse audit, citation-ref resolution. Deterministic, no model calls. |
Data model (lib/db/schema.ts) |
All 18 tables from plan.md §5, in Drizzle, with real migrations applied and CI running against a real ephemeral Postgres on every push. |
UI (components/*, app/(app)/*) |
React Flow canvas (5 node types, ghost citation nodes, kind-coloured edges), doc-reader, outline, audit, learn, and share views -- all four reader subpages share one nav/spacing shell and a consistent guest banner. Renders the bundled fixture or any real ingested workspace alike. |
Citation APIs (lib/services/related.ts, lib/services/citationExpand.ts) |
Real Semantic Scholar + OpenAlex calls (free, no key) for the related-papers rail and canvas citation expansion. Typed ok/no_match/rate_limited/error status, never fabricated fallback data. |
LLM layer (lib/agents/*) |
Archetype classifier, archetype-conditioned pillar planner, and 6 of the 21 node generators (summary, methodology, statistical_validity, stated_limitations, weaknesses, does_not_establish), fanned out via p-queue with per-node failure isolation. Runs on Groq (primary) + Featherless (fallback), not Anthropic: see Configuration. Every claimed quote is re-verified through the grounding spine before it becomes a real evidence row. Verified end to end against both live APIs, not just mocked. |
MCP server (mcp/*) |
All 12 tools in lib/mcp/registry.ts are live over stdio (search_paper, verify_claim, get_outline, get_node, create_node, find_contradictions, paper_facts, list_papers, list_workspaces, create_workspace, add_paper, get_job), plus 3 resource templates for @-mentioning a workspace/paper/node and the 4 docs/PLAN-V1.md §13.3 prompts. create_node re-verifies submitted evidence server-side, so a client cannot assert quote_located. A token requires a live session (or is refused outright in a production-configured process with none) rather than defaulting to unrestricted access. Verified over the real protocol with the SDK's own client. |
Grounded chat (lib/services/chat.ts, POST /api/chat) |
Query rewrite → route classifier → stable-id context block → answer → citation re-verification. Refusal path with an explicit "answer without sources" opt-in; ungrounded answers render visually distinct. Verified against the live Groq API. |
Canvas layout (lib/layout/*) |
Deterministic server-computed positions: radial for one paper, layered columns for several. Verified to produce no overlapping cards at 1, 2, or 3 papers. Pillars open collapsed, edges render only when both endpoints do, cards shed detail as you zoom out (lib/graph/lod.ts), and a closed-by-default legend explains every colour and line style the graph actually uses. |
Auth (lib/auth/*, app/auth/callback) |
Google sign-in via Supabase as the OAuth broker, plus real username/password accounts (a required real email, so password recovery has somewhere to send to, and email confirmation is real -- Supabase sends it, sign-in gates on it). Password sessions are server-side revocable (logout kills that one session; a "sign out everywhere" action kills every session for the account), translated into the app's own signed cookie so there is one answer to "who is signed in". Sign-in buys persistence, not access: the reader and upload are open to guests, with a banner saying plainly that guest work is not kept. Every workspace-scoped mutation route requires a session for anything other than the public demo workspace. |
Upload validation + ingest (lib/services/upload.ts, lib/services/ingest.ts, POST /api/ingest) |
Size (50MB), magic bytes, page cap (120), text-layer check, duplicate detection (DOI then fuzzy title), and arXiv/PMC/direct-PDF URL resolution (DOI resolution still needs an Unpaywall-style resolver). GET /api/jobs/[id] streams the 9-stage progress list over SSE. A 202 kicks off the real pipeline: scripts/parse.py (PyMuPDF) extracts sections/chunks/numerics/authors/publication year/archetype, then lib/agents/orchestrator.ts classifies, plans pillars, and fans out generators, all re-verified against the source before the graph updates. A scanned/image-only PDF fails loudly with a clear diagnosis instead of silently ingesting nothing. The parsed PDF itself is stored locally and served back for the reader's real page view (react-pdf), not a styled mock. |
Cross-paper synthesis (lib/services/synthesis.ts, POST /api/compare) |
Pairwise comparison writes real agrees/contradicts/extends/shares_method/relates edges (two-sided evidence required) and 4 of the 6 docs/PLAN-V1.md §10 synthesis node types: Consensus, Contradictions (LLM-classified), Timeline of Findings, and Methodological Divergence (both deterministic, from real per-paper year/archetype). Dataset Overlap and Open Questions still need signals nothing in the pipeline extracts yet. |
Node mutation (lib/services/nodes.ts, PATCH/DELETE /api/nodes/[id]) |
Editing a node's body re-verifies every citation against the edited text (downgrading/stripping one that no longer matches) and records a version history row; deleting cascades edges/evidence and marks referencing nodes stale. |
Export & promote (GET /api/export, POST /api/chat/promote) |
Markdown and BibTeX export; promoting a chat answer that draws on more than one paper into a cross-paper thread node. |
| API | POST /api/verify, POST /api/audit, POST /api/chat, POST /api/ingest, POST /api/compare, POST /api/share, GET /api/jobs/[id], GET /api/graph/[workspaceId], GET /api/related, GET /api/expand, GET /api/export, full nodes/nodes/[id] CRUD, GET /api/papers/[paperId]/pdf |
| Tooling | Typecheck, lint, test, and build all gated in CI on every push and PR, against a real ephemeral Postgres service container. 297 Vitest cases across 34 files, including the LLM and chat layers tested against a hand-rolled mock model (no API key or network call needed). |
Each of these is a one-line TODO at the top of its file, describing what belongs there.
| Area | Missing |
|---|---|
| OCR fallback + seed script | scripts/ocr_fallback.py (PaddleOCR-VL, for scanned/table-heavy pages) and scripts/seed.ts (bulk-loading a corpus into Postgres once one exists) are still stubs -- a scanned/image-only PDF now fails the job with a clear message instead of silently ingesting nothing, but there's no OCR pass yet to actually recover text from one. scripts/parse.py (PyMuPDF) and lib/services/ingest.ts are otherwise real -- pip install pymupdf is the one setup step, since it's a local script per plan.md §2, never a deployed service. |
| Remaining generators | 20 of the ~22 real types in docs/PLAN-V1.md §8 are implemented (lib/agents/generators/), including equations -- scripts/parse.py detects display-equation regions and converts them to LaTeX via Pix2Text (free, open-source, runs entirely locally), bbox-anchored like any other chunk -- and figures, which needed the same Pix2Text pass to also crop each detected figure region and pair it with its nearby caption (a real figure_caption-kind chunk), plus a third model tier: visionModel() in lib/ai/client.ts (OpenRouter, a free :free-suffixed model, since Groq has no vision model at all and Featherless's returned capacity_exhausted on every attempt checked live). Left: concept_links (needs cross-paper context a single-paper GeneratorContext doesn't carry, plus a way to write real relates edges from a generator's output -- already solved a different way by lib/services/synthesis.ts's relates classification, so this isn't a real gap so much as a pointer to where the equivalent already lives). quiz/flashcards are solved a different way -- lib/services/quiz.ts and components/learn/FlashcardDeck.tsx derive them from already-verified leaf nodes rather than being their own generator. |
| Synthesis, remaining node types | Dataset Overlap and Open Questions (docs/PLAN-V1.md §10) need signals nothing in the pipeline extracts yet (dataset identifiers, a genuine gap-in-the-literature judgment) -- not a metadata gap like the two that shipped, new extraction work. |
| MCP remote transport | npx pepiros-mcp is live on npm (published as pepiros-mcp, the tool layer bundled to one file via esbuild) and verified end to end with a real initialize call over a fresh install. What's left is the remote streamable-HTTP + OAuth transport, for a hosted connector rather than a locally-run stdio process; docs/PLAN-V1.md §13.4 says verify connector requirements against Anthropic's current docs before building that. |
| Session refresh | A signed-in session simply expires at its 7-day lifetime with no silent renewal. Revocation exists (see Auth, above); refresh doesn't yet. |
| Measurement | evals/, scripts/measure-drop-rate.ts |
Chat and the generators need at least one of GROQ_API_KEY or FEATHERLESS_API_KEY; everything else runs without a key. PDF ingest itself only runs locally (npm run dev) -- Vercel's Node runtime has no Python interpreter, so the hosted deployment serves the demo workspace's already-ingested content but can't parse a new upload.
git clone https://github.com/StudentSuite/pepiros.git
cd pepiros
npm install
npm run devOpen http://localhost:3000/w/ws-1. ws-1 is the only workspace id the fixture defines.
No environment file, database, or API key is needed to run it: the app is fully functional against fixtures/workspace.json, which ships 3 papers, a contradiction pair, a cross-paper cites link, and one planted misattribution that the verifier correctly demotes to unsupported.
Requires Node 20 or newer. Uploading a real PDF additionally needs Python 3 with PyMuPDF (pip install -r scripts/requirements.txt) and a GROQ_API_KEY (or FEATHERLESS_API_KEY) in .env -- neither is needed just to browse the fixture workspace.
| Command | Does |
|---|---|
npm run dev |
Next.js dev server |
npm run build / npm start |
Production build / serve |
npm run typecheck |
tsc --noEmit across the repo |
npm run lint |
ESLint |
npm test / npm run test:watch |
Vitest over lib/**/*.test.ts |
npm run db:generate / db:migrate / db:studio |
Drizzle Kit against DATABASE_URL |
npm run seed |
scripts/seed.ts (still a stub) |
npm run mcp:stdio |
MCP server over stdio, see Using the MCP server |
Next.js 15 (App Router, TS, React 19) -> Vercel
app/api/* HTTP surface for the UI
mcp/server.ts MCP surface for Claude (stdio today, remote HTTP later)
lib/services/* <- BOTH of the above call only this
lib/grounding/* deterministic verification, no model calls
lib/agents/* archetype classifier, pillar planner, node generators -> Groq, falling back to Featherless (lib/ai/client.ts)
lib/layout/* deterministic server-computed node positions (radial | layered)
lib/chat/* citation-marker parsing, shared by the chat server and client
Supabase Postgres (no vector column), Storage, Auth, Realtime (job status)
scripts/parse.py local PyMuPDF run: sections, chunks, figures, equations, refs, numerics
scripts/ocr_fallback.py local PaddleOCR-VL, for scanned or table-heavy pages
Three things that look like omissions and are not. There are no embeddings and no vector column: a paper is 8-20k tokens, so the whole thing goes in context behind a prompt cache, addressed by stable citation ids (search_paper scores keyword coverage rather than cosine distance). There is no deployed Python service: PyMuPDF and PaddleOCR run as local scripts, so there is no second deploy target. There is no force-directed layout: positions come from lib/layout/* as a pure function of the graph's shape, so the same graph always lands identically. See plan.md §2 and §11 before proposing any of them.
Working in this repo with an AI coding agent? Read CLAUDE.md first.
Point Claude Code (or Desktop) at the stdio server to call the grounding layer from a conversation. Published on npm, no local clone needed:
{
"mcpServers": {
"pepiros": {
"command": "npx",
"args": ["-y", "pepiros-mcp"]
}
}
}Working from a clone instead (e.g. to test a local change to mcp/* before it's published):
{
"mcpServers": {
"pepiros": {
"command": "npx",
"args": ["tsx", "mcp/stdio.ts"],
"cwd": "/absolute/path/to/pepiros"
}
}
}Then the docs/PLAN-V1.md §13.5 beat works for real: ask Claude to summarize the fixture's RCT, then "now verify every claim you just made." It calls verify_claim on its own sentences and reports back which ones the source actually supports.
Worth knowing what the tiers mean, because the distinction is the product: quote located means the quote was found in the source. It does not mean the claim follows from the quote. Nothing here judges entailment: that is deliberately left to the reader, and there is no tool that will do it for you.
Nothing below is required to run against the fixture. Copy .env.example to .env once a real backend exists.
LLM provider: Groq primary, Featherless fallback, not Anthropic. Either works alone:
| Variable | For |
|---|---|
GROQ_API_KEY |
Primary. Defaults to openai/gpt-oss-20b/-120b, the only two Groq models confirmed to support the structured outputs every call here needs; see lib/ai/client.ts's comment before changing either id |
FEATHERLESS_API_KEY |
Fallback, used only when Groq 401/402/403/429s or marks its own error retryable (lib/ai/fallbackModel.ts). Featherless is OpenAI-compatible with no first-party AI SDK provider: lib/ai/client.ts goes through @ai-sdk/openai-compatible. Its $25/mo flat-rate plan is contractually for human-driven use in Featherless's own UI, not the programmatic API traffic this is; the metered Developer plan is the one its terms describe as intended for that |
FEATHERLESS_MODEL_FAST, FEATHERLESS_MODEL_STRONG |
Default to two Qwen2.5 models verified live against a real account (meta-llama's repos there are gated behind a HuggingFace org connection and 403 without it) |
Free account:
| Variable | For |
|---|---|
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY |
Postgres, Storage, Auth, Realtime |
DATABASE_URL |
Drizzle's direct Postgres connection |
SESSION_SECRET |
Signs the app's own session cookie. Any long random string; falls back to a known constant in development and refuses to start without one in production |
The app is wired for it, but Google itself is enabled per-Supabase-project, not per-env-var, so this is two dashboard steps, not a code change:
- Google Cloud Console → create an OAuth 2.0 client (Web application), and set the authorised redirect URI to
https://<your-project>.supabase.co/auth/v1/callback. - Supabase dashboard → Authentication → Providers → Google → paste the client ID and secret, and add your app's origin (
http://localhost:3000in development) to Authentication → URL Configuration → Redirect URLs.
Until that is done the button still renders and fails honestly: the callback bounces back to /login with a message rather than dead-ending on a provider error page. Email/password sign-in and guest browsing are unaffected either way.
Free, no key required:
| Variable | For |
|---|---|
NEXT_PUBLIC_APP_URL |
MCP results carry deep links back into the canvas, so the app needs to know its own origin |
OPENALEX_MAILTO, CROSSREF_MAILTO |
Courtesy identifiers that move requests into the polite rate-limit pool |
SEMANTIC_SCHOLAR_API_KEY |
Optional. Unauthenticated access is enough unless the related-papers rail starts returning 429 |
MCP_TOKEN_SECRET |
Self-generated, any long random string |
Direction: Editorial Paper (Are.na × Instapaper/NYT Reader), on top of the "lab notebook at night" thesis: dark chrome, a warm paper-white reading surface, pillar colour as a structural system. Full brief and the canonical palette: design/DIRECTIONS.md. Live token reference: /dev/tokens.
| Layer | Where |
|---|---|
| Tokens (color, spacing, radius, elevation, motion, layout dims) | app/globals.css, tailwind.config.ts |
Fonts (Source Serif 4, Inter, JetBrains Mono via next/font) |
app/layout.tsx |
| Primitives (Button, Input, Dialog, Drawer, Tooltip, Popover, Tabs, Menu, Toast, Skeleton, ErrorBanner, Badge, Icon, Logo, ...) | components/ui/ |
| Icons | Lucide, only through components/ui/Icon.tsx (never a raw lucide-react import in a feature component) |
| Brand assets (favicon, app icon, OG image) | app/favicon.ico, app/icon.png, app/apple-icon.png, app/opengraph-image.png, app/twitter-image.png |
| Image-gen prompts (brand kit + all 8 app surfaces) | design/prompts/, zipped at design/pepiros-editorial-paper-prompts.zip |
| Platform-vision scope (accounts, publish, discovery, discussion) | docs/PLAN-V1.md §22 |
Two conventions worth knowing before adding a component: pillar hues have two accessors in components/ui/PillarChip.tsx, pillarColor() for borders/dots/edge strokes (canonical hex, 3:1 threshold) and pillarTextColor() for anywhere a pillar hue is literal text colour (lightened for WCAG AA's 4.5:1, three of the seven canonical hues fail it unmixed). And motion reaches for lib/motion.ts's named helpers or the keyframes in globals.css, never a hand-picked duration: Editorial Paper is ease-out only, never spring.
| File | What |
|---|---|
plan.md |
Canonical spec: product, architecture, locked decisions, cut list |
docs/PLAN-V1.md |
Long-form reference behind plan.md, cited by the TODO comments |
CLAUDE.md |
Conventions and invariants for AI coding agents |
CONTRIBUTING.md |
Setup, PR gates, code conventions |
CHANGELOG.md |
Keep a Changelog format |
SECURITY.md |
Private vulnerability reporting |
MIT © Anay Dhawan and Yash Kewlani