Skip to content

Technical documentationInternal

For the team and anyone taking the product over. Not linked from the site and not indexed.

Technical documentation pages

Literature workspace

The literature workspace at /literature searches Europe PMC and OpenAlex, reads licensed Europe PMC full text, keeps highlights, comments and structured evidence in the browser's object store, and offers three optional Anthropic-backed features (evidence extraction, search-strategy suggestions and Study summaries). This page describes the client, the server routes under /api/literature, the ranking and identity logic, the cost controls, what leaves the browser, and the known defects.

Purpose and scope

The workspace supports a literature review that starts from a query and ends in recorded, source-linked findings:

  1. Find papers by keyword, Boolean strategy, identifier or natural-language question, or a bounded combined shortlist.
  2. Select, save and add papers to Studies; export RIS.
  3. Read the abstract and, where a supported reuse licence exists, the Europe PMC full text; highlight passages and comment.
  4. Record structured evidence (six fields plus a verbatim supporting passage), manually or from a reviewed Claude draft.
  5. Follow citations (references, citing works, related works, shared references) through OpenAlex.
  6. Save an immutable search-run manifest to a Study and export it.

Literature is not an app in the registry (content/apps.ts). It is a suite page, like Bench and the object pages, rendered in SuiteFrame (see Architecture). It has no version or release status of its own.

Out of scope in the code: PDF storage, publisher-page annotation, screening workflows, scheduled alerts, cross-device notebook sync (beyond what Study backups carry), and any claim of systematic-review completeness. The UI copy states that discovery is a bounded shortlist and that blank evidence fields mean "not extracted".

Addresses and entry points

Address What it is Source
/literature The workspace page. Metadata sets robots: { index: false, follow: false }; the share card comes from cardForPath("/literature") ("Scientific literature"). src/app/(workspace)/literature/page.tsx, src/lib/sharing/cards.ts
/literature#q=<text> Seeds the search box (first 300 characters). The fragment never reaches the server. It does not run the search. literatureHref in content/apps/routes.ts; initialQuery in src/components/suite/literature/literature-page.tsx
/o/paper/<ID> A paper's object page; its "Paper" tab renders PaperDetails src/components/suite/objects/object-page.tsx, src/components/suite/literature/object-literature.tsx
/o/<type>/<id> for target, compound, condition, trial, product A "Literature" tab (ObjectLiterature) that seeds a search from the object and an Evidence tab (EvidenceTable filtered by subjectRef) object-page.tsx lines 71 to 73, 172 and 189
/study/<id> Evidence comparison for the Study and the Study report panel src/components/suite/objects/study-page.tsx, src/components/suite/workspace/study-summary.tsx
POST /api/literature Single-provider search (version 1 contract) src/app/api/literature/route.ts
POST /api/literature/search Multi-lane search (version 2 contract) src/app/api/literature/search/route.ts
POST /api/literature/discover Citation discovery src/app/api/literature/discover/route.ts
POST /api/literature/fulltext Europe PMC full text src/app/api/literature/fulltext/route.ts
GET, POST /api/literature/extract AI evidence extraction src/app/api/literature/extract/route.ts
GET, POST /api/literature/plan AI search-strategy suggestion src/app/api/literature/plan/route.ts
POST /api/study/summary AI Study summary; shares the literature AI modules src/app/api/study/summary/route.ts

The page is served with the object-page Content Security Policy (objectPageHeaderRules via OBJECT_ROUTE_SOURCES). See Security and network for that policy and for the navigation-boundary caveat on /literature.

File map

