ai4l-library 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +24 -6
  2. package/README.md +144 -12
  3. package/dist/index.js +3632 -232
  4. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,13 +1,31 @@
1
- # AI4L Library - Change Log
1
+ # AI4L Library — Change Log
2
2
 
3
+ ### v0.1.4 — 2026-11-07
4
+
5
+ * `.env` is the whole configuration: every value an environment variable, the tier set and the defaults built in. `library/config.json` and `AI4L_LIBRARY_CONFIG` are gone, and with them `${NAME}` references and the per-tier `description`.
6
+ * Renamed, no aliases — `CONTACT_EMAIL` → `AI4L_LIBRARY_CONTACT_EMAIL`; `ELSEVIER_API_KEY` → `AI4L_LIBRARY_ELSEVIER_API_KEY`; `WILEY_TDM_TOKEN` → `AI4L_LIBRARY_WILEY_TDM_TOKEN`; `CORE_API_KEY` → `AI4L_LIBRARY_CORE_API_KEY`; `PERPLEXITY_URL` / `PERPLEXITY_API_KEY` / `PERPLEXITY_MODEL` → `AI4L_LIBRARY_VERIFY_URL` / `_API_KEY` / `_MODEL`; config.json's `threshold`, `timeout_seconds`, `retry_after_days`, `retry_blocked`, `refresh_after_days`, `download.headless` → `AI4L_LIBRARY_VERIFY_THRESHOLD`, `AI4L_LIBRARY_VERIFY_TIMEOUT_SECS`, `AI4L_LIBRARY_RETRY_AFTER_DAYS`, `AI4L_LIBRARY_RETRY_BLOCKED`, `AI4L_LIBRARY_REFRESH_AFTER_DAYS`, `AI4L_LIBRARY_BROWSER_HEADLESS`. `NCBI_API_KEY`, `BRIGHTDATA_API_TOKEN`, `BRIGHTDATA_ZONE` and `AI4L_LIBRARY_BROWSER_n` unchanged.
7
+ * `AI4L_LIBRARY_TIERS` is the whole ladder: the listed tiers in exactly that order, a tier not listed is off (an unlisted tier no longer runs after the listed ones).
8
+ * One proxy tier, `proxy`, instead of url tiers: `AI4L_LIBRARY_PROXY_URL` (template with `{doi}` `{pmid}` `{pmcid}`) and `AI4L_LIBRARY_PROXY_HEADERS`; `PROXY_TOKEN` and the `my-proxy` example are gone.
9
+ * `library_info`: `config` replaces `tiers_config` — the cascade's settings, problems with `AI4L_LIBRARY_TIERS`, and per variable where its value came from (environment, `.env`, default), never the value; `tiers` in run order without descriptions (the README describes them).
10
+ * The container's working directory is `/library`; the configuration arrives as its environment.
11
+
12
+ ### v0.1.3 — 2026-11-07
13
+
14
+ * IDs: `pmid:` (bare digits count as one), `doi:` for papers PubMed does not index (a DOI PubMed indexes is stored under its PMID and copied under the DOI, so every tool takes either), `dailymed:<set ID>` drug labels, `nct:` trial records.
15
+ * `library_add(id)`: full text through a configurable cascade (`config.json`: inbox, pmc, europepmc, tdm, unpaywall, openalex, core, url tiers) and a download ladder (plain fetch, browser rungs, Bright Data); every text checked (title, methods and results, no wall page) before it counts; failed tiers recorded in `attempts`, transient ones retried after `retry_after_days`; PubMed record re-read after `refresh_after_days` for correction notices and retractions (`citable`, `corrections`).
16
+ * `library_read(id, section?, query?, max_chars?, max_hits?)`: the outline, one section, or the paragraphs matching a query — never the whole paper.
17
+ * `library_verify(claim, id)`: is one finding stated by one source, judged on the stored text by the Perplexity Decisions API; `superseded` when a stored correction replaced the figure; `glyphs_unresolved` for symbols a PDF font gave no character for.
18
+ * `library_info(ids?)`: the library's directory, tiers, download ladder, verify model health, entries by type and level.
19
+ * PDF conversion: symbols restored from the font's glyph names (minus, ±, ≤, Greek letters), words broken at line ends joined back.
20
+ * `library.log`: one line per action, host-prefixed.
21
+ * Environment variables renamed `LIBRARY_*` → `AI4L_LIBRARY_*` (`AI4L_LIBRARY_DIR`, `AI4L_LIBRARY_CONFIG`, `AI4L_LIBRARY_ENV`, `AI4L_LIBRARY_TIERS`, `AI4L_LIBRARY_BROWSER_n`, `AI4L_LIBRARY_TUI_STATUS_SECS`); the old names are not read.
22
+ * `.env` is also looked for in the library directory when neither `AI4L_LIBRARY_ENV` nor `./.env` names one.
23
+ * Entry files are written to a temporary name and renamed into place (several servers may share one library); `library.log` rotates to `library.log.1` past 50 MB.
3
24
 
4
25
  ### v0.1.1 — 2026-10-26
5
26
 