Path Role
src/app/(workspace)/literature/page.tsx Route; theme script and LiteraturePage
src/app/api/literature/route.ts v1 search: Europe PMC or OpenAlex semantic/singleton
src/app/api/literature/search/route.ts v2 search: validation, owner, executeSearch
src/app/api/literature/discover/route.ts Calls discover
src/app/api/literature/fulltext/route.ts Calls fullText
src/app/api/literature/extract/route.ts Availability (GET) and extraction (POST)
src/app/api/literature/plan/route.ts Binds createSearchPlanHandlers to real dependencies
src/app/api/study/summary/route.ts Study summary (uses src/lib/literature/study-summary.ts)
src/lib/literature/model.ts Paper type, readPaper validator, samePaper, paperObject, citation, papersRis
src/lib/literature/identity.ts PAPER_ID, normalisePaperId, paperIdentity
src/lib/literature/europe-pmc.ts LiteratureQuery, validateQuery, searchExpression, searchUrl, parsePaper, parseResults, plainText
src/lib/literature/openalex.ts openAlexUrl, parseOpenAlexPaper, parseOpenAlexResults, abstract reconstruction
src/lib/literature/sources.ts Server transport: locator, limitedText, publicResponse, publicJson, resolveWork, fetchPaper, requestOrigin, requestBody, failure
src/lib/literature/shared.ts Client-safe LiteratureError and record
src/lib/literature/discovery.ts Citation discovery
src/lib/literature/fulltext.ts JATS XML parsing, licence gate, full-text cache
src/lib/literature/annotations.ts Highlight/comment records, anchors, notebook Markdown
src/lib/literature/evidence.ts Evidence fields, validation, text and CSV
src/lib/literature/extraction.ts EXTRACTION_MODEL, bounded source, extraction request and parser
src/lib/literature/extraction-access.ts AI access gate and per-process AI allowance
src/lib/literature/anthropic.ts The only Anthropic transport
src/lib/literature/study-summary.ts, study-report.ts Study packet, summary request/parser, printable HTML report
src/lib/literature/search/types.ts SearchLane, ProviderOutcome, SearchHit, SearchRun
src/lib/literature/search/query-parser.ts Boolean grammar and provider compilers
src/lib/literature/search/providers.ts Per-lane provider transport, exact-identifier detection
src/lib/literature/search/execute.ts Lane planning, reservations, caching, coalescing
src/lib/literature/search/rank.ts Identity graph deduplication and reciprocal rank fusion
src/lib/literature/search/quota.ts Search and AI-assist reservations (local and Supabase)
src/lib/literature/search/manifest.ts Search-run manifest creation, validation and export
src/lib/literature/search/reuse.ts Marks cached or coalesced results as reused
src/lib/literature/search/concepts.ts Local subject suggestions, insertion, concept-group query builder
src/lib/literature/search/assistant.ts Search-plan request, parser, availability and handlers
src/lib/literature/search/saved-papers.ts Saved-library filtering
src/lib/literature/search/evaluation/ Offline benchmark (benchmark.ts), metrics, evaluator and CLI
src/components/suite/literature/literature-page.tsx Page header, tabs, Saved papers
src/components/suite/literature/literature-desk.tsx, desk-context.tsx Selection, reader drawer, dialogs
src/components/suite/literature/literature-search.tsx Search form and results
src/components/suite/literature/client.ts Browser fetch helpers and tab-local caches
src/components/suite/literature/strategy-builder.tsx Concept-group builder
src/components/suite/literature/search-assistant.tsx Claude strategy suggestion UI
src/components/suite/literature/search-history.tsx Save, restore and export search runs
src/components/suite/literature/paper-card.tsx Paper card, actions, reading status, RIS export
src/components/suite/literature/paper-reader.tsx, paper-research.tsx Reader layout; full-text loading
src/components/suite/literature/review-notebook.tsx Highlights, comments, notebook
src/components/suite/literature/extraction-assistant.tsx Claude evidence-draft UI
src/components/suite/literature/evidence-editor.tsx, evidence-table.tsx Evidence editing and comparison
src/components/suite/literature/citation-browser.tsx Citation discovery dialog
src/components/suite/literature/object-literature.tsx Object-page Literature tab and paper page
supabase/migrations/202610090001_literature_search_quota.sql Shared quota table and RPC
content/apps/routes.ts literatureHref, OBJECT_ROUTE_SOURCES
content/apps/workspace-research.ts, workspace-reports.ts, workspace-navigation.ts The small amount of literature copy held in content files
e2e/literature/*.spec.ts Browser tests (six files)

Most user-facing literature copy is written inline in the components above, not in content/. This departs from the engineering rule that copy lives in content files.

Paper identity and the Paper record

Paper (src/lib/literature/model.ts) is the bibliographic snapshot used everywhere: version: 1, id, title, authors[], journal, year, doi, pmid, pmcid, abstract, publicationTypes[], openAccess, retracted, citedBy, sourceUrl, fullTextUrl, licence, retrievedAt, and optionally provider: "OpenAlex", openalexId and abstractSource { provider, sourceUrl, retrievedAt }.

  • Primary id. <SOURCE>-<accession>, where the source is one of MED, PMC, PPR, AGR, CBA, CTX, PAT, ETH, HIR, NBK or OAX (identity.ts). The pattern is ^[A-Z]{2,6}-[A-Z0-9][A-Z0-9._-]{0,99}$; OAX- ids must be OAX-W<digits>. paperIdentity prefers MED-<pmid> whenever a PMID exists, so a paper seen first by PMCID or OpenAlex resolves to the same saved PubMed identity. parseOpenAlexPaper also uses MED-<pmid> when OpenAlex reports a PMID.
  • Object ref. paper:<id> (paperRef). paperObject adds aliases doi:…, pmid:…, the PMCID, and for MED- papers paper:PMC-… and paper:OAX-….
  • Sameness. samePaper is true when any of id, DOI (case-insensitive), PMID, PMCID or OpenAlex id agree. It is used for selection, evidence filtering and annotation lookup. Titles never establish identity.
  • Validation. readPaper treats stored or recovered data as untrusted: it checks every field's type, re-derives sourceUrl from the id (Europe PMC https://europepmc.org/article/<SRC>/<acc> or https://openalex.org/W…), accepts only https: fullTextUrl values without credentials (safeHttps), and only reconstructs abstractSource URLs it recognises.
  • Text. plainText (europe-pmc.ts) turns provider markup into text: closing p/h1–h6 and br become blank lines, other tags are stripped, numeric and five named entities are decoded. Nothing from a provider is rendered as HTML.
  • Exports. citation gives a one-line reference. papersRis writes TY (UNPB for preprints, otherwise JOUR), TI, one AU per author, JO, PY, DO, UR and an N1 retrieval note including a retraction flag; line breaks inside values are collapsed so a title cannot inject RIS fields; abstracts are omitted.

UI structure and state

Page and tabs

LiteraturePage renders SuiteFrame → LiteratureDesk → LiteratureWorkspace. The header's title takes the workspace's subject heading behind the 4 px accent bar (D-0084); it shows two buttons with live counts: saved papers (useObjectList({ type: "paper", limit: 500 }), shown as 500+ at the cap) and review notes (notes holding a valid reviewAnnotation among the newest 1,000 notes). Tabs keepMounted holds four panels, so their state survives tab switches:

Tab Component Content
Search LiteratureSearch Search form, review toolkit, results
Saved papers SavedPapers (in literature-page.tsx) Newest 500 saved papers, filtered by text (filterSavedPapers), Study membership and reading status tag (reading:to-read, reading:reading, reading:read); RIS export of the filtered list
Review notebook ReviewNotebook Highlights and comments across papers; search, type filter, Markdown export
Evidence EvidenceTable Evidence comparison across all findings or one Study; CSV export

filterSavedPapers matches exact DOI, PMID, PMCID and OpenAlex identifiers, then substring matches over title, authors, journal and identifiers, then (only for letter-only queries of at least 5 characters in which every word has at least 5 characters) adds Fuse.js matches (threshold: 0.22, weights title 0.6, authors 0.25, journal 0.15).

The desk (selection, reader and dialogs)

LiteratureDesk (literature-desk.tsx) is keyed by the workspace scope id and scope epoch, so switching account or scope remounts it and discards selection and open dialogs. Its context (desk-context.tsx) exposes selected, toggle, read, extract and explore.

  • Selection. Up to 20 papers. Actions: Save selected (upserts each paperObject), Add selected to Study (an existing Study or a new one created with rt.createStudy; a retry reuses the Study created in the same dialog), Compare evidence (an EvidenceTable restricted to the selection), Find shared references (enabled for 2 to 5 papers), Export selected RIS, Clear selection. Each write checks that currentWorkspaceAccess().scopeId has not changed. A partial failure reports how many finished; re-saving does not duplicate papers because upsert is keyed by ref.
  • Reader. A Drawer containing PaperReader.
  • Dialogs. CitationBrowser, EvidenceEditor, the compare dialog and the bulk-add dialog.

Search form and results

LiteratureSearch keys LiteratureSearchContent by ${scopeId ?? "public"}/${scopeEpoch}; a scope change remounts it and its unmount aborts the pending request.

State in LiteratureSearchContent:

State Meaning
draft: LiteratureQuery The form, starting from DEFAULT_QUERY (field: "all", type: "all", no years, openAccess: false, excludeRetracted: true, sort: "relevance")
applied, results The last successful query and its results. A failed or cancelled attempt never replaces either.
cursors, page Provider cursor history for Previous/Next in provider-order searches
busy, error, retry In-flight state, last error and the attempt that Retry repeats
layout "list" or "grid" (two columns from 52rem container width)
controller The AbortController of the current request; a new run aborts the previous one

Mode buttons and the "Search mode" select set mode to keyword, semantic or discovery. Changing mode resets field to all and sort to relevance; semantic also forces provider: "openalex", syntax: "simple" and synonyms: false. In keyword mode a "Literature source" select chooses Europe PMC or OpenAlex. Filters: search field (all, title, author surname; keyword mode only), publication type, sort (keyword mode only), years, syntax (simple or advanced), open access only, exclude retractions, and Europe PMC vocabulary expansion (synonyms). maxLength is 2,000 for semantic, discovery and advanced syntax, otherwise 300; validateQuery enforces the same limits server-side.

Submission runs validateQuery in the browser first; a validation error is shown and no request is sent. searchLiteratureV2 then posts to /api/literature/search.

Results:

  • A run whose run.coverage is bounded-discovery (or a legacy result with limited: true) is paged in the browser: 20 papers per page sliced from the frozen hit list; no further request is made.
  • Otherwise Next uses results.nextCursor and Previous reuses the stored cursor for that page; each is a new request.
  • Each card shows "Why this paper appears" from hit.reasons (the lanes and ranks that retrieved it) and the abstract and full-text field sources with their reported licences. A notice appears when the displayed abstract came from a different provider from the primary record.
  • Hits with identifier conflicts render in a warning card without save actions and are excluded from "Export this page" RIS.
  • Per-lane status boxes show each provider's total or status, message and warnings. A partial-failure notice appears when any lane failed.
  • "Search provenance" shows executedQuery (one line per lane), retrieval time, run id, distinct record count, candidate limit, coverage, ranking and deduplication versions.
  • A reuse notice appears when the run was served from a cache or a shared in-flight request.
  • When the draft differs from applied, a notice says the results belong to the last successful search.

When no search has succeeded, a starter panel offers three examples ("Drug resistance" keyword, "Compare methods" semantic, "Explore a field" discovery). Clicking an example fills the form; it does not run the search.

Subject suggestions

For keyword and discovery modes with a field other than author, the final term or phrase of the query (activeSubject) drives suggestions:

  • Local (suggestLiteratureSubjects in concepts.ts): compounds (ChEMBL), conditions (Disease Ontology) and the protein seed rows (UniProt, human). Exact and prefix matches first (up to 8, shorter labels first), then up to 3 Fuse.js spelling suggestions (threshold: 0.2, minMatchCharLength: 4) only for letter-only input of 6 to 160 characters that is not a gene symbol and not all upper case. The index loads with the catalogues (useCatalogs) and is prepared after paint.
  • Remote (only while the input is focused): usePubchemSuggestions (3 to 120 characters, 250 ms debounce) and useUniprotSuggestions (2 to 120 characters, 200 ms debounce) call /api/search/compounds and /api/search/proteins with the active term in the query string. See Server and accounts for those routes.

Picking a suggestion calls insertSubject, which replaces only the active term, quotes labels containing whitespace, parentheses, quotes, colons, backslashes or a bare operator, and keeps earlier concepts and operators. It also sets draft.expansions to the picked subject's identity (entityId, source, terms: [label]). Any later edit of the query text clears expansions (set("query", …)).

expansions is provenance only. It is validated (at most 6 entries, each with at most 8 terms of 180 characters) and stored in the search definition and manifest, but neither provider compiler reads it; no alias is added to an upstream query automatically.

Strategy builder

StrategyBuilder holds up to 6 concept groups (CONCEPT_LIMITS), each with up to 8 alternatives of at most 160 characters, an optional name (not sent) and include toggles. When the first alternative is picked from a local subject, its catalogue aliases are offered as checkboxes. buildConceptQuery deduplicates alternatives case-insensitively (NFKC), quotes every alternative as a literal (escaping \ and "), joins alternatives with OR inside parentheses and included groups with AND, and refuses strategies over 2,000 characters. "Use this strategy" fills the search box and sets mode: "keyword", provider: "europe-pmc", syntax: "advanced", field: "all", sort: "relevance", with expansions for groups that came from a subject. It never runs the search.

Search assistant (Claude)

SearchAssistant checks availability with GET /api/literature/plan on mount. The user must tick "I agree to send the question above to Anthropic"; the consent is bound to the exact query text and lapses when the text changes. Generating also requires write access (canWrite) and at most 2,000 characters. The returned groups are editable; a second checkbox ("I have reviewed the concepts and alternative names") is required, and any edit clears it. "Use reviewed AI strategy" passes the buildConceptQuery result to the same applyStrategy as the builder, without expansions. Cancel aborts the browser request only; the copy warns that Anthropic may already have processed it.

Search history (saved runs)

SearchHistory builds a manifest from the current successful results with createSearchManifest. With a Study chosen and write access, "Save search to Study" first flushes pending workspace work (flushPendingWorkspaceWork), then creates a private note (createMine("note", { label: "Literature search · <query>" (≤200), data: { text: manifestText(...), literatureSearch: manifest } })) and adds it to the Study. A retry after a failed membership write reuses the created note. Saved runs (notes among the newest 500 that are members of the user's Studies) can be restored into the form (not rerun) or exported as <base>.search.json and <base>.ris. The manifest is never edited after creation; a rerun creates a new note.

Reader, full text and annotations

PaperReader shows the header, actions and reading status, the abstract as an AnnotatedPassage, a sidebar PaperNotebook, and PaperResearch (the extraction assistant and the full-text reader).

  • Full text. "Load available full text" posts the paper locator to /api/literature/fulltext. The reader shows a section filter, the licence text and URL, a partial-view notice when truncated, and each paragraph as an AnnotatedPassage with a "Record passage" button (disabled for paragraphs over 20,000 characters).
  • Highlights. AnnotatedPassage captures the native selection (one document-level selectionchange listener plus mouse, key and touch handlers), computes character offsets inside the passage element, and accepts the anchor only when the selected text equals text.slice(start, end) and is at most 8,000 characters. A "Highlight a passage without selecting text" form finds exact occurrences (up to 100) and lets the user choose which occurrence.
  • Records. AnnotationComposer validates with validateAnnotation, upserts the paper, creates or updates a mine:note with data: { text: annotationText(...), reviewAnnotation }, and links it to the paper with role mentions. A comment is limited to 4,000 characters; a paper-level comment needs no highlight and no Study.
  • Anchors. An abstract anchor stores source, text, start, end and section; a full-text anchor also stores blockId and context (paragraph text, PMCID, https://europepmc.org/articles/<PMCID>, section, licence, retrieval time). validateAnnotation refuses offsets that do not reproduce the quoted text from the stored snapshot and refuses forged source URLs.
  • Display. highlightRanges marks only anchors whose snapshot equals the text now displayed; overlapping ranges merge for display only. When an abstract has changed since the note was made, the note stays in the notebook with a notice and is not re-anchored.
  • Notebook export. reviewNotebookMarkdown writes drybench-review-notebook.md with passages as block quotes, sources, retrieval dates and full-text licences.

Citation browser

CitationBrowser posts { papers: [locator…], mode, page } to /api/literature/discover. One paper offers references, citing papers and related papers; 2 to 5 papers open in shared-references mode, where each result shows "Referenced by n of m selected papers". Pages hold 20 records. The dialog states that OpenAlex citation records can be incomplete and that an empty result does not show that no relevant papers exist.

Evidence editor and table

EvidenceEditor records a finding as a private note. Fields (EVIDENCE_FIELDS): research question; species, cell line or population; method or assay; intervention, concentration and exposure; finding (required); limitations. Each is at most 4,000 characters. The supporting passage (1 to 8,000 characters) has a source:

Source Check in validateEvidence
abstract The passage must occur in the paper's abstract after whitespace normalisation
fulltext The passage must occur in the stored paragraph context; the context's URL must be https://europepmc.org/articles/<PMCID>
external A pasted passage; a location (page, section, figure) of up to 500 characters is required; labelled for verification

When the draft came from Claude, assistance stores provider: "Anthropic", the model id, generation and review times and, per field, the original suggestion, quote, section, source and context; each stored quote is re-checked against its source on every read. Saving upserts the paper, creates or updates the note (data: { text: evidenceText(e), evidence }, label = first 120 characters of the finding), links it with mentions to the paper and to chosen saved objects (target, compound, condition, trial, product, experiment), removes stale mentions links, and optionally adds both paper and finding to a Study. A target that is not yet stored is upserted as: "carry" (D-0045).

EvidenceTable lists findings among the newest 1,000 notes, filtered by Study membership, by subject (incoming mentions links) or by a paper list, plus unrecorded papers from the Study or selection. It exports drybench-evidence.csv through evidenceCsv, whose columns include the model and per-field model suggestions; toCsv handles spreadsheet formula escaping.

Object pages

  • ObjectLiterature seeds a search: a trial's id, a target's gene or protein fact, a product's ingredients, or otherwise the label (first 300 characters). It does not run the search.
  • PaperDetails shows a saved paper from the store without a search request (the embedded extraction panel still makes its availability GET). For an unsaved id it calls searchLiterature (the v1 route) with the id as the query and excludeRetracted: false. Viewing does not save the paper.

Server routes

All literature routes declare runtime = "nodejs" and a maxDuration. Every response carries Cache-Control: private, no-store (privateHeaders or the route's own header object); /api/literature/search also sends Vary: Cookie. Errors are JSON { error: string }. failure() returns the message of a LiteratureError with its status and otherwise a generic 503; upstream response bodies are never returned.

Shared transport helpers (src/lib/literature/sources.ts)

Helper Behaviour
requestOrigin(request) new URL(request.url).origin. In local development only (NODE_ENV not production, no VERCEL, no WORKSPACE_SITE_URL, loopback hostname), it substitutes the Host header's loopback host and port, because Next normalises loopback IPs to localhost. Forwarded headers are never trusted.
requestBody(request, max = 16_000) Rejects a present but different Origin (403), reads at most max bytes, parses a JSON object, or throws 400 "Invalid literature request."
limitedText(response, max = 4_000_000) Refuses a Content-Length over max and streams with a running byte count, cancelling on overflow (413)
publicResponse(url, signal) Allows only https://api.openalex.org and https://www.ebi.ac.uk; deletes any api_key query parameter; for OpenAlex list requests (pathname === "/works") calls reserveSearch("openalex-keyword", "anonymous"); sends Authorization: Bearer $OPENALEX_API_KEY to OpenAlex only; redirect: "error", cache: "no-store", 18 s timeout. 404 → 404; 401, 403, 429 → 503; other non-OK → 502.
locator(value) Requires an id that normalisePaperId returns unchanged; keeps only well-formed doi, pmid, pmcid and openalexId
fetchPaper(locator) OAX- ids: resolveWork then parseOpenAlexPaper. Others: a Europe PMC search for the id (excludeRetracted: false), first result
resolveWork(locator) Resolves the primary id only (OAX- → W…; MED- → pmid:…; otherwise the paper's PMID or DOI from Europe PMC) and fetches https://api.openalex.org/works/<id>

Route summary

Route Method maxDuration Origin check Body limit Upstream hosts Allowance
/api/literature POST 30 s Mismatch rejected; missing accepted 12,000 characters, checked after reading the whole body www.ebi.ac.uk or api.openalex.org OpenAlex branch: 1 s gate per process plus reserveSearch. Europe PMC branch: none
/api/literature/search POST 30 s Mismatch rejected; missing accepted 40,000 bytes, streamed Both reserveSearch per lane
/api/literature/discover POST 60 s Mismatch rejected; missing accepted (requestBody) 16,000 bytes Both Only OpenAlex /works list calls are reserved
/api/literature/fulltext POST 45 s As requestBody 16,000 bytes Both (OpenAlex only for OAX- papers) None
/api/literature/extract GET 120 s None (GET); access gate applies n/a None None
/api/literature/extract POST 120 s Exact match required; missing rejected 16,000 bytes Europe PMC/OpenAlex, then api.anthropic.com reserveExtraction
/api/literature/plan GET 120 s None (GET); access gate applies n/a None None
/api/literature/plan POST 120 s Exact match required; missing rejected 16,000 bytes api.anthropic.com; Supabase RPC when hosted reserveExtraction and reserveSearchAssist
/api/study/summary POST 120 s Exact match required; missing rejected 400,000 bytes api.anthropic.com reserveExtraction

POST /api/literature (v1)

Caller in the app: searchLiterature in client.ts, used only by PaperDetails for an unsaved paper id. The tests in src/app/api/literature/route.test.ts also exercise semantic queries.

  1. Origin: a present Origin that differs from requestOrigin → 403.
  2. Body: await request.text() (whole body), then more than 12,000 characters → 400; non-object JSON → 400.
  3. validateQuery(body) → 400 with its message.
  4. cursor defaults to "*"; must be a non-empty string of at most 4,096 characters without control characters.
  5. OpenAlex branch when mode === "semantic" or the query is exactly OAX-W<digits>:
    • cursor must be "*" (400 otherwise).
    • Module cache semanticCache: key JSON.stringify(query), 5 minutes, 32 entries, oldest inserted evicted. Shared by all callers of the process.
    • Semantic only: one request per second per process (nextSemanticAt), else 429. The gate is set before the reservation.
    • reserveSearch("openalex-semantic" | "openalex-keyword", "anonymous"); denied → 429 with the reservation message.
    • GET openAlexUrl(query) with Accept: application/json and, when set, Authorization: Bearer $OPENALEX_API_KEY; timeout 18 s combined with the request signal.
    • 401, 403 → 503 and 429 → 429, with one message about allowance; 404 → an empty result; other non-OK → 502; exception → 503.
  6. Europe PMC branch otherwise: GET searchUrl(query, cursor) with Accept: application/json and, when OPENALEX_API_KEY is set, Authorization: Bearer $OPENALEX_API_KEY (line 55). 429 → 429; non-OK → 502; any exception, including an unreadable 200 body, → 503 "Europe PMC could not be reached". See Known gaps, defects and risks.

The result shape is LiteratureResults: papers, total, nextCursor, retrievedAt, source ("Europe PMC", "OpenAlex" or "Combined"), executedQuery, optional limited, run, warnings, skippedRecords.

POST /api/literature/search (v2)

The main search route.

  1. Origin: a present, mismatched Origin → 403.
  2. Body read through limitedText(new Response(request.body), 40_000); JSON parse; validateQuery.
  3. Cursor: as v1. Discovery mode requires "*" ("Discovery pages use the retrieved shortlist").
  4. Owner for allowances and the server cache: search:<sessionId> when sessionId matches ^[a-zA-Z0-9_-]{16,80}$, otherwise search:<randomUUID>. When WORKSPACE_ACCESS_MODE=account and verifiedWorkspaceAccount() returns a user, the owner is account:<user.id>. Errors in that lookup are swallowed and the anonymous owner is kept.
  5. executeSearch(query, cursor, request.signal, owner).
  6. If every provider outcome failed: 429 when any lane is rate_limited or budget_exhausted, otherwise 503, with { error, providers } and Retry-After from the first lane that supplied one.
  7. Errors: client abort → 499 with no body; failure after validation → 503 generic; validation failure → 400 with "Invalid search request." for JSON syntax errors or the error's own message.

The browser sends { ...query, cursor, sessionId }. sessionId is a random UUID per workspace scope, held in memory for the tab (searchSessions in client.ts).

POST /api/literature/discover

Body { papers: PaperLocator[], mode, page }. discover (discovery.ts):

  • 1 paper for references, citing, related; 2 to 5 distinct papers for shared; page an integer from 1 to 100; otherwise 400.
  • Overall timeout 45 s combined with the request signal.
  • Each seed is resolved with resolveWork (sequentially). Up to 2,000 ids per seed are read from referenced_works (or related_works for related); longer lists set truncated.
  • references, related and shared: ids are counted across seeds (for shared, kept only when count > 1), sorted by count then id, and the page's 20 ids are fetched with GET https://api.openalex.org/works?per_page=20&filter=openalex:W1|W2|…, then re-sorted to the ranked order.
  • citing: GET https://api.openalex.org/works?per_page=20&filter=cites:<W>&sort=publication_date:desc&page=<n>; total from meta.count.
  • Result { papers, total, page, hasMore, mode, seeds, sharedCounts, truncated, retrievedAt }; hasMore is false from page 100; truncated is also set when total > 2000.

POST /api/literature/fulltext

Body { paper: PaperLocator }. Returns a FullText: { paper, pmcid, title, blocks: { id, section, text }[], sourceUrl, licence, licenceUrl, retrievedAt, truncated }. See Full-text retrieval and licensing.

GET and POST /api/literature/extract

GET runs the access gate (extractionOwner; no origin check for GET) and returns { available, model, reason? }, where available is whether ANTHROPIC_API_KEY is set and model is ANTHROPIC_LITERATURE_MODEL or EXTRACTION_MODEL. The Study report panel also uses this GET as its availability check.

POST, in order: access gate (exact origin, then local or hosted account) → key present (503) → body (16,000 bytes) → locator(body.paper) → source is abstract or fulltext, section (optional) a string of at most 500 characters → model id matches ^claude-[a-z0-9.-]{1,80}$ (503) → reserveExtraction(owner) → for full text, fullText(paper) → boundedSource(...) from the server-fetched paper (fetchPaper), never from client-supplied text → anthropicMessage(extractionRequest(...)) → parseExtraction. The reservation's busy flag is released in finally.

GET and POST /api/literature/plan

createSearchPlanHandlers in src/lib/literature/search/assistant.ts, bound in the route to extractionOwner, reserveExtraction, reserveSearchAssist and anthropicMessage.

  • GET: access gate, then searchPlanAvailability. No allowance is reserved and no model is called.
  • POST, in order: access gate → body (16,000 bytes) → consent === true (400) → no keys other than query and consent (400) → validatePlanQuery (1 to 2,000 characters, no control characters, whitespace collapsed) → availability (503 with reason) → abort check (499) → reserveExtraction(owner) → reserveSearchAssist(owner); denied → 429 with Retry-After when known → abort check → one anthropicMessage call → parseSearchPlan. No automatic retry.

searchPlanAvailability reports unavailable when: no ANTHROPIC_API_KEY; the model id fails the pattern; ANTHROPIC_WORKSPACE_ID is set but not ^[a-zA-Z0-9_-]{1,120}$; or, outside local development, LITERATURE_SEARCH_SHARED_QUOTA is not 1 or the Supabase URL or service key is missing.

POST /api/study/summary

Not under /api/literature, but it uses the same gate, transport and per-process AI allowance. Body (at most 400,000 bytes) { packet, focus, consent: true }; focus is overview, evidence or handover; validateStudyPacket allows 1 to 200 sources with ids S1…, and at most 80,000 characters in total (STUDY_AI_CHARACTERS; 413 above it). See AI features.

Origin checks

The reviewer's claim that the literature POST routes accept requests without an Origin header is correct for four of the six POST handlers and wrong for two:

Handler Code Missing Origin Mismatched Origin
POST /api/literature route.ts line 17: if (origin && origin !== requestOrigin(request)) Accepted 403
POST /api/literature/search search/route.ts line 11, same pattern Accepted 403
POST /api/literature/discover requestBody, sources.ts lines 84 to 85, same pattern Accepted 403
POST /api/literature/fulltext requestBody, same Accepted 403
POST /api/literature/extract extractionOrigin, extraction-access.ts line 9: request.headers.get("origin") !== expected 403 403
POST /api/literature/plan extractionOwner → extractionOrigin, same 403 403
POST /api/study/summary Same 403 403

extractionOrigin compares with the WORKSPACE_SITE_URL origin when set, and also requires the request's own origin to equal it. The GET handlers of extract and plan skip the origin check by design (request.method !== "GET").

Browsers send Origin on cross-origin fetch and form POSTs, so a third-party web page cannot drive the four lenient routes from a visitor's browser. A non-browser client that omits Origin can call them freely. They carry no credentials and return public bibliographic data, so the exposure is the deployment acting as an open proxy for Europe PMC and OpenAlex, consumption of the shared anonymous OpenAlex allowance, and (for /search in account mode) cookies are not sent by such clients, so no account allowance is reachable this way. The existing tests check only the mismatched case for the lenient routes; no test pins either behaviour for a missing header.

Query validation and compilation

validateQuery (europe-pmc.ts)

Field Rule
mode keyword, semantic, discovery or absent
provider europe-pmc, openalex or absent
syntax simple, advanced or absent
synonyms boolean or absent; true refused for semantic, and for provider: "openalex" outside discovery
expansions At most 6; each entityId ≤ 200, source ≤ 100, 1 to 8 terms ≤ 180 characters
query Contains a letter or digit; ≤ 2,000 characters for semantic, discovery or advanced syntax, else ≤ 300
field, type, sort all/title/author; all/review/preprint/journal; relevance/newest/cited
from, to Empty or a four-digit year 1800 to 2199; from ≤ to
Mode constraints Semantic and discovery require field: "all" and sort: "relevance"; semantic refuses advanced syntax
Grammar For non-semantic queries other than a bare OpenAlex work id, searchExpression is run to surface grammar errors before any provider call

Boolean grammar (query-parser.ts)

  • Tokens: parentheses, quoted phrases with backslash escapes, TITLE:, AUTHOR:, AUTH: and AUTH_LAST: field prefixes (others are refused, for example JOURNAL:), terms, and the operators AND, OR, NOT (upper case only; lower-case words are ordinary terms).
  • Precedence NOT > AND > OR; adjacency is implicit AND.
  • Limits (QUERY_LIMITS): 4,096 characters, 200 tokens, 200 nodes, nesting depth 16. Control characters are refused.
  • Errors are QuerySyntaxError with a 1-based character position, an end offset and a code (empty, length, control, escape, quote, field, operator, group, complexity, unsupported).
  • With the author field and no operators, the whole input becomes one author phrase (a byline, not several co-authors).
  • hasAdvancedSyntax is true when the input has operators, parentheses or fields.

Europe PMC expression (searchExpression)

Whole identifiers take priority (only with field: "all"): a DOI (bare, doi: or a doi.org URL) → DOI:"…"; a PMID (digits, optional PMID:) → EXT_ID:<n> AND SRC:MED; a PMCID → PMCID:PMC…; a source-prefixed id such as PPR-… → EXT_ID:… AND SRC:…. Otherwise the grammar is compiled with every term quoted (TITLE:"…", AUTH_LAST:"…" or bare "…"). Filters are appended with AND:

(<term>)
AND FIRST_PDATE:[<from|1800>-01-01 TO <to|2199>-12-31]   when a year is set
AND OPEN_ACCESS:Y                                        when openAccess
AND NOT PUB_TYPE:"Retracted Publication"                 when excludeRetracted
AND SRC:PPR | PUB_TYPE:"Review" | PUB_TYPE:"Journal Article"   by type
<space>sort_date:y | sort_cited:y                        for newest | cited

searchUrl builds https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=<expr>&format=json&resultType=core&pageSize=20&cursorMark=<cursor> and adds synonym=TRUE|FALSE only when synonyms is defined.

OpenAlex compilation (providers.ts, query-parser.ts)

compileOpenAlexQuery requires every leaf to use one field (mixed TITLE and AUTHOR is refused as unsupported). Phrases, author terms, terms with whitespace and literal operator words are quoted; other terms have Lucene-special characters backslash-escaped. Filters: publication_year:<from|1800>-<to|2199>, is_oa:true, is_retracted:false, type:article (for "journal", with a warning that type classifications differ), type:review, type:preprint.

Lane Request
openalex-keyword `GET https://api.openalex.org/works?per_page=<20
openalex-semantic GET https://api.openalex.org/works?per_page=50&search.semantic=<query>&filter=…. Refused (as unsupported, before any request) for a cursor other than *, a non-all field, a non-relevance sort, Boolean syntax, or more than 2,000 characters
Exact identifier `GET https://api.openalex.org/works/<W…

The v1 route's openAlexUrl uses the same semantic parameters (search.semantic, per_page=50, the same filters) or /works/<W> for an OAX-W… query; for that singleton it does not apply the filters.

Exact identifiers

exactLiteratureIdentifier (field all only) recognises a DOI, a PMID (PMID:, MED- or bare digits up to 12), a PMCID (PMCID:, PMC- or bare), an OpenAlex work id (OAX-, URL or bare W…) and any other valid source id. An exact identifier always runs one lane, even in discovery or semantic mode: OpenAlex ids go to openalex-keyword, everything else to europe-pmc-keyword. A 404 for an exact lookup is an empty successful result, not an outage.

Search execution, merging and ranking

Lane plan (execute.ts)

Request Lanes
Exact identifier One: openalex-keyword for OpenAlex ids, otherwise europe-pmc-keyword
mode: "discovery" europe-pmc-keyword, openalex-keyword, and openalex-semantic unless the syntax is advanced or the query has Boolean syntax
mode: "semantic" openalex-semantic
Keyword openalex-keyword when provider: "openalex", else europe-pmc-keyword

Page size per lane (pageSize in providers.ts) is 50 (PROVIDER_CANDIDATE_LIMIT) for discovery and the semantic lane, otherwise 20. Lanes run in parallel. For each lane: reserve(lane, owner) (denied → a budget_exhausted outcome with no request), then searchProvider(query, lane, cursor, AbortSignal.timeout(20_000)), which adds its own 18 s timeout. The request's own abort signal is not passed to providers: a cancelled browser request leaves the provider call running so that coalesced callers and the cache still receive it.

Provider transport (searchProvider)

  • The compiled URL must be at most 4,094 bytes (MAX_URL_BYTES), else unsupported with no request.
  • Headers: Accept: application/json, plus Authorization: Bearer $OPENALEX_API_KEY for OpenAlex only. redirect: "error", cache: "no-store".
  • Responses are limited to 3,000,000 bytes (Content-Length and streamed).
  • Status mapping: 429 → rate_limited with a retryAfter from Retry-After (seconds or HTTP date, clamped to 0 to 3,600); 404 on an exact lookup → ok with total 0; 400 or 422 → unsupported; 401 or 403 → unavailable ("credentials or allowance"); other non-OK → unavailable; parse or network error → unavailable; timeout or abort → timeout.
  • Europe PMC records go through parsePaper; only byte-identical duplicates are removed at this stage, so conflicting identifiers survive until fusion. total is hitCount; nextCursor is nextCursorMark when it differs from the request cursor and at most 4,096 characters.
  • OpenAlex keyword requires a numeric meta.count and returns meta.next_cursor. Semantic returns total: null, no cursor, and a warning that it is a bounded shortlist of at most 50.
  • Each outcome records the exact executed expression (executedQuery) without credentials.

Identity graph and deduplication (rank.ts, validated-identifiers-v1)

  1. Every returned record becomes a node with normalised identifiers: DOI (strip doi: and doi.org URLs, decode, lower-case, ≤ 512 characters, 10.<4–9 digits>/…), PMID (strip pmid: and PubMed URLs, 1 to 12 digits), PMCID (strip pmcid: and PMC URLs, upper-case), OpenAlex (W…). The primary id contributes too (MED- → PMID, PMC- → PMCID, OAX- → OpenAlex), plus a record:<id> key.
  2. Union-find joins nodes that share any key; bridges are transitive. Titles, years and authors never create an edge, so preprints and published versions stay separate unless identifiers link them.
  3. A component in which any namespace holds more than one value is a conflict. It is split recursively by the first contradictory namespace; each resulting hit carries conflicts text ("Conflicting DOI values … Records are kept separate."). A record whose own identifiers contradict each other cannot be split and is flagged.
  4. Within a hit, records are ordered by canonicalOrder: MED- ids from Europe PMC, then MED- from OpenAlex, then PMC-, then other Europe PMC, then OpenAlex; ties by completeness (title, journal, year, authors, abstract, safe full-text URL) and then a stable key. The first record supplies the primary id.
  5. Merged fields: first non-empty title, journal, year and author list in that order; the first value of each identifier namespace; the longest abstract from one record (never stitched), with abstractSource naming that record; the union of publication types; retracted if any record says so; fullTextUrl, licence and openAccess taken together from the first record with a safe full-text URL (otherwise from the primary), so an open-access flag from one provider is never paired with another provider's URL or licence.
  6. fieldSources records the provider, source URL and licence of the abstract and full text.

Reciprocal rank fusion (rrf-60-v1)

Each hit keeps one contribution per lane at its best (lowest) rank. The score is:

score(hit) = Σ over lanes ℓ that retrieved the hit   1 / (60 + rank_ℓ)

There is no citation-count or recency boost. Hits are sorted by score descending, then by paper id, then by the JSON of the paper and of the contributions, which makes the order deterministic. reasons lists only the lanes and ranks that actually retrieved the hit.

Ordering, totals and paging

  • Bounded discovery (mode: "discovery" without an exact identifier): order is the RRF order; total is the number of distinct hits; nextCursor is null; candidateLimit = lanes × 50; rankingVersion = "rrf-60-v1"; coverage bounded-discovery. Provider totals are shown separately and are never added together.
  • Semantic (without an exact identifier): coverage bounded-discovery, candidate limit 50.
  • Provider search (single lane): hits are re-sorted by their best native rank, so the provider's order is kept; total and nextCursor come from the provider; rankingVersion = "provider-order-v1"; candidate limit 20.
  • source is Combined for more than one lane.

Server cache and coalescing

executeSearch keeps a module-level cache keyed by JSON.stringify({ owner, query, cursor, version: 2 }) for 5 minutes and 48 entries, and an in-flight map with the same key. Only runs in which every lane succeeded are cached. A cache hit or a coalesced wait returns reuseSearchResult: a deep copy with a fresh run.id, a new executedAt, reusedFrom (the original run id) and reuse: "cache" | "coalesced"; retrievedAt stays the original retrieval time. The cache and coalescing are per owner, so they never cross accounts or anonymous sessions. Both are per process.

Search run and manifest

SearchRun (types.ts): version: 2, id, definition (the validated query), retrievedAt, executedAt, requestedCursor, optional reusedFrom/reuse, coverage, providers (outcomes without papers), uniqueRetrieved, candidateLimit, rankingVersion, deduplicationVersion, hits.

createSearchManifest (manifest.ts) produces { version: 1, kind: "literature-search", strategy, run, limits, warnings }:

  • At most 150 hits (MANIFEST_HITS); omitted hits are counted and disclosed. run.uniqueRetrieved must equal stored plus omitted hits.
  • Hits keep a bibliography without abstract or abstractSource: title ≤ 1,000 characters, first 12 authors ≤ 200 characters each, journal ≤ 300, at most 8 publication types.
  • A successful legacy result (no run) gets adapterVersion: "legacy-literature-search", rankingVersion: "legacy-provider-order"; a Combined result without a run is refused.
  • readManifest revalidates everything from storage or a recovery file: at most 750,000 bytes (MANIFEST_BYTES), known lanes and statuses, provider/lane pairing, at least one successful lane, contributions only from successful lanes, identifier consistency through readPaper.
  • manifestRis excludes conflicted hits.
  • studySearchManifests feeds Study ZIP exports, which write search-runs/<run.id>.ris for each saved run alongside references.ris and evidence.csv (src/components/suite/objects/study-export.ts).

Quotas and rate limits

src/lib/literature/search/quota.ts exposes reserveSearch(lane, owner) and reserveSearchAssist(owner) (and a factory, createSearchQuota, used by tests). It stores no query text.

Owner key. accountOwner(owner) strips an account: prefix and, if the rest is a UUID, returns its SHA-256 hex digest. Anything else (every search:<session> owner, "local", "anonymous") becomes the single aggregate anonymous owner. Rotating session ids therefore cannot reset an allowance.

Lane or feature Local development (NODE_ENV not production, no VERCEL, no WORKSPACE_SITE_URL) Hosted
europe-pmc-keyword (v2 only) reserveFree: per process, per 60 s window, 30 calls per owner key and 120 in total Same (no shared storage)
openalex-keyword, openalex-semantic In-memory daily counters (UTC day): global LITERATURE_OPENALEX_DAILY_REQUESTS (default 500, 1 to 10,000), per owner LITERATURE_SEARCH_OWNER_DAILY_REQUESTS (default 100, capped at the global limit); semantic adds a global 1 s gap Supabase RPC reserve_literature_search_quota with the same limits; refused unless LITERATURE_SEARCH_SHARED_QUOTA=1 and the Supabase URL and service-role key are set
AI search assistance In-memory: LITERATURE_SEARCH_ASSIST_DAILY_REQUESTS (default 25, 1 to 1,000) and LITERATURE_SEARCH_ASSIST_OWNER_DAILY_REQUESTS (default 25) Requires an account owner, WORKSPACE_ACCESS_MODE=account and LITERATURE_AI_ENABLED=1, then the same RPC with provider anthropic-search-assist

Keyword and semantic OpenAlex calls share one daily allowance. Every accepted reservation counts, including attempts that then fail upstream. The hosted path fails closed: a non-OK response, a body over 4,096 bytes, malformed JSON, a 5 s timeout or an unrecognised reason all deny with "Shared search allowance could not be checked", and there is no fallback to local counters. RPC request: POST <SUPABASE_URL>/rest/v1/rpc/reserve_literature_search_quota with apikey and Authorization: Bearer <service key> and body { p_provider, p_owner, p_global_limit, p_owner_limit, p_semantic }.

The migration 202610090001_literature_search_quota.sql creates public.literature_search_quota (provider, owner_key, usage_day, calls, last_semantic_at) with row-level security on and access revoked from public, anon and authenticated. The security definer function validates its arguments, takes a transaction-level advisory lock per provider (pg_advisory_xact_lock(1909102026, 1|2)), reads clock_timestamp() after locking, resets counters on a new UTC day, returns { allowed: false, reason: "daily_limit", retry_after } or { allowed: false, reason: "rate_limited", retry_after } (semantic gate, preserved across midnight) or { allowed: true, reason: "ok" }. Only service_role may execute it. The design record states that the migration has not been applied to a live database. See Server, accounts and billing for the Supabase setup.

The AI allowance (reserveExtraction in extraction-access.ts) is separate and in memory, per process: one call in flight per owner, at least 10 s between starts per owner, and a daily limit LITERATURE_AI_DAILY_REQUESTS (default 25, 1 to 1,000) applied both per owner and to the process total. Failed calls count. Extraction, search plans and Study summaries all draw on it.

Caches

Cache Where Key Lifetime and size Scope
v1 OpenAlex results route.ts semanticCache JSON.stringify(query) 5 min, 32 Process, all callers
v2 results execute.ts cache owner, query, cursor, version 5 min, 48; only all-lane successes Process, per owner
v2 in-flight execute.ts active Same Until settled Process, per owner
Full text fulltext.ts cache PaperLocator.id 5 min, 12 Process, all callers
Browser v1 client.ts cache query + cursor 5 min, 20 Tab memory
Browser v2 client.ts v2Cache query, cursor, scope 5 min, 20; only all-lane successes; served through reuseSearchResult Tab memory
Suggestion hooks use-pubchem-suggestions.ts, use-uniprot-suggestions.ts lower-cased term 100 entries Tab memory

On a multi-instance deployment each instance has its own server caches and its own in-memory gates.

Full-text retrieval and licensing

fullText(locator) (fulltext.ts):

  1. Returns a cached value for the same id within 5 minutes.
  2. fetchPaper(locator). If the record has a PMID but no PMCID, a second Europe PMC lookup for MED-<pmid> supplies the PMCID (this is how OpenAlex records reach full text).
  3. PMCID from the record or a PMC- id; none → 404 with a message that the abstract and original link remain available.
  4. GET https://www.ebi.ac.uk/europepmc/webservices/rest/<PMCID>/fullTextXML through publicResponse; body at most 4,000,000 bytes.
  5. parseFullText:
    • Refuses more than 4,000,000 characters, any <!ENTITY, and any DOCTYPE with an internal subset; strips other DOCTYPE declarations; validates with XMLValidator; parses with fast-xml-parser (preserveOrder, attributes kept, values not coerced, declarations and processing instructions ignored, processEntities: true). No DTD is fetched.
    • Requires an article element. If article-meta lists pmc/pmcid ids, one must equal the requested PMCID ("belongs to a different paper" otherwise).
    • Licence gate. Collects license elements' text and the first xlink:href, href or nested ext-link URL under creativecommons.org/licenses/ or /publicdomain/. The article is readable only when that URL matches ^https?://creativecommons.org/(licenses/(by|by-sa)/|publicdomain/(zero|mark)/): CC BY, CC BY-SA, CC0 or the Public Domain Mark. Anything else, including NC and ND variants and articles with no machine-readable URL, returns 422 "This article's reuse licence is not supported by the in-app reader. Read it at Europe PMC instead."
    • Walks the abstract(s) and then body. Skips ref-list, table-wrap, fig, supplementary-material, script and style; drops inline-graphic, tex-math and mml:math inside text. Each non-empty p becomes a block p1, p2, … with its section path (Methods › Cell culture); whitespace is collapsed. At most 600 blocks and 240,000 characters; the rest sets truncated. Depth over 80 is refused. No body → 404.
  6. Returns sourceUrl: https://europepmc.org/articles/<PMCID>, licence (licence text, or the URL when there is no text) and licenceUrl.

What the code does with licensed text: it displays paragraphs as plain text with the licence and source link; it stores paragraph snapshots (up to 20,000 characters each) inside annotations and evidence records with the licence string and retrieval date; it exports them in notebook Markdown and evidence CSV; and, on request, it sends up to 48,000 characters to Anthropic for extraction. CC BY-SA is share-alike. Whether these uses meet each licence's attribution and share-alike terms, and whether abstracts (which are displayed and sent to Anthropic regardless of article licence) may be used this way commercially, is held for the General Counsel. The provider terms of Europe PMC and OpenAlex are not recorded in the registry; see Data sources.

AI features

Access gate (extraction-access.ts)

extractionOwner(request):

  1. For non-GET requests, extractionOrigin: the Origin header must equal the expected origin (WORKSPACE_SITE_URL's origin, else the request's), and the request's own origin must equal it too.
  2. Local development (localExtraction: not production, no VERCEL, no WORKSPACE_SITE_URL, hostname localhost, 127.0.0.1 or [::1]) → owner "local", no account needed.
  3. Otherwise LITERATURE_AI_ENABLED must be 1 and WORKSPACE_ACCESS_MODE must be account (403 otherwise), and serverWorkspaceAccess() must return an account with workspace.write (403 otherwise). The owner is the account id.

Anthropic transport (anthropic.ts)

POST https://api.anthropic.com/v1/messages
Content-Type: application/json
x-api-key: $ANTHROPIC_API_KEY
anthropic-version: 2023-06-01
anthropic-workspace-id: $ANTHROPIC_WORKSPACE_ID      (only when set; must match ^[a-zA-Z0-9_-]{1,120}$)
redirect: error, cache: no-store, timeout 80 s combined with the request signal

No SDK is used. The response body is limited to 200,000 bytes. Errors: a 400 mentioning anthropic-workspace-id → "This API key needs a workspace…"; 429 → 429 "usage limit"; 401/403 → 503 "key could not be used"; other → 503. Provider error text is never returned or logged. There are no retries.

The default model is EXTRACTION_MODEL = "claude-haiku-5-5" (extraction.ts), overridable by ANTHROPIC_LITERATURE_MODEL (must match ^claude-[a-z0-9.-]{1,80}$). .env.example sets the same value. All three features send output_config: { format: { type: "json_schema", schema } } (structured output) and no thinking, tools or temperature parameters. Every parser requires stop_reason === "end_turn"; a refusal or a max_tokens stop yields 422 and nothing is saved.

Evidence extraction

  • Input bound. boundedSource uses the abstract as one block (id: "abstract"), or the full-text blocks of the chosen section. It keeps whole paragraphs only, skips paragraphs over 20,000 characters, stops adding at 48,000 characters (EXTRACTION_CHARACTERS), and sets truncated when anything was left out. No usable text → 422.
  • Request. { model, max_tokens: 3500, system, messages: [{ role: "user", content: JSON.stringify({ title, coverage, fields: EVIDENCE_FIELDS, blocks }) }], output_config }. The schema requires each of the six fields as { value, quote, blockId }. The system prompt tells the model to extract one coherent finding, to treat the title and blocks as untrusted data and ignore instructions in them, to use only the supplied blocks, to give a verbatim contiguous quote and its block id per field, to leave unsupported fields empty, not to invent limitations, causality or clinical advice, and to keep values and quotes within 1,500 characters.
  • Parsing. parseExtraction joins text blocks, parses JSON, and for each field requires a known blockId, a non-empty value ≤ 4,000 characters and a quote ≤ 8,000 characters that occurs in that block after whitespace normalisation. Failing fields are dropped with a warning; a missing finding → 422. Full-text supports carry the paragraph context. The result includes characters, truncated, warnings and usage (input and output tokens).
  • Human review. The UI requires a review checkbox before "Review in evidence editor"; the user edits and saves through EvidenceEditor, which keeps the original suggestions in assistance.

Search-strategy suggestion

  • Request. { model, max_tokens: 2200, system, messages: [{ role: "user", content: JSON.stringify({ researchQuestion }) }], output_config } with schema { groups: [{ label, terms[] }] }. The system prompt asks for up to six essential concepts with up to eight literal terms each, warns against forcing every clinical-question element, forbids Boolean syntax in terms, citations, URLs, paper ids, medical advice and claims that a term or paper is verified, and treats the question as untrusted data.
  • Parsing. parseSearchPlan requires end_turn, only text content blocks, at most 20,000 characters, exactly one top-level key groups with 1 to 6 groups, each with exactly label and terms, 1 to 8 terms, labels ≤ 80 and terms ≤ 160 characters without control characters; duplicate terms are removed (NFKC, case-insensitive). Any violation → 422. The plan carries the warning that AI terms are unverified.

Study summary

buildStudyPacket (client side) turns a Study bundle into numbered sources from explicit projections only (Study notes and brief, member notes, papers, evidence without full assistance detail, note text, experiment objective/status/conclusion, file metadata, saved-work labels, recorded results and inputs through recordedProjectionText); prior Study reports, binary data and opaque state are excluded and every omission is disclosed as a warning. The user can deselect sources before sending. The request is { model, max_tokens: 5500, system, messages: [{ role: "user", content: JSON.stringify({ focus, title, coverageWarnings, sections, sources }) }], output_config } with four sections (overview, findings, gaps, next steps). parseStudySummary keeps at most 8 points per section and 8 citations per point, and drops any point whose citations do not name a supplied source with a quote found in that source's text. A reviewed summary can be saved as a note (data.studyReport) and reopened without a new call. studyReportHtml builds an offline, escaped report with its own default-src 'none' CSP for printing. See Storage and state for the Study bundle.

Cost controls

  • No AI call happens without an explicit button press; search plans and Study summaries also require a consent checkbox bound to the content.
  • Hosted AI is off unless LITERATURE_AI_ENABLED=1 and account mode are set, and needs a signed-in account with write capability.
  • reserveExtraction: per process, one in flight per owner, 10 s spacing, 25 per day by default, failed calls counted.
  • Search plans additionally need the shared Supabase allowance on hosted deployments.
  • Input bounds: 48,000 characters (extraction), 2,000 (plan), 80,000 (summary). Output bounds: 3,500, 2,200 and 5,500 tokens.
  • No retries anywhere; cancelling in the browser does not cancel the upstream call.
  • These are not a distributed spending cap for extraction and summaries. An Anthropic account budget is the only hard limit.

Environment variables

Variable Used by Default
OPENALEX_API_KEY route.ts, sources.ts, providers.ts Unset (anonymous OpenAlex allowance)
ANTHROPIC_API_KEY anthropic.ts, extract and summary routes, assistant.ts Unset: AI features report unavailable
ANTHROPIC_WORKSPACE_ID anthropic.ts, assistant.ts Unset
ANTHROPIC_LITERATURE_MODEL Extract and summary routes, assistant.ts claude-haiku-5-5
LITERATURE_AI_ENABLED extraction-access.ts, quota.ts Off
LITERATURE_AI_DAILY_REQUESTS extraction-access.ts 25 (1 to 1,000)
LITERATURE_SEARCH_SHARED_QUOTA quota.ts, assistant.ts Off
LITERATURE_OPENALEX_DAILY_REQUESTS quota.ts 500 (1 to 10,000)
LITERATURE_SEARCH_OWNER_DAILY_REQUESTS quota.ts 100 (≤ OpenAlex limit)
LITERATURE_SEARCH_ASSIST_DAILY_REQUESTS quota.ts 25 (1 to 1,000)
LITERATURE_SEARCH_ASSIST_OWNER_DAILY_REQUESTS quota.ts 25 (≤ assist limit)
WORKSPACE_ACCESS_MODE, WORKSPACE_SITE_URL, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, NODE_ENV, VERCEL Access mode, origin, quota storage, local detection See Deployment and environment

Out-of-range numeric values fall back to the defaults. All are server-only; none is read in browser code.

Data stored in the browser

Nothing in the literature code uses localStorage, sessionStorage or IndexedDB directly. Two kinds of browser state exist:

  • Tab memory: the search caches and session ids in client.ts, the suggestion caches, and React state. Lost on reload. Research queries are never written to localStorage (comment in client.ts). The URL holds a query only when the user follows a #q= link; the fragment is not sent to the server.
  • The workspace object store (IndexedDB, with a memory fallback that the UI announces): see Storage and state. Literature writes:
Record Type and ref data
Saved paper paper:<id> (public-object ref) { paper }; reading status as mine.tags (reading:*)
Highlight or comment mine:note { text, reviewAnnotation }, mentions link to the paper
Evidence mine:note { text, evidence }, mentions links to the paper and chosen objects
Search run mine:note { text, literatureSearch: SearchManifest }, Study membership
Study report mine:note { text, studyReport }

Readers revalidate every record (readPaper, readAnnotation, readEvidence, readSearchManifest, readSavedStudyReport) and ignore invalid data. Study recovery bundles carry papers and evidence and remap links (src/lib/literature/persistence.test.ts); unrelated annotations are excluded from a Study's bundle. Lists read the newest 500 papers and 1,000 notes, and the UI says so at the cap.

Privacy: what leaves the browser

Trigger Data sent First hop Onward to
Typing in the search box (focused, keyword/discovery, not author field) The final term or phrase only, in a GET query string, after 200 to 250 ms The suite server (/api/search/compounds, /api/search/proteins) PubChem autocomplete, UniProt REST. Those responses are publicly cacheable, so CDN logs may hold the term.
Search Query text, filters, expansions, cursor, a random tab session id (POST body) The suite server Europe PMC and/or OpenAlex receive the compiled expression or the question text and filters (in their URLs). In account mode the account id becomes the allowance owner and is stored only as a SHA-256 hash in Supabase.
Opening an unsaved paper page The paper id The suite server (/api/literature) Europe PMC or OpenAlex
Full text, citations Paper identifiers only The suite server Europe PMC, OpenAlex
Suggest evidence Paper identifiers, source choice, section name The suite server, which fetches the paper's public text itself Anthropic receives the title and up to 48,000 characters of abstract or licensed full text. No notes or Study data.
Suggest strategy The question text (≤ 2,000 characters) after explicit consent The suite server Anthropic
Generate Study summary The selected Study source texts (≤ 80,000 characters) after explicit consent, including the user's private notes, evidence and recorded results The suite server Anthropic

No literature route logs request bodies or caches AI responses. Server search caches hold results (including the executed query text) in process memory for up to 5 minutes. Anthropic's handling and retention of the request content is governed by the account's terms with Anthropic and is not configured in this code. The search-form copy states what is sent; the Study summary and strategy consent checkboxes name Anthropic.

Benchmark evidence

The offline evaluation lives in src/lib/literature/search/evaluation/ and is described in docs/design/literature-search-benchmark.md.

  • benchmark.ts, version literature-search-2026-10-09-v1: 60 cases in seven buckets (10 identifier formats, 10 synonyms, 10 multi-concept, 10 questions, 8 Boolean/field strategies, 6 typos and symbols, 6 edge cases, five of which expect a specific error code). Every 3rd case in a bucket (index % 3 === 2) is holdout: 18 holdout, 42 development. Every case has relevanceStatus: "unjudged".
  • The identifier bucket uses ten records (verifiedKnownRecords) taken from one Europe PMC request (SRC:MED AND TITLE:CRISPR AND FIRST_PDATE:[2024-01-01 TO 2024-12-31]) on 9 October 2026 per the file comment. All ten are CRISPR papers.
  • metrics.ts: precision@k (grade > 0 relevant, denominator k), nDCG@k (gain 2^g − 1, discount log2(rank + 1), ideal from the whole judged pool), known-item recall@k, duplicate and conflict rates, nearest-rank percentiles. A window with any ungraded candidate is unjudged, never zero.
  • evaluate.ts: freezeSearchRun converts a SearchRun to a fixture without query text; evaluateRuns reports quality, splits, functional recovery, failures and reported latency/cost, each with coverage counts.
  • cli.ts: node src/lib/literature/search/evaluation/cli.ts --runs runs.json [--judgments j.json] [--out report.json]. Offline only; refuses to overwrite an output file; inputs ≤ 5 MB.

No run fixtures or human judgments are in the repository. Search relevance has not been measured, and no relevance or recall figure exists for either provider or for the fused ranking. The RRF constant 60 and equal lane weights are untuned defaults. The design record reports one live unpaid Europe PMC Boolean request (20 records and a continuation cursor) as an execution check, not a quality measurement.

Tests

Unit tests (Node's test runner, part of npm test; 143 tests across 21 files, all passing when run on 10 October 2026 with node --test over the files below):

File Tests Covers
src/lib/literature/literature.test.ts 9 Identifier expressions, validation, provider parsing, paging termination, refs, saved metadata, RIS injection
src/lib/literature/evidence.test.ts 5 Semantic URL/filters and key handling, OpenAlex provenance, evidence passage rules, CSV formula protection, identity merge
src/lib/literature/annotations.test.ts 5 Anchors, repeated and overlapping highlights, full-text provenance, store links and tombstones
src/lib/literature/research.test.ts 6 Full-text parsing, fabricated-quote rejection, request contents, discovery, host and size limits, AI access defaults
src/lib/literature/persistence.test.ts 2 Study recovery of papers and evidence
src/lib/literature/study-summary.test.ts 4 Packet projection, size limits, citation checks, report escaping
src/lib/literature/search/*.test.ts 87 Parser, concepts, providers, execution, ranking, quota, manifests, assistant, saved papers, reuse
src/lib/literature/search/evaluation/metrics.test.ts 10 Benchmark shape and metric arithmetic
src/app/api/literature/**/route.test.ts 15 Route validation, provider failure mapping, semantic cache and gate, mismatched origins, AI gating, no automatic retry

Browser tests in e2e/literature/ (17 cases in six files) run in the desktop, phone and desktop-dark Playwright projects. They intercept every literature API route (page.route) with fixtures, so no live provider or Anthropic call is made. Coverage: search, save, Study membership, notes and reading status across reloads; filters, cursor paging, retry and empty results; seeded object searches; concept-group strategies; combined retrieval with partial failures and frozen paging; error preservation of previous results; search history save, restore and exports; Claude strategy consent and re-review; citations; full-text passages and reviewed drafts; highlights and the notebook; evidence and Study exports; the Study report. These were not run for this page. See Build, test and CI.

Not covered by any test: the Authorization header on the v1 Europe PMC branch; requests with no Origin header on the lenient routes; the Supabase RPC against a real database; any live Anthropic response (the design records state that live generation was pending ANTHROPIC_WORKSPACE_ID and that Claude behaviour is fixture-tested only); live provider drift.

Design documents and divergences

Document Status
docs/design/literature-workspace.md (7 October 2026) Partly superseded. It says keyword, title and author search use Europe PMC only, and that the only OpenAlex controls are the per-process cache and 1 s gate. The v2 route adds OpenAlex keyword search and the shared daily allowance. Its extraction, full-text and Study summary sections match the code.
docs/design/literature-search-v2.md (9 October 2026) Implemented with a smaller design: one parser module instead of the proposed query.ts, plan.ts, identity.ts, session.ts and per-provider modules; no Chevrotain. Proposed but not implemented: owner-scoped opaque session cursors in shared storage and rejection of cursor replay across plans or sessions (provider cursors are accepted from the client as-is); "Why this matched" from actual term matches (only lanes and ranks are shown); a zero-result draft advisor; PubMed and Semantic Scholar adapters.
docs/design/literature-search-benchmark.md Matches benchmark.ts, metrics.ts, evaluate.ts and cli.ts.
docs/design/literature-review-workspace.md (in Git history) Retired on 17 October 2026: it matched the annotation, notebook and library code, which this page now describes in full ("Reader, full text and annotations").
docs/design/research-review-increment.md Describes the Study packet's explicit projections, which buildStudyPacket implements; it records that runtime checks for that increment were pending.