6
- * `library_add(id)` and `library_status(id)` for `pmid:` IDs: the `pubmed` tier fetches the efetch XML and writes `-abstract.xml`, `-abstract.md` and `-meta.md` under `<type>/<L1>/<L2>/<number>/`; entries are level `abstract` and idempotent
7
- * Status JSON per call; invalid IDs, unsupported types, unknown PMIDs and fetch failures are returned as `error`, never thrown
8
- * Library directory: `LIBRARY_DIR`, else `/library` when mounted, else `./library`
9
- * Unit tests (`npm test`) and an opt-in live PubMed test (`LIVE=1`)
27
+ * `library_add(id)` and `library_status(id)` for `pmid:` IDs.
10
28
 
11
29
  ### v0.0.1 — 2026-10-26
12
30
 
13
- * Project skeleton: stdio MCP server with `get_version`; publishing scaffold (dev/public npm, MCP registry, GitHub release)
31
+ * Project skeleton.
package/README.md CHANGED
@@ -1,27 +1,162 @@
1
- ![Version](https://img.shields.io/badge/Version-0.1.1-green.svg)
1
+ ![Version 0.1.3](https://img.shields.io/badge/Version-0.1.4-green.svg)
2
+ ![License MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
2
3
  [![Forever Healthy](https://img.shields.io/badge/(c)_2026-Forever_Healthy-573D7D.svg)](https://forever-healthy.org)
3
4
  [![npm](https://img.shields.io/badge/npm-ai4l--library-cb3837.svg)](https://www.npmjs.com/package/ai4l-library)
5
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-io.github.forever--healthy%2Fai4l--library-1f6feb.svg)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.forever-healthy/ai4l-library)
6
+ [![Glama score](https://glama.ai/mcp/servers/forever-healthy/AI4L-library/badges/score.svg)](https://glama.ai/mcp/servers/forever-healthy/AI4L-library)
4
7
 
5
- # AI4L Library MCP Server
8
+ ![AI4L – AI for Practical Longevity](https://raw.githubusercontent.com/forever-healthy/AI4L-library/main/docs/AI4L-Header.png)
9
+
10
+ **AI4L Library** - MCP server that builds a local library of the scientific papers an evidence review cites, part of [AI4L](https://github.com/forever-healthy/AI4L)
6
11
 
7
12
  A [Model Context Protocol](https://modelcontextprotocol.io) server that builds a local library of the scientific papers an evidence review cites. Given a paper ID (`type:value`, e.g. `pmid:30955454`), it fetches the paper's metadata, abstract and — where obtainable — full text through a configurable cascade of retrieval services (PubMed, PMC, Europe PMC, Unpaywall, OpenAlex, …), converts everything to Markdown, and stores it under a mounted `library/` directory. Nothing flows through the model's context: the server writes to disk itself and returns only a small status per call.
8
13
 
9
- **Status: v0.1, early development.** `library_add` and `library_status` work for `pmid:` IDs through the PubMed tier only, so every entry is level `abstract`. The full-text cascade and `library_read` are next.
14
+ **Out of the box, keyless, everywhere:** PubMed, PMC, Europe PMC, Unpaywall and OpenAlex, a plain fetch, and search inside stored papers. Everything stronger is an endpoint or a key the deployment adds in `.env` — browser rungs (Playwright servers, a real Chrome), Bright Data, CORE, the Elsevier and Wiley text-mining tokens, your own proxy — each with an honest fallback when absent; `library_info` shows what is configured.
15
+
16
+ **Status: v0.2, early development.** Four ID types: papers by `pmid:` and, when PubMed does not index them, by `doi:` (record from Crossref); drug labels by `dailymed:`; trial records by `nct:`. Every paper entry gets metadata and abstract from PubMed (or Crossref and OpenAlex); the full-text cascade then tries PMC (JATS XML), the publishers' text-and-data-mining links (Crossref; Elsevier and Wiley with a token), and open-access copies located via Unpaywall and OpenAlex (PDF, XML or HTML). A full text counts only after checks: the paper's title or DOI in the text, 800+ words of running prose (a publisher's abstract page has 200–600), Methods and Results or a sectioned body, no wall page. `library_read` serves outline, section or search passages for claim verification. Papers whose PMC XML body is withheld by the publisher are reachable only through open-access copies or a licensed proxy; PMC's web pages are for humans and are not scraped. What follows: better PDF layout.
10
17
 
11
18
 
12
19
  ## Tools
13
20
 
14
- * `library_add(id, url?)` → build or complete the library entry for one ID; returns its status (level `full_text` / `abstract`, retrieving tier, retraction flag, files). Idempotent. One ID per call.
15
- * `library_status(id)` → read-only check of presence and level
16
- * `library_read(id, section | query)` → one passage of a stored paper's Markdown — the deliberate, small flow through context for claim verification (planned)
21
+ * `library_add(id)` → build or complete the library entry for one ID — a paper `pmid:<PMID>` or `doi:<DOI>` (see "Papers without a PMID"), a drug label `dailymed:<set ID>` (see "Drug labels"), or a trial record `nct:<8 digits>` (see "Clinical trial records"); returns its status (level `full_text` / `abstract` / `title` — what the entry holds, `title` when there is neither a full text nor abstract text — retrieving tier, `coi` and `funding` (PubMed's record, else the paper's own text), `citable` with `comment` — the one flag to check before citing, from `retracted` and PubMed's correction notices in `corrections` — `has_abstract` — false for title-only PubMed records such as letters and editorials — files, failed tiers in `attempts`). Idempotent: a `full_text` entry is never refetched, an `abstract` or `title` entry retries only tiers whose earlier failure was transient and past the cooldown; an entry whose PubMed record is older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` gets the record re-read for new notices. One ID per call.
22
+ * `library_status(id)` → read-only check of presence and level; never fetches
23
+ * `library_info(ids?)` → the whole library, everything the TUI shows: the library directory and host, the decision model with its endpoint and whether it answers right now (`verify`), the entries and their size on disk (`size`), per entry type (`types`: pmid, doi, dailymed, nct) the entries with their levels — `full_text`, `abstract`, `title` — and retractions; entry counts by type and level, counts of retracted and not-citable entries and of entries with errata, expressions of concern and updates, the configured tiers, library directory and host; `ids: true` also lists every entry ID
24
+ * `library_verify(claim, id)` → is one finding of a review stated by one paper it cites? `claim` is the finding alone, verbatim, without its citations; `id` one paper (`pmid:`, or any `doi:`), drug label or trial record the review cites for it — a finding cited to three papers is three calls with the same claim; a claim that names a source or joins two findings is refused. The server reads the paper (full text, else abstract) and asks a decision model; nothing of the paper enters the model's context. `verdict` **pass** (`score` the probability), **fail** (the full text does not state it), **na** (only the abstract is in the library and does not state it) or **error**; `basis` full_text or abstract. See "Verifying claims" below.
25
+ * `library_read(id, section?, query?, max_chars?, max_hits?)` → one small piece of a stored paper's Markdown, never the whole paper: without `section`/`query` the **outline** (headings with word counts, and whether the full text or only the abstract is stored); `section` → that section's text (heading matched as case-insensitive substring, capped at `max_chars`, default 6000); `query` → the paragraphs containing all terms, each with its section, best first (at most `max_hits`, default 5); with no hit, the headings instead. Outline first, then pick. Every full text converted from JATS XML (PMC, Europe PMC, Elsevier) has a **Declarations** section — funding, conflicts of interest, author notes, gathered from wherever the XML keeps them — so `section: "Declarations"` answers who paid and who declares what in one call; a paper without one states nothing. Reads are read-only and may run in parallel.
26
+ * `get_version()` → the running server's package name, version, host and library directory
27
+
28
+ IDs follow the `type:value` form: `pmid:`, `doi:`, `dailymed:`, `nct:`; bare digits count as `pmid:`, a bare NCT number as `nct:`.
29
+
30
+ ### Retrieval tiers
31
+
32
+ | Tier | Does | Needs |
33
+ |---|---|---|
34
+ | `pubmed` | metadata, abstract, PMCID, DOI, publication types (retraction) — always | – |
35
+ | `inbox` | a file you downloaded yourself: drop it into `<library>/inbox/` with the PMID in its name (`39025385.pdf`; PDF, XML or HTML) and the next `library_add` for that ID converts it into the entry and removes it from the inbox. For publishers that hand a PDF to a browser but nothing to a script | PMID |
36
+ | `pmc` | full text as JATS XML via E-utilities; counts only with a `<body>` | PMCID |
37
+ | `europepmc` | Europe PMC's record: its own full text (JATS XML for the open-access subset, the rendered PDF for everything it holds, author manuscripts included) and the free links it lists | – |
38
+ | `tdm` | the publishers' text-and-data-mining links from Crossref; Elsevier (`X-ELS-APIKey`) and Wiley (`Wiley-TDM-Client-Token`) need a token; full text for open-access and entitled articles by the front door | DOI; `AI4L_LIBRARY_ELSEVIER_API_KEY`, `AI4L_LIBRARY_WILEY_TDM_TOKEN` |
39
+ | `unpaywall` | locates open-access copies (publisher, repositories) and downloads them, best first | DOI |
40
+ | `openalex` | locates open-access copies and downloads them, second opinion; skips URLs the previous tier already tried | PMID or DOI |
41
+ | `core` | CORE (core.ac.uk), the aggregator of repository copies, by DOI; skipped without a key | `AI4L_LIBRARY_CORE_API_KEY` (free) |
42
+ | `proxy` | your own service: an institutional proxy, a licensed service, a local mirror — a URL template filled from the paper's IDs (below) | `AI4L_LIBRARY_PROXY_URL`; the IDs the template uses |
43
+
44
+ A located copy is fetched up a ladder, the way AI4L verifies URLs: a plain **fetch** (no browser) with a desktop Chrome User-Agent; then each configured **browser** rung in order (a Playwright server, or a real Google Chrome attached over the DevTools protocol, `browser/chrome.md`) (`AI4L_LIBRARY_BROWSER_1`, `_2`, `_3`: Playwright `run-server` endpoints such as `ws://127.0.0.1:8932`, typically one next to the server and one on a residential line; `browser/compose.yaml` runs one, headed under Xvfb on Microsoft's Playwright image; `"local"` uses this host's own Playwright Chromium) when the plain fetch met a 403, a bot wall or a script-rendered landing page; then the Bright Data **unlocker** when every browser was refused. No browser ships in the package and none is downloaded at run time. Nothing disguises the automation: a site that blocks automated browsers by policy stays blocked, and the attempt says so.
45
+
46
+
47
+ Downloads are plain HTTP with a desktop Chrome User-Agent (API calls identify the library with the contact address), PDFs first and repository copies before publisher sites; PDF (text only, no tables), JATS XML or HTML are converted to Markdown. NCBI/PMC web URLs are never fetched (PMC content comes through E-utilities). When a publisher answers a located open-access copy with a 403 or a bot wall, the page is requested once more through the [Bright Data](https://brightdata.com) Web Unlocker — the same last-resort tier AI4L's URL verification uses — if `BRIGHTDATA_API_TOKEN` is set (`BRIGHTDATA_ZONE`, default `mcp_unlocker`). Without the token the plain failure stands and is retried later.
48
+
49
+ ### Configuring
50
+
51
+ `.env` is the whole configuration: every value the library reads is an environment variable, listed and explained in [`.env.example`](.env.example) — copy it to `.env` next to the library and fill in what you use. It is loaded at startup from the working directory (or the file `AI4L_LIBRARY_ENV` names, or `.env` in the library directory); a variable already set in the environment wins, and an unset one takes the built-in default. The library itself is `library/` under the working directory (`/library` when that exists, as in the container); keep it beside `.env`, gitignored.
52
+
53
+ * **Which tiers run, in which order:** `AI4L_LIBRARY_TIERS=pmc,tdm,unpaywall,openalex` — exactly these, in this order; a tier not listed is off. Unset, every tier runs in the order of the table above, the proxy last.
54
+ * **Keys:** `AI4L_LIBRARY_ELSEVIER_API_KEY` and `AI4L_LIBRARY_WILEY_TDM_TOKEN` for `tdm`, `AI4L_LIBRARY_CORE_API_KEY` for `core`, `NCBI_API_KEY` (10 instead of 3 requests/s), `BRIGHTDATA_API_TOKEN` and `BRIGHTDATA_ZONE` for the unlocker. `AI4L_LIBRARY_CONTACT_EMAIL` is the one address sent to every service (NCBI `tool`/`email`, Unpaywall and OpenAlex polite pools, Crossref, the User-Agent).
55
+ * **Retries:** `AI4L_LIBRARY_RETRY_AFTER_DAYS` (default 1) is the cooldown before a transient tier failure is retried; `AI4L_LIBRARY_REFRESH_AFTER_DAYS` (30) the age of an entry's PubMed record after which `library_add` re-reads it for new correction notices and retractions. A tdm link behind a token that is not configured is recorded as `needs elsevier token` and not re-checked; after adding the token, remove those attempts from the entries to give them a go. `AI4L_LIBRARY_RETRY_BLOCKED` (false) decides whether a browser challenge that did not clear, or a Bright Data refusal, is final: with the same method tomorrow ends the same way, so set it true only after a change of method (a new browser rung, a new service).
56
+ * **Browsers and verify:** `AI4L_LIBRARY_BROWSER_1..3` and `AI4L_LIBRARY_BROWSER_HEADLESS` (above); `AI4L_LIBRARY_VERIFY_*` ("Verifying claims").
57
+
58
+ `library_info` reports the tiers in run order and, per variable, whether its value came from the environment, `.env` or the default — never the value.
59
+
60
+ **The proxy tier.** One service of your own — an institutional proxy, a licensed service, a local mirror — fetched at its position in the ladder like any other tier:
61
+
62
+ ```sh
63
+ AI4L_LIBRARY_PROXY_URL=https://proxy.example.org/login?url=https://doi.org/{doi}
64
+ AI4L_LIBRARY_PROXY_HEADERS=Authorization: Bearer <token>
65
+ ```
66
+
67
+ * The URL is a template. Placeholders: `{doi}` (`10.1056/NEJMoa042274`), `{pmid}` (`15516696`), `{pmcid}` (`PMC6728809`, with its prefix). Each is URL-encoded when filled — the slash in a DOI becomes `%2F` — so put a placeholder in a path segment or a query value. A template may use several. The tier applies only to a paper for which every placeholder it uses is known; otherwise it is skipped as `no doi` (or `no pmcid`, …), a permanent reason. A template without any placeholder never runs (`template has no placeholder`).
68
+ * The headers are optional request headers for credentials (`Authorization`, an API-key header, a cookie), as `Name: value; Name: value`.
69
+ * Unset URL: no proxy tier. `AI4L_LIBRARY_TIERS` places `proxy` in the order like any built-in tier.
70
+
71
+ **What the proxy tier expects back.** The tier sends one GET to the filled template, with a desktop Chrome User-Agent and the headers; redirects are followed. The service answers the paper as one of two formats, read from the response bytes, not the URL or the Content-Type:
72
+
73
+ * **PDF** — the body starts with `%PDF-`. It needs a text layer: under 500 characters per page counts as a scan (`scanned pdf`).
74
+ * **XML** — JATS (as PMC delivers it) or Elsevier's full-text XML, with a `<body>`; recognised by `<?xml` or `<article` at the start.
75
+
76
+ Any other body — an HTML page, JSON, plain text, an empty response — is the service's error message. It is recorded in `attempts` as `service: <message>`: a JSON `message` or `error`, else the page's title and visible text, at most 160 characters (`service: Access denied. Your IP is not authorised.`). A PDF or XML must then pass the same checks as every tier (below): the paper's title or DOI in the text, 800+ words of running prose, Methods and Results or a sectioned body. How a miss is recorded:
77
+
78
+ | response | recorded as | retried |
79
+ |---|---|---|
80
+ | 403, 429, 5xx, timeout, network error | `HTTP 403`, `HTTP 429`, `HTTP 503`, `timeout`, … | after the cooldown (`AI4L_LIBRARY_RETRY_AFTER_DAYS`) |
81
+ | 404 or another 4xx | `HTTP 404`, … — with the service's message when its body carries one: `HTTP 404 — 404 Not Found GET /10.1056%2FNEJMoa042274` | after the cooldown (`AI4L_LIBRARY_RETRY_AFTER_DAYS`) |
82
+ | 200, but neither PDF nor XML | `service: <message>` | after the cooldown (`AI4L_LIBRARY_RETRY_AFTER_DAYS`) |
83
+ | XML without a body | `xml without body` | never |
84
+ | another document (title and DOI absent) | `neither title nor DOI in text (…)` | never |
85
+ | no article body | `no article body (pdf, 342 words, …)` | never |
86
+ | a PDF without a text layer | `scanned pdf` | never |
87
+
88
+ The proxy tier is not escalated to the browser rungs or Bright Data: it is the service's own answer that counts. NCBI and PMC web URLs are never fetched (the `pmc` tier covers them), and a URL already tried for the paper in the same cascade run is skipped.
89
+
90
+ Every full text is checked before it counts: title present, methods and results present (or a substantial sectioned body), not an error, login, paywall or bot-wall page. The original (XML, HTML or PDF) is stored next to the Markdown. A failed tier is recorded in the entry's `attempts` with reason and date, e.g. `unpaywall (HTTP 403: nature.com) 2026-10-26`. **Retries:** a permanent reason (no PMCID, no body, closed, no open-access copy, content checks failed) is never retried; a transient one (timeout, network error, HTTP 403/429/5xx, wall page) is retried by the next `library_add` once `AI4L_LIBRARY_RETRY_AFTER_DAYS` (default 1) have passed, replacing the old entry. A `full_text` entry is never touched again. A failed PubMed fetch writes no entry at all, so it is simply retried next time.
91
+
92
+ ### Verifying claims
93
+
94
+ `library_verify(claim, id)` answers whether one source states one finding of a review — a paper, a drug label (`dailymed:…`) or a trial record (`nct:…`), each judged on its stored full text the same way. This is the contract for an auditor, or any client, built against it.
95
+
96
+ **The call.** `claim` is one finding, verbatim from the review, with markdown links reduced to their text and its citations removed: `IPSS fell 2.3 points more than placebo in men with ED`. Section labels such as `**Magnitude:**` and list bullets may stay. `id` is one paper the review cites for that finding, as `pmid:<PMID>`, already in the library (`library_add` first). One claim, one paper per call: a finding cited to three papers is three calls with the same claim text, and each answer stands on its own — a source that does not carry the finding is named by its own row, so it can be dropped without touching the others. The server never aggregates over papers. A sentence that joins two findings, each with its own citation, is two claims: the text before each citation group, without the group. A sentence without a citation is not a claim.
17
97
 
18
- IDs follow the `type:value` form (`pmid:`, `pmc:`, `doi:`, `nct:`); bare digits count as `pmid:`. v0.1 supports `pmid:` only.
19
- * `get_version()` → the running server's package name, version and library directory
98
+ **How to build the claim.** The decision model answers exactly the statement it is given, so the statement must be one the paper could make:
99
+
100
+ * **A complete statement:** subject, intervention, outcome, number. Splitting a sentence at its citations leaves fragments ("and dementia", "or silodosin", "across long-acting PDE5 inhibitors, 0.40 percentage points lower"); restore the subject from the sentence ("HbA1c was 0.40 percentage points lower with long-acting PDE5 inhibitors than with control"). Resolve "it", "both", "the drug".
101
+ * **One finding, one number set:** split at semicolons too when the parts are different results ("Tadalafil OR 1.54 (95% CI 0.79–3.00); class RR 1.11 (95% CI 1.02–1.22)" is two claims, from two papers).
102
+ * **The paper's finding, nothing around it:** cut provenance ("in a Lilly-sponsored trial", "a Sanofi-authored cohort", "in Kloner's study", "(FDA label)"), framing of the literature ("in two randomized trials", "pooled trials", "newer pooled data still show"), and the review's own inference or advice ("so emergency nitrates require a 48-hour gap", "risking loss of effect", "easing a common stressor"). A paper reports one trial; "trials used 20 mg daily" against one of them fails.
103
+ * **Not a claim, no call:** statements about the literature as a whole ("no trial has tested…", "only case reports exist"), recommendations ("confirm no nitrates are in use"), history ("development with ICOS…"). A number the review took from a label or other non-paper source stays with that source and is not sent against a paper cited next to it.
104
+ * List bullets and bold section labels may stay; the model ignores them.
105
+
106
+ **Refused, before any model call:** an empty claim; a claim under four words; a fragment (starting with a conjunction or a lowercase word once bullets and labels are stripped); a claim that names a source ("claim names a source [Brock et al., 2014] — send the finding without its sources; the paper is the id"); a claim with two or more citation groups ("compound claim: 2 citation groups … — one finding per call, without its sources"). A citation group is a parenthesis or bracket holding authors and a year; adjacent brackets separated by punctuation or "and" count as one; "(95% CI 0.49–0.85)" is not one.
107
+
108
+ **The result**, JSON: `claim` and `id` as sent; `verdict`; `score`, the model's probability that the paper states the finding; `basis`, `full_text`, `abstract` or `title`, what of the paper was read; `windows`, the number of model requests over the paper (1 unless the paper was cut for the Mac app's limit); `glyphs_unresolved`, the symbols the paper's PDF font gave no character for (a minus, ±, ≤ — when above 0, a `fail` on a figure may be the font, not the paper); `superseded`, `true` when the paper carries a stored correction and the claim uses a figure that correction replaced (the verdict is kept: the figure is in the paper, but out of date), `false` when it does not, `null` for a paper without a stored correction; `model`; and `error` with the reason when the call could not run. The verdicts:
109
+
110
+ | verdict | meaning |
111
+ |---|---|
112
+ | `pass` | the paper states the finding in substance: `score` reached the threshold (default 0.5) |
113
+ | `fail` | the paper's full text was read and does not state it |
114
+ | `na` | only the paper's abstract is in the library, and that does not state it — the finding may be in the full text (drop it into the inbox and verify again); or only its title is there (PubMed has no abstract for it, no full text was found), in which case nothing is judged and `score` is null |
115
+ | `error` | the call could not run: a refused claim (empty, too short, a fragment, naming a source, compound), paper not in the library, no model configured, the model failing or not answering within the timeout |
116
+
117
+ "In substance": wording may differ, a number may be rounded or derived from the paper's numbers; a changed number, an opposite direction, another population or intervention is not backed. A paper is read whole; nothing of it enters the caller's context, and the model returns one probability, no quotes and no prose. For a look at the text behind a verdict there is `library_read`; on the hosted model real findings score 0.95 and up and changed numbers 0.20 and under, so a `pass` under 0.7 or a `fail` over 0.3 is the one worth that look.
118
+
119
+ **For an audit table:** one row per call; `pass` → 🟢, `fail` → 🔴, `na` → N/A, `error` → empty; comment: the score to two decimals and the basis (`title` alone when the basis is `title`, where `score` is null), or the error text. A refused claim (`error` naming a source, a compound claim, a fragment, too short) means the claim cell is wrong: fix it as the error says and call once more. `library_info` reports the model as `verify.model`, its endpoint as `verify.base_url`, whether it is local, and whether it answers right now — `verify.ok` with the answer time in milliseconds in `verify.ms`, or the reason in `verify.error`, from one tiny request per call; write the model once above the table, and stop before the claims when `ok` is false. A finding whose sources split 🟢 / 🔴 keeps the 🟢 rows, and the 🔴 row names the source to remove.
120
+
121
+ #### The judge: Perplexity's Decider
122
+
123
+ The verdict comes from [Perplexity's decision model](https://docs.perplexity.ai/docs/decisions), which returns a probability for a yes/no question about text you supply, no prose. Two ways to run it, same request, same answer:
124
+
125
+ * **Hosted** at `api.perplexity.ai`: model `pplx-decider-v1.2`, a whole paper in about two seconds, 2 cents per million input tokens, needs a key from [console.perplexity.ai](https://console.perplexity.ai). The paper text leaves the machine.
126
+ * **Local**, the [Decider for Mac](https://docs.perplexity.ai/docs/decisions/decider-for-mac) app on a Mac in the network: model `pplx-decider-flash-v1.2`, no key, text stays local; 32k-token requests, so long papers are judged in windows cut at headings. Use the Mac's IP in the URL, not a host name, with "Allow Network Access" on, and leave its `max_tokens` at 32768 (it serves one request at a time and a larger request blocks it for minutes).
127
+
128
+ Hosted or local is three lines in `.env`: `AI4L_LIBRARY_VERIFY_URL`, `AI4L_LIBRARY_VERIFY_MODEL`, `AI4L_LIBRARY_VERIFY_API_KEY`. `AI4L_LIBRARY_VERIFY_THRESHOLD` (0.5) is the probability at or above which a paper counts as backing the claim; `AI4L_LIBRARY_VERIFY_TIMEOUT_SECS` (60) caps one model request. A paper longer than 800000 characters hosted (a whole paper), 90000 on the Mac app, is judged in windows. Without the URL the tool answers `error`. Self-test of an endpoint, the app's own example:
129
+
130
+ ```sh
131
+ curl -s http://<ip>:8080/v1/decisions -H 'Content-Type: application/json' -d '{"model":"pplx-decider-flash-v1.2","state":"I was charged twice for my order.","questions":{"team":{"type":"choice","criteria":{"billing":null,"shipping":null,"technical":null}}}}'
132
+ ```
133
+
134
+ **Papers without a PMID.** A review may cite a paper PubMed does not index by its DOI (`https://doi.org/<DOI>`); `library_add("doi:10.5812/asjsm.110753")` takes it. If PubMed does index the DOI, the call builds that paper's `pmid:` entry and stores a copy of it under `doi:` — the same files, renamed, with `pmid` set in its meta — so the DOI resolves on disk: every later call answers from the `doi:` entry without asking PubMed (5 of the 101 DOIs evipedia cites). Each `library_add` of such a DOI completes the `pmid:` entry (retries, record refresh) and copies it again, so the copy is as current as the original. A mapped paper counts once under `pmid` and once under `doi` in `library_info`. Otherwise the record comes from [Crossref](https://www.crossref.org/) — or, for a DOI another registry holds (DataCite, mEDRA, …), from doi.org's content negotiation as CSL JSON, stored as `-csl.json`: title, authors, journal, year, the abstract — Crossref's when the publisher deposited one, else OpenAlex's (of the 97 DOI-only papers evipedia cites, Crossref has 55 abstracts, OpenAlex 86; 11 have none anywhere and stay title-only unless a full text is found) — the funders as `funding`, and Crossref's update notices — corrections, retractions, expressions of concern — as `corrections`, so `citable` works as for a PubMed paper. The status has `pmid` null, the abstract's head line says "not in PubMed", `retrieved_by` names the source of what the entry holds — `crossref`, `doi.org` or `openalex` for the abstract, `crossref` or `doi.org` for a title alone, the tier once a full text is found; the full-text cascade then runs on the DOI (`unpaywall`, `openalex`, `tdm`, `core`, `proxy`; `pmc`, `europepmc` and `inbox` need a PMID). An entry older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` gets its Crossref record re-read for new notices. Entries live under `doi/<hash 2>/<hash 4>/<slug>/`, the slug being the DOI in lowercase with every run of other characters than letters, digits, `.` and `-` as `_` (`doi-10.5812_asjsm.110753`), the Crossref record as `-crossref.json`. `library_status`, `library_read` and `library_verify` take any DOI `library_add` took: they read its `doi:` entry, the copy or the Crossref one, and never look a DOI up. Measured on evipedia's 915 live reviews (2026-11-06): 87 cite 101 papers by DOI only, against 34,378 PubMed citations.
135
+
136
+ PubMed's book records — National Academies reports, NTP monographs, StatPearls chapters in NCBI Bookshelf — are read as papers too: the chapter or book title, the authors (else the editors), the publisher in place of the journal, publication type `Book`.
137
+
138
+ **Drug labels.** `library_add("dailymed:<set ID>")` stores the current FDA prescribing information of one product from [DailyMed](https://dailymed.nlm.nih.gov/), the set ID being the fixed identifier in the label's page URL (`…/drugInfo.cfm?setid=<set ID>`) across all its revisions — the same ID a review links. The SPL XML is converted to Markdown: first a **Highlights** section with the label's header lines — the product line and "Initial U.S. Approval: 2003" (the year is also in the head line) — then every text section in order, a boxed warning headed "Boxed Warning" and the recent major changes headed as such even where the label gives them no title, (indications, dosage, contraindications, warnings, adverse reactions with their tables, drug interactions, clinical pharmacology, clinical studies); the structured product data and package images are left out; there is no abstract. The status carries `setid`, the title as `<brand> (<generic>) <form> — prescribing information`, the labeler as `first_author`, the effective year as `year`, the label version as `version` (`spl 52`), and `publication_types` `["Drug Label"]`. `library_read` and `library_verify` work on a label as on a paper, so a figure the review takes from a label is verified against the label. An entry older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when DailyMed has a newer version. Entries live under `dailymed/<first 2>/<first 4>/<set ID>/`, with the SPL XML and the Markdown as `dailymed-<set ID>-fulltext.xml` and `-fulltext.md`, and the log line reads `add dailymed:<set ID> dailymed fetched level=full_text spl 52 effective=2026-06-03 Cialis (Tadalafil)`.
139
+
140
+ **Clinical trial records.** `library_add("nct:05173896")` stores the current record of one trial from [ClinicalTrials.gov](https://clinicaltrials.gov/): the ID is the eight digits of the NCT number in the review's link (`…/study/NCT05173896`); `nct:NCT05173896` and a bare `NCT05173896` are accepted and read the same. The API's JSON is converted to Markdown, one section per module: identification with secondary IDs such as EudraCT, status with every date and its actual/estimated type, sponsor and collaborators, description, conditions, design (phase, allocation, masking, enrollment with its type), arms and interventions with doses, primary and secondary outcomes with time frames, eligibility, locations as a country list, references; and, when results are posted, the participant flow, baseline characteristics, every outcome measure with its group values and analyses, and the adverse events, all as tables. Values are written in plain words, "Phase 2", "Active, not recruiting", "100 (estimated)", so a figure the review states can be found as stated. The status carries `nctid`, `publications` (the results publications the record lists, as `pmid:…`, so whether a trial has been published is one status away), the brief title, the lead sponsor as `first_author`, the first-posted year as `year`, the last-update-posted date as `version`, and `publication_types` `["Trial Registration"]`. `library_read` and `library_verify` work on a record as on a paper. An entry older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when the record was updated since; a status the review states that has moved on then fails, which is right. Entries live under `nct/<digits 1–2>/<digits 1–4>/<digits>/` with the JSON and the Markdown as `nct-<digits>-fulltext.json` and `-fulltext.md`; the log line reads `add nct:05173896 ctgov fetched level=full_text updated 2024-12-09 active, not recruiting ETLAS2`.
141
+
142
+ **Funding and conflicts of interest.** Every status carries `coi`, the paper's conflict-of-interest statement, and `funding`, who paid. Both come from PubMed's record at ingest (`CoiStatement`, the grant list's agencies; a quarter of records have each) and are refreshed with it; where PubMed has none and a full text was retrieved, they are taken from the paper's own text — its Declarations section, or a section headed Funding, Conflicts of interest, Competing interests or Disclosure — as the statement verbatim. `coi` null and `funding` empty therefore mean: neither PubMed nor the stored text states anything. For an abstract-only entry that is as far as the library can see. The full statements of a paper with full text are one call away: `library_read(id, section: "Declarations")`. In `-meta.md` they are the lines `coi:` (a JSON string) and `funding:` (a JSON array, one item per agency or statement).
143
+
144
+ **Symbols a PDF font hides.** Some publishers' symbol fonts carry no Unicode for their glyphs, so text extraction gives a control character where the PDF shows a minus, ±, ≤ or β. The library restores the symbol from the glyph's name in the font — the names of the Adobe Pi fonts (`H11350` is ≥ everywhere) and of the Advent symbol font most Elsevier and Wiley PDFs use — each name checked against the glyph's outline in the library's PDFs; a name it does not know is left as it is and counted: the status's `glyphs_unresolved` is the number left in the full text (162 of 1,119 PDF-derived entries have some, down from 277), and `library_verify` returns the same count for the text it judged. A `fail` on a paper with `glyphs_unresolved` above 0 says "check the symbol", not "the paper does not state it". Words a PDF breaks at a line end ("differ- ence") are joined back when the paper uses the word elsewhere or no compound is known at that break; a hyphen the paper uses elsewhere ("time-dependent") and a suspended one ("dose- and time-dependent") are kept.
145
+
146
+ **Citable, and correction notices.** `citable` is the one flag an auditor checks before a paper counts as evidence: false when PubMed records the paper as retracted — by the "Retracted Publication" type or by a retraction notice — and, once correction notices are assessed (below), when a correction changed its data. `comment` says why, or, when the paper is citable, names what to be aware of: an erratum (its severity is not assessed yet; `library_add` on the notice's PMID fetches it when it has one), an expression of concern, or a newer version that supersedes this one, typically an updated Cochrane review, which should be cited instead. Behind the flag, `corrections` lists PubMed's notices about the paper as `{type, pmid, source}` — `erratum_in`, `retraction_in`, `partial_retraction_in`, `retracted_and_republished_in`, `expression_of_concern_in` or `update_in`, the notice's own PMID when PubMed has one, and its citation verbatim. Comments are not notices and are not carried: PubMed's CommentIn, SummaryForPatientsIn, ReprintIn and the "Of/For/From" direction (this paper being the notice about another) are ignored. `update_in` is not an erratum: every case seen is a Cochrane review superseded by a newer version. Notices appear after ingest, so `library_add` on an existing entry re-reads the PubMed record once it is older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` (default 30; `pubmed_checked` in the entry is the date of the last read) and updates the notices, retraction, publication types and a PMCID that appeared since — never the stored abstract or full text, and without reopening full-text retries. The notice's own text is not fetched or applied. Where NLM has corrected the PubMed abstract after an erratum — it marks each corrected sentence `[corrected]` — those sentences are copied to the end of the stored full text under `## Corrected in PubMed abstract`, at ingest and on every refresh, so a full text that predates the correction still carries the corrected figures and `library_verify` passes a claim that states them — and marks a claim that states a replaced figure as `superseded`. The `comment` then reads "erratum, corrected figures stored" instead of "not assessed". In `-meta.md` each notice is one `corrections:` line, `<type> <pmid or -> "<source>"`; entries written before this feature are read from their stored PubMed XML until they are next rewritten.
147
+
148
+ **Why a notice may have no PMID.** PubMed gives the notice's PMID only when the notice is itself a PubMed record. For about a quarter of the errata in a 6,900-entry library it is not: the journal printed the correction, PubMed recorded the citation in the paper's record, and never indexed the correction as a record of its own (NEJM, JAMA and Lancet errata from 1988 to 2014 are typical). There is nothing to redirect to, so `pmid` is null and `source` — the citation as PubMed wrote it — is the notice's only identifier. Measured on the 79 such errata in the library (2026-11-04): PubMed's citation matcher resolves 18 after all (13 directly, 5 after picking the "Published Erratum" among two candidates), Crossref gives a DOI for 38 more when journal, volume and page match, and 23 have no identifier anywhere. The planned second stage resolves them at ingest (PMID first, then DOI, the citation always kept), fetches the notice's text and classifies it once per paper as major (a number, table, figure, result or conclusion changed) or minor (authors, funding, typos): a major erratum makes the paper not citable, like a retraction, so the audit never has to apply a correction; until then an erratum is a note on a citable paper.
149
+
150
+ Every action is appended to `library/library.log`, one line per call, starting with a local timestamp and the host name — so several workers sharing one library can be told apart. Past 50 MB the file is renamed to `library.log.1` (one old file kept) and the next line starts a new one. Entry files are written to a temporary name and renamed into place, so a reader never sees a half-written file, and several servers may write one library directory:
151
+
152
+ ```
153
+ 2026-10-26 17:12:31 omacvm-berlin add pmid:30955454 fetched pubmed level=abstract
154
+ ```
20
155
 
21
156
 
22
157
  ## Install
23
158
 
24
- The server is published to npm as **[`ai4l-library`](https://www.npmjs.com/package/ai4l-library)** and runs over stdio via `npx` — no global install needed (requires Node.js ≥ 18):
159
+ The server is published to npm as **[`ai4l-library`](https://www.npmjs.com/package/ai4l-library)** and runs over stdio via `npx` — no global install needed (requires Node.js ≥ 18). It's also listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.forever-healthy/ai4l-library) as **`io.github.forever-healthy/ai4l-library`**, so MCP-aware clients can discover and install it automatically.
25
160
 
26
161
  ```
27
162
  npx -y ai4l-library
@@ -40,9 +175,6 @@ Client config (`.mcp.json` for Claude Code, `claude_desktop_config.json` for Cla
40
175
  }
41
176
  ```
42
177
 
43
- Planned: a Docker image with the converters and a headless browser built in, the library mounted as a volume —
44
- `docker run --rm -i --env-file .env -v "$PWD/library:/library" ghcr.io/forever-healthy/ai4l-library`
45
-
46
178
 
47
179
  ## License
48
180