Known gaps, defects and risks

  1. OpenAlex key sent to EMBL-EBI. Confirmed. src/app/api/literature/route.ts:54-55 fetches searchUrl(...) (always https://www.ebi.ac.uk/europepmc/webservices/rest/search) with Authorization: Bearer ${OPENALEX_API_KEY} whenever that variable is set. The OpenAlex credential is disclosed to a third party on every v1 Europe PMC search. In the app this branch is reached from PaperDetails for an unsaved, non-OAX paper id, and by any direct caller. The v2 transport (providers.ts:120) and publicResponse (sources.ts:38) send the key to OpenAlex only. Fix: drop the header from the Europe PMC branch and add a test; rotate the key if it has been set on a deployment.
  2. Missing Origin accepted on four POST routes. Confirmed for /api/literature (route.ts:16-17), /api/literature/search (search/route.ts:10-11), and /discover and /fulltext through requestBody (sources.ts:84-85). Not true for /extract, /plan and /api/study/summary, which require an exact match (extraction-access.ts:9). See Origin checks for impact.
  3. No rate limit on the v1 Europe PMC branch. route.ts:53-63 calls Europe PMC without reserveFree or any gate, unlike the v2 lane.
  4. Shared anonymous burst bucket. reserveFree (quota.ts:51-54) keys every non-account caller as anonymous: 30 Europe PMC searches per minute per process for all anonymous users together. One client can block anonymous search on an instance for up to a minute. Similarly all anonymous callers share one OpenAlex owner allowance (default 100 per day) on hosted deployments.
  5. Unmetered OpenAlex singleton calls. publicResponse reserves only for pathname === "/works" (sources.ts:34-37). resolveWork (sources.ts:51) fetches /works/<id> without a reservation: up to five per discover request, plus one per full-text or extraction request for an OAX- paper. Whether OpenAlex bills these is not verified here.
  6. AI allowance is per process and not per user in effect. reserveExtraction (extraction-access.ts:26-30) applies the same limit to each owner and to the process total, so one account can use the whole daily allowance of an instance, and each instance has its own. The plan route reserves an extraction unit before the shared assist allowance and does not refund it when the shared allowance denies (assistant.ts:96-98).
  7. Possible incompatibility with the default model. Not verified against a live response. claude-haiku-5-5 is a current model id. According to Anthropic's current API documentation, Claude Haiku 5.5 runs adaptive thinking by default when thinking is omitted and returns thinking content blocks (with empty text by default). parseSearchPlan rejects any non-text block (assistant.ts:35), so every plan could fail with 422; extraction and summary parsers filter for text blocks and would not fail for that reason, but thinking tokens count towards the 2,200, 3,500 and 5,500 max_tokens limits and a max_tokens stop is treated as unusable. A live check is needed before enabling AI features.
  8. Misleading error copy. An oversized /search body returns 400 "The source document is too large to load here." (search/route.ts:14 with sources.ts:21-24). An unreadable Europe PMC 200 response on v1 returns 503 "Europe PMC could not be reached" (route.ts:59-62).
  9. v1 body read before the size check. route.ts:20-21 reads the whole body into memory, then checks 12,000 characters; the platform's body limit is the only bound. v2 and the other routes stream with a byte limit.
  10. v1 semantic gate consumed before reservation. route.ts:36-38 sets the 1 s gate before reserveSearch; a denied reservation still blocks semantic requests for a second.
  11. expansions is provenance only. Picking a second subject replaces the first expansion (literature-search.tsx:162) and any keystroke clears it (literature-search.tsx:72), so a saved manifest may not list every subject the user picked. No alias is ever compiled into a query.
  12. Cursors are not bound to a plan or owner. Design item not implemented (search/route.ts:18-19). Impact is limited to public data.
  13. Per-process state. All caches and the v1 semantic gate, the Europe PMC burst guard and the AI allowance live in memory; serverless instances do not share them.
  14. Shared quota not deployed. Hosted OpenAlex lanes and hosted AI planning stay closed until the migration is applied and LITERATURE_SEARCH_SHARED_QUOTA=1 is set; the design record states the migration has not been applied to live Supabase.
  15. Registry and compliance gaps. Europe PMC, OpenAlex and Anthropic are contacted server-side but are not declared as hosts or data sources in content/apps.ts, and no licence id or licence-read date is recorded for them. The CSP is unaffected (server-side calls), but the project rule that every source carries a licence id and date is not met. Licence questions in Full-text retrieval and licensing are held for the General Counsel.
  16. Copy in components. Nearly all literature copy is inline in src/components/suite/literature/* and the route handlers, contrary to the engineering rule that copy lives in content files.
  17. Search quality unmeasured. No human relevance judgments exist; the identifier set is narrow (CRISPR, 2024). No claim about ranking quality or recall is supported.
  18. Bounded reading views. Lists inspect the newest 500 papers and 1,000 notes; the full-text reader stops at 600 paragraphs and 240,000 characters; tables, figures, formulas and supplements are never shown. Each limit is disclosed in the UI.

Extending safely

  • A new provider or lane. Add the lane to SearchLane in types.ts, the lane list and status checks in manifest.ts (lanes, provider pairing), lane planning in execute.ts, transport in providers.ts (fixed host, redirect: "error", byte limits, a timeout, typed status mapping, executedQuery without credentials) and labels in literature-search.tsx and rank.ts. Decide whether it needs a reservation in quota.ts (paid providers must fail closed). Send any credential only to its own host, in a header. Bump RANKING_VERSION or DEDUPLICATION_VERSION when behaviour changes, because saved manifests record them.
  • Changing ranking or identity. Keep identifier-only edges and conflict splitting; saved notes link by paper:<id> and a wrong merge would attach them to a different paper. Bump the version strings, and extend rank.test.ts. Measure with the benchmark CLI on frozen runs before changing defaults.
  • Query features. Extend the grammar in query-parser.ts with diagnostics and limits, and both compilers; refuse what a provider cannot represent instead of dropping it. Run validateQuery in the browser and server.
  • Stored shapes. Paper, ReviewAnnotation, Evidence, SearchManifest and SavedStudyReport are version 1 and validated on every read, including recovery files. Add optional fields with defaults, or bump the version and keep a reader for version 1. See Storage and state.
  • AI features. Route every model call through anthropicMessage and extractionOwner, reserve before calling, require explicit user action (and consent where private text is sent), send only server-fetched public text or user-approved text, use structured output, verify every quoted passage against the supplied text, and never retry automatically. Test with injected dependencies as assistant.test.ts does, then make one live check before enabling the feature, including the model's thinking behaviour.
  • Fix items 1 and 2 above first. For the origin checks, either require an exact Origin (as extractionOrigin does) or accept a missing header only together with a server-side rate limit; add tests for both cases.
  • Copy and hosts. New copy belongs in content/; a new upstream host or data source should be recorded with its licence id and the date its terms were read. See Registry and content and Data sources.
  • Run npm run lint, npm test, npm run build, npx tsc --noEmit and, for visible changes, npm run test:e2e.

Scientific audit correction — passage/citation identity (11 October 2026)

Full-text evidence, every nested AI field support, and full-text annotation anchors now require the retained context PMCID to match the cited Paper PMCID (or a PMC-backed Paper ID when no PMCID field is available). A valid Europe PMC URL and a matching quotation alone do not prove attribution to the cited paper. Conflicting or unresolved identities fail closed with an explicit error; no automatic reassignment to another paper occurs. Existing stored objects are not mutated. Such objects must be recovered from their stored/exported record and attributed correctly, or deliberately recorded as user-supplied external passages. Abstract/external passage semantics are unchanged. Regression tests cover main and nested mismatches, unknown identity, valid PMC-backed identity, and annotations. This is an identity check, not verification of an author's scientific conclusions or a user's interpretation.

Canonical workspace route (16 October 2026)

Literature opens at /literature/app. /literature redirects permanently, including legacy #q= search fragments. literatureHref supplies all new links and the marketing footer lists the shared tool from the registry. Both routes retain the workspace security policy; the tool is not falsely registered as a scientific app with an engine or task pages.