@cyanheads/pubmed-mcp-server 2.6.11 → 2.7.0
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.
- package/CLAUDE.md +2 -2
- package/README.md +36 -14
- package/dist/config/server-config.d.ts +6 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +52 -0
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +19 -12
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +71 -23
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +671 -141
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +0 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +21 -41
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +87 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +217 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -0
- package/dist/services/error-contracts.d.ts +18 -1
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +21 -4
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts +47 -0
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -0
- package/dist/services/europe-pmc/api-client.js +123 -0
- package/dist/services/europe-pmc/api-client.js.map +1 -0
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +71 -0
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -0
- package/dist/services/europe-pmc/europe-pmc-service.js +243 -0
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -0
- package/dist/services/europe-pmc/request-queue.d.ts +33 -0
- package/dist/services/europe-pmc/request-queue.d.ts.map +1 -0
- package/dist/services/europe-pmc/request-queue.js +107 -0
- package/dist/services/europe-pmc/request-queue.js.map +1 -0
- package/dist/services/europe-pmc/types.d.ts +123 -0
- package/dist/services/europe-pmc/types.d.ts.map +1 -0
- package/dist/services/europe-pmc/types.js +16 -0
- package/dist/services/europe-pmc/types.js.map +1 -0
- package/dist/services/ncbi/ncbi-service.d.ts +6 -7
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +11 -11
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/request-queue.d.ts +26 -11
- package/dist/services/ncbi/request-queue.d.ts.map +1 -1
- package/dist/services/ncbi/request-queue.js +94 -52
- package/dist/services/ncbi/request-queue.js.map +1 -1
- package/dist/services/ncbi/response-handler.js +1 -1
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.js +14 -13
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
- package/package.json +7 -6
- package/server.json +4 -4
|
@@ -1,30 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Full-text fetch tool.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
2
|
+
* @fileoverview Full-text fetch tool. Resolves full-text articles through a
|
|
3
|
+
* three-stage chain: NCBI PMC EFetch → Europe PMC `fullTextXML` → Unpaywall.
|
|
4
|
+
* Accepts three mutually-exclusive input shapes:
|
|
5
|
+
*
|
|
6
|
+
* - `pmcids` — fetch directly by PMC ID. Articles not in PMC fall through to
|
|
7
|
+
* EPMC by PMC ID, then to Unpaywall when the DOI is available.
|
|
8
|
+
* - `pmids` — resolve PMID → PMCID via PMC ID Converter, then run the chain.
|
|
9
|
+
* - `dois` — skip PMC EFetch (no PMCID); resolve via EPMC search-by-DOI →
|
|
10
|
+
* fullTextXML, then Unpaywall. Covers EPMC-only OA and preprints with no
|
|
11
|
+
* PubMed presence.
|
|
12
|
+
*
|
|
13
|
+
* Output uses a discriminated union on `source` (`pmc` | `unpaywall`) with an
|
|
14
|
+
* extra `viaSource` discriminator that records which layer produced the
|
|
15
|
+
* content. EPMC's JATS reuses the `pmc` schema shape because it's the same
|
|
16
|
+
* DTD; `viaSource: 'europepmc'` distinguishes it from PMC EFetch output.
|
|
17
|
+
*
|
|
7
18
|
* @module src/mcp-server/tools/definitions/fetch-fulltext.tool
|
|
8
19
|
*/
|
|
9
20
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
10
|
-
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
11
21
|
import { htmlExtractor, pdfParser } from '@cyanheads/mcp-ts-core/utils';
|
|
12
|
-
import { NCBI_SERVICE_ERRORS, UNPAYWALL_SERVICE_ERRORS } from '../../../services/error-contracts.js';
|
|
22
|
+
import { EUROPEPMC_SERVICE_ERRORS, NCBI_SERVICE_ERRORS, UNPAYWALL_SERVICE_ERRORS, } from '../../../services/error-contracts.js';
|
|
23
|
+
import { getEuropePmcService, } from '../../../services/europe-pmc/europe-pmc-service.js';
|
|
13
24
|
import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
|
|
14
25
|
import { extractDoi, extractPmid } from '../../../services/ncbi/parsing/article-parser.js';
|
|
15
26
|
import { parsePmcArticle } from '../../../services/ncbi/parsing/pmc-article-parser.js';
|
|
16
27
|
import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers.js';
|
|
17
28
|
import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
|
|
18
|
-
import { getUnpaywallService } from '../../../services/unpaywall/unpaywall-service.js';
|
|
29
|
+
import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
|
|
19
30
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
|
|
20
31
|
import { pmidStringSchema } from './_schemas.js';
|
|
21
32
|
function normalizePmcId(id) {
|
|
22
33
|
return id.replace(/^PMC/i, '');
|
|
23
34
|
}
|
|
35
|
+
function withPmcPrefix(id) {
|
|
36
|
+
return id.startsWith('PMC') ? id : `PMC${id}`;
|
|
37
|
+
}
|
|
24
38
|
function filterSections(sections, sectionFilter) {
|
|
25
39
|
const lowerFilter = sectionFilter.map((s) => s.toLowerCase());
|
|
26
40
|
return sections.filter((s) => s.title && lowerFilter.some((f) => s.title?.toLowerCase().includes(f)));
|
|
27
41
|
}
|
|
42
|
+
function applyPmcFilters(article, filters) {
|
|
43
|
+
let out = article;
|
|
44
|
+
if (filters.sections?.length) {
|
|
45
|
+
out = { ...out, sections: filterSections(out.sections, filters.sections) };
|
|
46
|
+
}
|
|
47
|
+
if (filters.maxSections !== undefined) {
|
|
48
|
+
out = { ...out, sections: out.sections.slice(0, filters.maxSections) };
|
|
49
|
+
}
|
|
50
|
+
if (!filters.includeReferences) {
|
|
51
|
+
const { references: _, ...rest } = out;
|
|
52
|
+
out = rest;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
28
56
|
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
|
29
57
|
const SubsectionSchema = z
|
|
30
58
|
.object({
|
|
@@ -73,9 +101,17 @@ const PublicationDateSchema = z
|
|
|
73
101
|
.describe('Publication date');
|
|
74
102
|
const PmcArticleSchema = z
|
|
75
103
|
.object({
|
|
76
|
-
source: z
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
source: z
|
|
105
|
+
.literal('pmc')
|
|
106
|
+
.describe('Structured JATS — same DTD whether sourced from NCBI PMC or Europe PMC'),
|
|
107
|
+
viaSource: z
|
|
108
|
+
.enum(['pmc', 'europepmc'])
|
|
109
|
+
.describe('Which layer produced the JATS: `pmc` for NCBI PMC EFetch (db=pmc), `europepmc` for Europe PMC `fullTextXML`. Both paths return the same JATS shape; the discriminator records origin for observability and license attribution.'),
|
|
110
|
+
pmcId: z
|
|
111
|
+
.string()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe('PMC ID — present for NCBI PMC records and Europe PMC entries that have a PMC counterpart. Absent for EPMC-only records like preprints; use `epmcId` in that case.'),
|
|
114
|
+
pmcUrl: z.string().optional().describe('PMC URL — derived from `pmcId` when present'),
|
|
79
115
|
pmid: z.string().optional().describe('PubMed ID'),
|
|
80
116
|
pubmedUrl: z.string().optional().describe('PubMed URL'),
|
|
81
117
|
doi: z.string().optional().describe('DOI'),
|
|
@@ -89,18 +125,32 @@ const PmcArticleSchema = z
|
|
|
89
125
|
publicationDate: PublicationDateSchema.optional(),
|
|
90
126
|
sections: z.array(SectionSchema).describe('Article body sections'),
|
|
91
127
|
references: z.array(ReferenceSchema).optional().describe('Reference list'),
|
|
128
|
+
epmcId: z
|
|
129
|
+
.string()
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('Europe PMC record id — present when `viaSource` is `europepmc`'),
|
|
132
|
+
epmcSource: z
|
|
133
|
+
.string()
|
|
134
|
+
.optional()
|
|
135
|
+
.describe('Europe PMC source code when `viaSource` is `europepmc`. Common values: `MED` (PubMed-derived), `PMC` (PMC counterpart), `PPR` (preprint), `PAT` (patent), `AGR` (Agricola), plus less common codes (`CTX`, `CBA`, `ETH`, `HIR`). Treat as opaque — EPMC may introduce new codes.'),
|
|
92
136
|
})
|
|
93
|
-
.describe('Structured
|
|
137
|
+
.describe('Structured JATS full-text article. `viaSource` records whether the JATS came from NCBI PMC or Europe PMC.');
|
|
94
138
|
const UnpaywallArticleSchema = z
|
|
95
139
|
.object({
|
|
96
140
|
source: z
|
|
97
141
|
.literal('unpaywall')
|
|
98
142
|
.describe('Content fetched from an open-access copy indexed by Unpaywall. Best-effort — structural fidelity depends on `contentFormat`.'),
|
|
143
|
+
viaSource: z
|
|
144
|
+
.literal('unpaywall')
|
|
145
|
+
.describe('Layer that produced this article. Constant `unpaywall` for this branch.'),
|
|
99
146
|
contentFormat: z
|
|
100
147
|
.enum(['html-markdown', 'pdf-text'])
|
|
101
148
|
.describe('How `content` was extracted. html-markdown: Defuddle extracted Markdown from an HTML landing page; light section structure may survive but is not guaranteed. pdf-text: unpdf extracted plain text from a PDF; no section, reference, or heading structure.'),
|
|
102
|
-
pmid: z
|
|
103
|
-
|
|
149
|
+
pmid: z
|
|
150
|
+
.string()
|
|
151
|
+
.optional()
|
|
152
|
+
.describe('PubMed ID when input was `pmids`; absent for `dois` input'),
|
|
153
|
+
pubmedUrl: z.string().optional().describe('PubMed URL — present when `pmid` is set'),
|
|
104
154
|
doi: z.string().describe('DOI used to locate the open-access copy'),
|
|
105
155
|
sourceUrl: z.string().describe('URL the content was fetched from'),
|
|
106
156
|
title: z.string().optional().describe('Detected article title when present'),
|
|
@@ -126,39 +176,60 @@ const UnpaywallArticleSchema = z
|
|
|
126
176
|
.describe('Best-effort full text from an open-access copy');
|
|
127
177
|
const ArticleSchema = z
|
|
128
178
|
.discriminatedUnion('source', [PmcArticleSchema, UnpaywallArticleSchema])
|
|
129
|
-
.describe('Full-text article; shape depends on `source` (pmc = structured, unpaywall = best-effort)');
|
|
179
|
+
.describe('Full-text article; shape depends on `source` (pmc = structured JATS, unpaywall = best-effort)');
|
|
130
180
|
const UnavailableReasonSchema = z
|
|
131
181
|
.enum([
|
|
182
|
+
'not-found',
|
|
132
183
|
'no-pmc-fallback-disabled',
|
|
184
|
+
'no-epmc-fulltext',
|
|
133
185
|
'no-doi',
|
|
134
186
|
'no-oa',
|
|
135
187
|
'fetch-failed',
|
|
136
188
|
'parse-failed',
|
|
137
189
|
'service-error',
|
|
138
190
|
])
|
|
139
|
-
.describe(
|
|
191
|
+
.describe('Why no full text was returned. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data).');
|
|
192
|
+
const TierOutcomeSchema = z
|
|
193
|
+
.enum([
|
|
194
|
+
'not-attempted',
|
|
195
|
+
'miss',
|
|
196
|
+
'no-fulltext',
|
|
197
|
+
'no-doi',
|
|
198
|
+
'no-oa',
|
|
199
|
+
'fetch-failed',
|
|
200
|
+
'parse-failed',
|
|
201
|
+
'service-error',
|
|
202
|
+
])
|
|
203
|
+
.describe('Per-tier outcome. not-attempted: tier was skipped. miss: tier returned no record. no-fulltext: EPMC indexed the record but publishes no fullTextXML. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall reports no open-access copy. fetch-failed: OA copy download failed. parse-failed: extraction produced empty content. service-error: tier service threw.');
|
|
204
|
+
const TriedTierSchema = z
|
|
205
|
+
.object({
|
|
206
|
+
tier: z.enum(['pmc', 'europepmc', 'unpaywall']).describe('Which tier in the resolution chain'),
|
|
207
|
+
outcome: TierOutcomeSchema,
|
|
208
|
+
detail: z.string().optional().describe('Tier-specific context when available'),
|
|
209
|
+
})
|
|
210
|
+
.describe('One tier the resolution chain attempted, with its outcome');
|
|
140
211
|
const UnavailableSchema = z
|
|
141
212
|
.object({
|
|
142
|
-
|
|
213
|
+
id: z
|
|
214
|
+
.string()
|
|
215
|
+
.describe('Identifier the chain could not resolve — PMID, PMCID, or DOI per `idType`'),
|
|
216
|
+
idType: z.enum(['pmid', 'pmcid', 'doi']).describe('Which input branch the id came from'),
|
|
143
217
|
reason: UnavailableReasonSchema,
|
|
144
|
-
|
|
218
|
+
triedTiers: z
|
|
219
|
+
.array(TriedTierSchema)
|
|
220
|
+
.describe('Per-tier outcomes the chain produced for this id, in execution order. Covers `pmc`, `europepmc`, and `unpaywall` — the same tiers the tool description references. Tiers that the chain skipped appear as `outcome: not-attempted` with a `detail` explaining why.'),
|
|
145
221
|
})
|
|
146
|
-
.describe('One
|
|
222
|
+
.describe('One identifier that could not be returned, with the full chain it traversed');
|
|
147
223
|
// ─── Tool Definition ─────────────────────────────────────────────────────────
|
|
148
224
|
export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
149
|
-
description: 'Fetch full-text articles from PubMed Central with structured sections and references. When
|
|
225
|
+
description: 'Fetch full-text articles from PubMed Central with structured sections and references. When PMC misses, transparently falls back to Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart), then to Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text. Provide exactly one of `pmcids` (PMC IDs directly), `pmids` (PubMed IDs, auto-resolved), or `dois` (preprints and EPMC-only OA records that lack PMID/PMCID).',
|
|
150
226
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
151
227
|
_meta: conceptMeta([SCHEMA_SCHOLARLY_ARTICLE, EDAM_DATA_RETRIEVAL]),
|
|
152
228
|
sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/fetch-fulltext.tool.ts',
|
|
153
229
|
errors: [
|
|
154
230
|
...NCBI_SERVICE_ERRORS,
|
|
155
231
|
...UNPAYWALL_SERVICE_ERRORS,
|
|
156
|
-
|
|
157
|
-
reason: 'invalid_pmc_efetch_response',
|
|
158
|
-
code: JsonRpcErrorCode.SerializationError,
|
|
159
|
-
when: 'PMC EFetch returned a payload missing the pmc-articleset wrapper.',
|
|
160
|
-
recovery: 'Retry once; if it persists, NCBI returned malformed data — try fewer PMC IDs at once.',
|
|
161
|
-
},
|
|
232
|
+
...EUROPEPMC_SERVICE_ERRORS,
|
|
162
233
|
],
|
|
163
234
|
input: z
|
|
164
235
|
.object({
|
|
@@ -167,13 +238,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
167
238
|
.min(1)
|
|
168
239
|
.max(10)
|
|
169
240
|
.optional()
|
|
170
|
-
.describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids` or `
|
|
241
|
+
.describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`.'),
|
|
171
242
|
pmids: z
|
|
172
243
|
.array(pmidStringSchema)
|
|
173
244
|
.min(1)
|
|
174
245
|
.max(10)
|
|
175
246
|
.optional()
|
|
176
|
-
.describe('PubMed IDs. Provide exactly one of `pmcids` or `
|
|
247
|
+
.describe('PubMed IDs. Provide exactly one of `pmcids`, `pmids`, or `dois`. Articles in PMC are returned as structured JATS; articles not in PMC fall through to Europe PMC (when EPMC has a `fullTextXML`), then to Unpaywall when `UNPAYWALL_EMAIL` is set and a DOI is available.'),
|
|
248
|
+
dois: z
|
|
249
|
+
.array(z.string().min(3))
|
|
250
|
+
.min(1)
|
|
251
|
+
.max(10)
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('DOIs to resolve (e.g. ["10.21203/rs.3.rs-9010375/v1"]). Provide exactly one of `pmcids`, `pmids`, or `dois`. Covers preprints and EPMC-only OA records that lack PMID/PMCID. Chain: Europe PMC search-by-DOI → fullTextXML → Unpaywall.'),
|
|
177
254
|
includeReferences: z
|
|
178
255
|
.boolean()
|
|
179
256
|
.default(false)
|
|
@@ -190,8 +267,8 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
190
267
|
.optional()
|
|
191
268
|
.describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
|
|
192
269
|
})
|
|
193
|
-
.refine((v) =>
|
|
194
|
-
message: 'Provide exactly one of `pmcids` or `
|
|
270
|
+
.refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
|
|
271
|
+
message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
|
|
195
272
|
}),
|
|
196
273
|
output: z.object({
|
|
197
274
|
articles: z.array(ArticleSchema).describe('Full-text articles'),
|
|
@@ -199,22 +276,37 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
199
276
|
unavailable: z
|
|
200
277
|
.array(UnavailableSchema)
|
|
201
278
|
.optional()
|
|
202
|
-
.describe('Per-
|
|
203
|
-
unavailablePmcIds: z
|
|
204
|
-
.array(z.string())
|
|
205
|
-
.optional()
|
|
206
|
-
.describe('PMC IDs that returned no data, whether requested directly via `pmcids` or resolved from `pmids` via the PMC ID Converter'),
|
|
279
|
+
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
|
|
207
280
|
}),
|
|
208
281
|
async handler(input, ctx) {
|
|
209
282
|
ctx.log.info('Executing pubmed_fetch_fulltext', {
|
|
210
283
|
hasPmcids: !!input.pmcids,
|
|
211
284
|
hasPmids: !!input.pmids,
|
|
212
|
-
|
|
285
|
+
hasDois: !!input.dois,
|
|
286
|
+
idCount: (input.pmcids ?? input.pmids ?? input.dois)?.length,
|
|
213
287
|
});
|
|
214
|
-
// ──
|
|
288
|
+
// ── Chain tracking ──────────────────────────────────────────────────────
|
|
289
|
+
// Per-input-id tier history (the `triedTiers` array on unavailable entries).
|
|
290
|
+
// Keys: pmid for `pmids` input, prefixed PMCID for `pmcids` input, doi for
|
|
291
|
+
// `dois` input. `recoveredIds` collects ids the chain produced an article
|
|
292
|
+
// for, so we can skip them when building `unavailable[]`.
|
|
293
|
+
const chainByInput = new Map();
|
|
294
|
+
const recoveredIds = new Set();
|
|
295
|
+
// PMCIDs the converter resolved from a pmid → PMID, for back-mapping after
|
|
296
|
+
// the PMC and EPMC stages.
|
|
297
|
+
const pmcidToPmid = new Map();
|
|
298
|
+
// DOI hints captured during pmids→pmcid routing so PMC-misses on the pmids
|
|
299
|
+
// branch can still reach Unpaywall without re-fetching from PubMed metadata.
|
|
300
|
+
const pmidContext = new Map();
|
|
301
|
+
const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
|
|
302
|
+
// ── Branch routing → produce buckets the staged chain consumes ──────────
|
|
215
303
|
let pmcIds = [];
|
|
216
|
-
let
|
|
304
|
+
let pmidFallbackCandidates = [];
|
|
305
|
+
let pmcidFallbackCandidates = [];
|
|
306
|
+
let doiCandidates = [];
|
|
217
307
|
if (input.pmids) {
|
|
308
|
+
for (const id of input.pmids)
|
|
309
|
+
chainByInput.set(id, []);
|
|
218
310
|
const records = await getNcbiService().idConvert(input.pmids, 'pmid', ctx.signal ? { signal: ctx.signal } : undefined);
|
|
219
311
|
const seen = new Set();
|
|
220
312
|
for (const r of records) {
|
|
@@ -223,111 +315,295 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
223
315
|
const pmid = String(r.pmid);
|
|
224
316
|
seen.add(pmid);
|
|
225
317
|
if (r.pmcid) {
|
|
226
|
-
|
|
318
|
+
const normalized = normalizePmcId(String(r.pmcid));
|
|
319
|
+
pmcIds.push(normalized);
|
|
320
|
+
pmcidToPmid.set(withPmcPrefix(normalized), pmid);
|
|
321
|
+
pmidContext.set(pmid, { pmid, ...(r.doi && { doi: r.doi }) });
|
|
227
322
|
}
|
|
228
323
|
else {
|
|
229
|
-
|
|
324
|
+
chainByInput.get(pmid)?.push({
|
|
325
|
+
tier: 'pmc',
|
|
326
|
+
outcome: 'not-attempted',
|
|
327
|
+
detail: 'PMID has no PMC counterpart',
|
|
328
|
+
});
|
|
329
|
+
pmidFallbackCandidates.push({ pmid, ...(r.doi && { doi: r.doi }) });
|
|
230
330
|
}
|
|
231
331
|
}
|
|
232
|
-
// Any requested PMIDs the converter didn't return at all: treat as fallback candidates.
|
|
233
332
|
for (const requested of input.pmids) {
|
|
234
|
-
if (!seen.has(requested))
|
|
235
|
-
|
|
333
|
+
if (!seen.has(requested)) {
|
|
334
|
+
chainByInput.get(requested)?.push({
|
|
335
|
+
tier: 'pmc',
|
|
336
|
+
outcome: 'not-attempted',
|
|
337
|
+
detail: 'ID Converter returned no record for this PMID',
|
|
338
|
+
});
|
|
339
|
+
pmidFallbackCandidates.push({ pmid: requested });
|
|
340
|
+
}
|
|
236
341
|
}
|
|
237
342
|
}
|
|
238
|
-
else {
|
|
239
|
-
|
|
343
|
+
else if (input.pmcids) {
|
|
344
|
+
for (const id of input.pmcids)
|
|
345
|
+
chainByInput.set(withPmcPrefix(normalizePmcId(id)), []);
|
|
346
|
+
pmcIds = input.pmcids.map(normalizePmcId);
|
|
347
|
+
}
|
|
348
|
+
else if (input.dois) {
|
|
349
|
+
for (const doi of input.dois) {
|
|
350
|
+
chainByInput.set(doi, [
|
|
351
|
+
{ tier: 'pmc', outcome: 'not-attempted', detail: 'DOI input bypasses PMC EFetch' },
|
|
352
|
+
]);
|
|
353
|
+
}
|
|
354
|
+
doiCandidates = input.dois.map((doi) => ({ doi }));
|
|
240
355
|
}
|
|
241
|
-
//
|
|
356
|
+
// Route PMC-missed prefixed PMCIDs into the fallback buckets so EPMC and
|
|
357
|
+
// (for pmids) Unpaywall still get a chance. For pmids input we look up the
|
|
358
|
+
// captured DOI hint via `pmidContext` to avoid an extra PubMed eFetch when
|
|
359
|
+
// available; the converter often returns the DOI alongside a PMCID match.
|
|
360
|
+
const routePmcMissesToFallback = (missingPrefixed) => {
|
|
361
|
+
if (missingPrefixed.length === 0)
|
|
362
|
+
return;
|
|
363
|
+
if (input.pmcids) {
|
|
364
|
+
pmcidFallbackCandidates = missingPrefixed.map((pmcid) => ({ pmcid }));
|
|
365
|
+
}
|
|
366
|
+
else if (input.pmids) {
|
|
367
|
+
for (const prefixed of missingPrefixed) {
|
|
368
|
+
const pmid = pmcidToPmid.get(prefixed);
|
|
369
|
+
if (pmid)
|
|
370
|
+
pmidFallbackCandidates.push(pmidContext.get(pmid) ?? { pmid });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
// ── Stage 1: PMC EFetch ─────────────────────────────────────────────────
|
|
375
|
+
// Wrapped so transient NCBI failures fall through to EPMC/Unpaywall rather
|
|
376
|
+
// than sinking the whole batch — the chain's contract is graceful fallback.
|
|
242
377
|
let pmcArticles = [];
|
|
243
|
-
let unavailablePmcIds;
|
|
244
378
|
if (pmcIds.length > 0) {
|
|
245
|
-
|
|
246
|
-
retmode: 'xml',
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
379
|
+
try {
|
|
380
|
+
const xmlData = await getNcbiService().eFetch({ db: 'pmc', id: pmcIds.join(','), retmode: 'xml' }, {
|
|
381
|
+
retmode: 'xml',
|
|
382
|
+
useOrderedParser: true,
|
|
383
|
+
usePost: pmcIds.length > 5,
|
|
384
|
+
signal: ctx.signal,
|
|
385
|
+
});
|
|
386
|
+
const articleSet = findOne(xmlData, 'pmc-articleset');
|
|
387
|
+
if (!articleSet) {
|
|
388
|
+
throw new Error('PMC EFetch response missing pmc-articleset wrapper');
|
|
389
|
+
}
|
|
390
|
+
const parsed = findAll(articleSet, 'article')
|
|
391
|
+
.map(parsePmcArticle)
|
|
392
|
+
.map((a) => applyPmcFilters(a, input));
|
|
393
|
+
pmcArticles = parsed.map((a) => ({
|
|
394
|
+
source: 'pmc',
|
|
395
|
+
viaSource: 'pmc',
|
|
396
|
+
...a,
|
|
397
|
+
}));
|
|
398
|
+
const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
|
|
399
|
+
for (const prefixed of returnedPmcIds) {
|
|
400
|
+
recoveredIds.add(pmcidToPmid.get(prefixed) ?? prefixed);
|
|
401
|
+
}
|
|
402
|
+
const missing = pmcIds
|
|
403
|
+
.map((id) => withPmcPrefix(id))
|
|
404
|
+
.filter((id) => !returnedPmcIds.has(id));
|
|
405
|
+
for (const prefixed of missing) {
|
|
406
|
+
const inputId = pmcidToPmid.get(prefixed) ?? prefixed;
|
|
407
|
+
chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
|
|
408
|
+
}
|
|
409
|
+
routePmcMissesToFallback(missing);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
413
|
+
ctx.log.warning('PMC EFetch failed; chain continues with next layer', {
|
|
414
|
+
pmcIdCount: pmcIds.length,
|
|
415
|
+
error: detail,
|
|
256
416
|
});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
417
|
+
const allPrefixed = pmcIds.map(withPmcPrefix);
|
|
418
|
+
for (const prefixed of allPrefixed) {
|
|
419
|
+
const inputId = pmcidToPmid.get(prefixed) ?? prefixed;
|
|
420
|
+
chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'service-error', detail });
|
|
421
|
+
}
|
|
422
|
+
routePmcMissesToFallback(allPrefixed);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// ── Stage 2: Europe PMC fullTextXML ─────────────────────────────────────
|
|
426
|
+
const epmc = getEuropePmcService();
|
|
427
|
+
const epmcOutcomes = epmc
|
|
428
|
+
? await runEpmcStage(epmc, {
|
|
429
|
+
pmidFallbackCandidates,
|
|
430
|
+
pmcidFallbackCandidates,
|
|
431
|
+
doiCandidates,
|
|
432
|
+
input,
|
|
433
|
+
ctx,
|
|
434
|
+
})
|
|
435
|
+
: {
|
|
436
|
+
articles: [],
|
|
437
|
+
remainingPmid: pmidFallbackCandidates,
|
|
438
|
+
remainingPmcid: pmcidFallbackCandidates,
|
|
439
|
+
remainingDoi: doiCandidates,
|
|
440
|
+
pmidOutcomes: new Map(),
|
|
441
|
+
pmcidOutcomes: new Map(),
|
|
442
|
+
doiOutcomes: new Map(),
|
|
443
|
+
};
|
|
444
|
+
pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
|
|
445
|
+
// Fold EPMC outcomes into each id's chain. EPMC-served articles count as
|
|
446
|
+
// recovered, so their ids are added to `recoveredIds` here.
|
|
447
|
+
if (!epmc) {
|
|
448
|
+
const epmcDisabledEntry = {
|
|
449
|
+
tier: 'europepmc',
|
|
450
|
+
outcome: 'not-attempted',
|
|
451
|
+
detail: 'EUROPEPMC_ENABLED=false',
|
|
452
|
+
};
|
|
453
|
+
for (const c of pmidFallbackCandidates)
|
|
454
|
+
chainByInput.get(c.pmid)?.push(epmcDisabledEntry);
|
|
455
|
+
for (const c of pmcidFallbackCandidates) {
|
|
456
|
+
const prefixed = withPmcPrefix(c.pmcid);
|
|
457
|
+
chainByInput.get(pmcidToPmid.get(prefixed) ?? prefixed)?.push(epmcDisabledEntry);
|
|
458
|
+
}
|
|
459
|
+
for (const c of doiCandidates)
|
|
460
|
+
chainByInput.get(c.doi)?.push(epmcDisabledEntry);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes) {
|
|
464
|
+
if (outcome.kind === 'hit') {
|
|
465
|
+
recoveredIds.add(pmid);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
chainByInput.get(pmid)?.push(epmcTierFromOutcome(outcome));
|
|
261
469
|
}
|
|
262
|
-
|
|
263
|
-
|
|
470
|
+
for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
|
|
471
|
+
const inputId = pmcidToPmid.get(prefixed) ?? prefixed;
|
|
472
|
+
if (outcome.kind === 'hit') {
|
|
473
|
+
recoveredIds.add(inputId);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
chainByInput.get(inputId)?.push(epmcTierFromOutcome(outcome));
|
|
264
477
|
}
|
|
265
|
-
|
|
266
|
-
|
|
478
|
+
for (const [doi, outcome] of epmcOutcomes.doiOutcomes) {
|
|
479
|
+
if (outcome.kind === 'hit') {
|
|
480
|
+
recoveredIds.add(doi);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
chainByInput.get(doi)?.push(epmcTierFromOutcome(outcome));
|
|
267
484
|
}
|
|
268
|
-
pmcArticles = parsed.map((a) => ({ source: 'pmc', ...a }));
|
|
269
|
-
const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId));
|
|
270
|
-
const missing = pmcIds.map((id) => `PMC${id}`).filter((id) => !returnedPmcIds.has(id));
|
|
271
|
-
if (missing.length > 0)
|
|
272
|
-
unavailablePmcIds = missing;
|
|
273
485
|
}
|
|
274
|
-
|
|
486
|
+
pmidFallbackCandidates = epmcOutcomes.remainingPmid;
|
|
487
|
+
pmcidFallbackCandidates = epmcOutcomes.remainingPmcid;
|
|
488
|
+
doiCandidates = epmcOutcomes.remainingDoi;
|
|
489
|
+
// ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
|
|
275
490
|
const unpaywall = getUnpaywallService();
|
|
276
|
-
const unavailable = [];
|
|
277
491
|
const fallbackArticles = [];
|
|
278
|
-
|
|
492
|
+
// PMC misses on `pmcids` input don't get an Unpaywall attempt — the current
|
|
493
|
+
// implementation doesn't resolve PMCID → DOI for that branch.
|
|
494
|
+
for (const c of pmcidFallbackCandidates) {
|
|
495
|
+
const prefixed = withPmcPrefix(c.pmcid);
|
|
496
|
+
chainByInput.get(pmcidToPmid.get(prefixed) ?? prefixed)?.push({
|
|
497
|
+
tier: 'unpaywall',
|
|
498
|
+
outcome: 'not-attempted',
|
|
499
|
+
detail: 'pmcids input does not resolve a DOI for Unpaywall',
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
if (pmidFallbackCandidates.length > 0) {
|
|
503
|
+
// The PMC ID Converter only returns DOIs for articles it has in PMC, so
|
|
504
|
+
// candidates here are missing DOIs by default. Pull them from PubMed
|
|
505
|
+
// metadata (db=pubmed) before dispatching to Unpaywall.
|
|
506
|
+
const needDoi = pmidFallbackCandidates.filter((c) => !c.doi).map((c) => c.pmid);
|
|
507
|
+
if (needDoi.length > 0) {
|
|
508
|
+
try {
|
|
509
|
+
const doiMap = await fetchPubmedDois(needDoi, ctx.signal);
|
|
510
|
+
pmidFallbackCandidates = pmidFallbackCandidates.map((c) => {
|
|
511
|
+
if (c.doi)
|
|
512
|
+
return c;
|
|
513
|
+
const doi = doiMap.get(c.pmid);
|
|
514
|
+
return doi ? { ...c, doi } : c;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
|
|
519
|
+
error: error instanceof Error ? error.message : String(error),
|
|
520
|
+
pmidCount: needDoi.length,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
279
524
|
if (!unpaywall) {
|
|
280
|
-
for (const c of
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
detail: '
|
|
525
|
+
for (const c of pmidFallbackCandidates) {
|
|
526
|
+
chainByInput.get(c.pmid)?.push({
|
|
527
|
+
tier: 'unpaywall',
|
|
528
|
+
outcome: 'not-attempted',
|
|
529
|
+
detail: 'UNPAYWALL_EMAIL is not set',
|
|
285
530
|
});
|
|
286
531
|
}
|
|
287
532
|
}
|
|
288
533
|
else {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const doi = doiMap.get(c.pmid);
|
|
300
|
-
return doi ? { ...c, doi } : c;
|
|
301
|
-
});
|
|
534
|
+
const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
|
|
535
|
+
candidate,
|
|
536
|
+
result: candidate.doi
|
|
537
|
+
? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi }, unpaywall, ctx)
|
|
538
|
+
: { unavailable: { reason: 'no-doi' } },
|
|
539
|
+
})));
|
|
540
|
+
for (const { candidate, result } of outcomes) {
|
|
541
|
+
if ('article' in result) {
|
|
542
|
+
fallbackArticles.push(result.article);
|
|
543
|
+
recoveredIds.add(candidate.pmid);
|
|
302
544
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
545
|
+
else {
|
|
546
|
+
const u = result.unavailable;
|
|
547
|
+
chainByInput.get(candidate.pmid)?.push({
|
|
548
|
+
tier: 'unpaywall',
|
|
549
|
+
outcome: unpaywallReasonToTierOutcome(u.reason),
|
|
550
|
+
...(u.detail && { detail: u.detail }),
|
|
307
551
|
});
|
|
308
552
|
}
|
|
309
553
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (doiCandidates.length > 0) {
|
|
557
|
+
if (!unpaywall) {
|
|
558
|
+
for (const c of doiCandidates) {
|
|
559
|
+
chainByInput.get(c.doi)?.push({
|
|
560
|
+
tier: 'unpaywall',
|
|
561
|
+
outcome: 'not-attempted',
|
|
562
|
+
detail: 'UNPAYWALL_EMAIL is not set',
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
// `resolveUnpaywall` catches its own failures so this Promise.all
|
|
568
|
+
// doesn't reject under normal operation.
|
|
569
|
+
const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
|
|
570
|
+
doi: c.doi,
|
|
571
|
+
result: await resolveUnpaywall({ doi: c.doi }, unpaywall, ctx),
|
|
572
|
+
})));
|
|
573
|
+
for (const { doi, result } of outcomes) {
|
|
574
|
+
if ('article' in result) {
|
|
575
|
+
fallbackArticles.push(result.article);
|
|
576
|
+
recoveredIds.add(doi);
|
|
317
577
|
}
|
|
318
578
|
else {
|
|
319
|
-
|
|
320
|
-
|
|
579
|
+
const u = result.unavailable;
|
|
580
|
+
chainByInput.get(doi)?.push({
|
|
581
|
+
tier: 'unpaywall',
|
|
582
|
+
outcome: unpaywallReasonToTierOutcome(u.reason),
|
|
583
|
+
...(u.detail && { detail: u.detail }),
|
|
321
584
|
});
|
|
322
585
|
}
|
|
323
586
|
}
|
|
324
587
|
}
|
|
325
588
|
}
|
|
589
|
+
// ── Assemble unavailable[] from chains ──────────────────────────────────
|
|
590
|
+
const unavailable = [];
|
|
591
|
+
for (const [id, chain] of chainByInput) {
|
|
592
|
+
if (recoveredIds.has(id))
|
|
593
|
+
continue;
|
|
594
|
+
unavailable.push({
|
|
595
|
+
id,
|
|
596
|
+
idType,
|
|
597
|
+
reason: reasonFromChain(chain),
|
|
598
|
+
triedTiers: chain,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
326
601
|
const articles = [...pmcArticles, ...fallbackArticles];
|
|
327
602
|
ctx.log.info('pubmed_fetch_fulltext completed', {
|
|
328
|
-
requested: (input.pmids ?? input.pmcids)?.length ?? 0,
|
|
603
|
+
requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
|
|
329
604
|
returned: articles.length,
|
|
330
|
-
pmcHits: pmcArticles.length,
|
|
605
|
+
pmcHits: pmcArticles.filter((a) => a.viaSource === 'pmc').length,
|
|
606
|
+
epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
|
|
331
607
|
unpaywallHits: fallbackArticles.length,
|
|
332
608
|
unavailable: unavailable.length,
|
|
333
609
|
});
|
|
@@ -335,22 +611,23 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
335
611
|
articles,
|
|
336
612
|
totalReturned: articles.length,
|
|
337
613
|
...(unavailable.length > 0 && { unavailable }),
|
|
338
|
-
...(unavailablePmcIds && { unavailablePmcIds }),
|
|
339
614
|
};
|
|
340
615
|
},
|
|
341
616
|
format: (result) => {
|
|
342
617
|
const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
|
|
343
618
|
if (result.unavailable?.length) {
|
|
344
|
-
lines.push(`\n**Unavailable
|
|
619
|
+
lines.push(`\n**Unavailable (${result.unavailable.length}):**`);
|
|
345
620
|
for (const u of result.unavailable) {
|
|
346
|
-
lines.push(`- ${u.
|
|
621
|
+
lines.push(`- [${u.idType}] ${u.id} — ${u.reason}`);
|
|
622
|
+
const chain = u.triedTiers
|
|
623
|
+
.map((t) => `${t.tier}:${t.outcome}${t.detail ? ` (${t.detail})` : ''}`)
|
|
624
|
+
.join(' → ');
|
|
625
|
+
if (chain)
|
|
626
|
+
lines.push(` chain: ${chain}`);
|
|
347
627
|
}
|
|
348
628
|
}
|
|
349
|
-
if (result.unavailablePmcIds?.length) {
|
|
350
|
-
lines.push(`**Unavailable PMC IDs:** ${result.unavailablePmcIds.join(', ')}`);
|
|
351
|
-
}
|
|
352
629
|
if (result.totalReturned === 0) {
|
|
353
|
-
lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC
|
|
630
|
+
lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC, Europe PMC, or recoverable via Unpaywall to retrieve full text. For metadata and abstracts only, use \`pubmed_fetch_articles\`.`);
|
|
354
631
|
}
|
|
355
632
|
for (const a of result.articles) {
|
|
356
633
|
lines.push('');
|
|
@@ -362,6 +639,170 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
362
639
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
363
640
|
},
|
|
364
641
|
});
|
|
642
|
+
/**
|
|
643
|
+
* Run the Europe PMC step against everything that fell through PMC EFetch
|
|
644
|
+
* plus any direct DOI input. Each candidate goes through search-by-best-id →
|
|
645
|
+
* fullTextXML. Hits become `source: 'pmc'` articles with `viaSource: 'europepmc'`;
|
|
646
|
+
* misses flow through to the Unpaywall stage unchanged.
|
|
647
|
+
*
|
|
648
|
+
* Candidates run in parallel — the EPMC request queue caps concurrency so this
|
|
649
|
+
* stays polite without serializing. Errors are caught and logged inside the
|
|
650
|
+
* helpers; a transient EPMC failure must not block the downstream Unpaywall
|
|
651
|
+
* fallback.
|
|
652
|
+
*/
|
|
653
|
+
async function runEpmcStage(epmc, args) {
|
|
654
|
+
const runOne = async (c, query, contextPmid) => {
|
|
655
|
+
const search = await searchEpmcSafe(epmc, query, args.ctx);
|
|
656
|
+
if (search.kind === 'error') {
|
|
657
|
+
return { c, outcome: { kind: 'service-error', detail: search.detail } };
|
|
658
|
+
}
|
|
659
|
+
if (search.kind === 'miss')
|
|
660
|
+
return { c, outcome: { kind: 'miss' } };
|
|
661
|
+
const fetched = await fetchEpmcArticle(epmc, search.hit, args, contextPmid);
|
|
662
|
+
if (fetched.kind === 'error') {
|
|
663
|
+
return { c, outcome: { kind: 'service-error', detail: fetched.detail } };
|
|
664
|
+
}
|
|
665
|
+
if (fetched.kind === 'no-fulltext') {
|
|
666
|
+
return {
|
|
667
|
+
c,
|
|
668
|
+
outcome: { kind: 'no-fulltext', ...(fetched.detail && { detail: fetched.detail }) },
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return { c, outcome: { kind: 'hit' }, article: fetched.article };
|
|
672
|
+
};
|
|
673
|
+
const fetchForPmid = (c) => runOne(c, `EXT_ID:"${c.pmid}" AND SRC:MED`, c.pmid);
|
|
674
|
+
const fetchForPmcid = (c) => {
|
|
675
|
+
const normalized = withPmcPrefix(c.pmcid);
|
|
676
|
+
return runOne({ c, normalized }, `PMCID:"${normalized}" AND SRC:PMC`, undefined);
|
|
677
|
+
};
|
|
678
|
+
const fetchForDoi = (c) => runOne(c, `DOI:"${c.doi}"`, undefined);
|
|
679
|
+
const [pmidResults, pmcidResults, doiResults] = await Promise.all([
|
|
680
|
+
Promise.all(args.pmidFallbackCandidates.map(fetchForPmid)),
|
|
681
|
+
Promise.all(args.pmcidFallbackCandidates.map(fetchForPmcid)),
|
|
682
|
+
Promise.all(args.doiCandidates.map(fetchForDoi)),
|
|
683
|
+
]);
|
|
684
|
+
const articles = [];
|
|
685
|
+
const remainingPmid = [];
|
|
686
|
+
const remainingPmcid = [];
|
|
687
|
+
const remainingDoi = [];
|
|
688
|
+
const pmidOutcomes = new Map();
|
|
689
|
+
const pmcidOutcomes = new Map();
|
|
690
|
+
const doiOutcomes = new Map();
|
|
691
|
+
for (const { c, outcome, article } of pmidResults) {
|
|
692
|
+
pmidOutcomes.set(c.pmid, outcome);
|
|
693
|
+
if (article)
|
|
694
|
+
articles.push(article);
|
|
695
|
+
else
|
|
696
|
+
remainingPmid.push(c);
|
|
697
|
+
}
|
|
698
|
+
for (const { c: pair, outcome, article } of pmcidResults) {
|
|
699
|
+
pmcidOutcomes.set(pair.normalized, outcome);
|
|
700
|
+
if (article)
|
|
701
|
+
articles.push(article);
|
|
702
|
+
else
|
|
703
|
+
remainingPmcid.push(pair.c);
|
|
704
|
+
}
|
|
705
|
+
for (const { c, outcome, article } of doiResults) {
|
|
706
|
+
doiOutcomes.set(c.doi, outcome);
|
|
707
|
+
if (article)
|
|
708
|
+
articles.push(article);
|
|
709
|
+
else
|
|
710
|
+
remainingDoi.push(c);
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
articles,
|
|
714
|
+
remainingPmid,
|
|
715
|
+
remainingPmcid,
|
|
716
|
+
remainingDoi,
|
|
717
|
+
pmidOutcomes,
|
|
718
|
+
pmcidOutcomes,
|
|
719
|
+
doiOutcomes,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Single-hit Europe PMC search with discriminated outcomes so the chain can
|
|
724
|
+
* record `miss` vs `service-error` separately. Errors are logged and swallowed
|
|
725
|
+
* so transient EPMC failures fall through to the next stage instead of
|
|
726
|
+
* aborting the chain.
|
|
727
|
+
*/
|
|
728
|
+
async function searchEpmcSafe(epmc, query, ctx) {
|
|
729
|
+
try {
|
|
730
|
+
const result = await epmc.search({
|
|
731
|
+
query,
|
|
732
|
+
resultType: 'core',
|
|
733
|
+
pageSize: 1,
|
|
734
|
+
...(ctx.signal && { signal: ctx.signal }),
|
|
735
|
+
});
|
|
736
|
+
return result.hits[0] ? { kind: 'hit', hit: result.hits[0] } : { kind: 'miss' };
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
740
|
+
ctx.log.warning('Europe PMC search failed; chain continues with next layer', {
|
|
741
|
+
query,
|
|
742
|
+
error: detail,
|
|
743
|
+
});
|
|
744
|
+
return { kind: 'error', detail };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Fetch and parse the JATS for an EPMC hit. Returns a discriminated outcome so
|
|
749
|
+
* the chain can record `no-fulltext` (record exists but EPMC publishes no JATS)
|
|
750
|
+
* separately from `service-error` (transient failure). Preprints/patents and
|
|
751
|
+
* MED-only records without a PMC counterpart short-circuit to `no-fulltext`
|
|
752
|
+
* since EPMC's fullTextXML endpoint is PMC-keyed.
|
|
753
|
+
*/
|
|
754
|
+
async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
|
|
755
|
+
// EPMC's fullTextXML endpoint is PMC-keyed (URL: `/{PMC<digits>}/fullTextXML`).
|
|
756
|
+
// For PMC-source hits, `hit.id` already is the PMC ID; for MED hits, `hit.pmcid`
|
|
757
|
+
// carries the counterpart when one exists. Preprints (PPR) and patents (PAT)
|
|
758
|
+
// have no PMC ID, so fullTextXML is never available.
|
|
759
|
+
const pmcLookupId = hit.pmcid ?? (hit.source === 'PMC' ? hit.id : undefined);
|
|
760
|
+
if (!pmcLookupId) {
|
|
761
|
+
return { kind: 'no-fulltext', detail: `EPMC source ${hit.source} has no PMC counterpart` };
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
const result = await epmc.fullTextXml(pmcLookupId, hit.source, args.ctx.signal ?? undefined);
|
|
765
|
+
if (result.kind === 'not-available') {
|
|
766
|
+
return { kind: 'no-fulltext', detail: 'EPMC fullTextXML not available for this record' };
|
|
767
|
+
}
|
|
768
|
+
const articleNode = epmc.parseFullTextXml(result.xml);
|
|
769
|
+
if (!articleNode) {
|
|
770
|
+
return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
|
|
771
|
+
}
|
|
772
|
+
const parsed = applyPmcFilters(parsePmcArticle(articleNode), args.input);
|
|
773
|
+
// `parsePmcArticle` always returns string fields (sometimes empty). Strip
|
|
774
|
+
// empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
|
|
775
|
+
// optional shape is respected — agents read `epmcId`/`epmcSource` for those.
|
|
776
|
+
const { pmcId, pmcUrl, ...rest } = parsed;
|
|
777
|
+
const pmid = rest.pmid ?? hit.pmid ?? contextPmid;
|
|
778
|
+
const doi = rest.doi ?? hit.doi;
|
|
779
|
+
return {
|
|
780
|
+
kind: 'article',
|
|
781
|
+
article: {
|
|
782
|
+
source: 'pmc',
|
|
783
|
+
viaSource: 'europepmc',
|
|
784
|
+
...rest,
|
|
785
|
+
...(pmcId && { pmcId, pmcUrl }),
|
|
786
|
+
...(pmid && {
|
|
787
|
+
pmid,
|
|
788
|
+
pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
|
|
789
|
+
}),
|
|
790
|
+
...(doi && { doi }),
|
|
791
|
+
epmcId: hit.id,
|
|
792
|
+
epmcSource: hit.source,
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
798
|
+
args.ctx.log.warning('Europe PMC fullTextXML failed; chain continues with next layer', {
|
|
799
|
+
epmcId: hit.id,
|
|
800
|
+
source: hit.source,
|
|
801
|
+
error: detail,
|
|
802
|
+
});
|
|
803
|
+
return { kind: 'error', detail };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
365
806
|
/**
|
|
366
807
|
* Batch-fetch DOIs from PubMed metadata for PMIDs that lack one after the PMC
|
|
367
808
|
* ID Converter roundtrip. The Converter only returns DOIs for articles already
|
|
@@ -389,23 +830,24 @@ async function fetchPubmedDois(pmids, signal) {
|
|
|
389
830
|
}
|
|
390
831
|
return out;
|
|
391
832
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
833
|
+
/**
|
|
834
|
+
* Resolve a DOI to an open-access article via Unpaywall. `pmid`, when set,
|
|
835
|
+
* is stamped onto the resulting article so the pmid-input branch carries its
|
|
836
|
+
* cross-reference through.
|
|
837
|
+
*/
|
|
838
|
+
async function resolveUnpaywall(args, service, ctx) {
|
|
839
|
+
const { pmid, doi } = args;
|
|
399
840
|
let resolution;
|
|
400
841
|
try {
|
|
401
842
|
resolution = await service.resolve(doi, ctx.signal);
|
|
402
843
|
}
|
|
403
844
|
catch (error) {
|
|
404
845
|
const detail = error instanceof Error ? error.message : String(error);
|
|
405
|
-
|
|
846
|
+
ctx.log.warning('Unpaywall DOI resolve failed', { doi, error: detail });
|
|
847
|
+
return { unavailable: { reason: 'service-error', detail } };
|
|
406
848
|
}
|
|
407
849
|
if (resolution.kind === 'no-oa') {
|
|
408
|
-
return { unavailable: {
|
|
850
|
+
return { unavailable: { reason: 'no-oa', detail: resolution.reason } };
|
|
409
851
|
}
|
|
410
852
|
let content;
|
|
411
853
|
try {
|
|
@@ -413,7 +855,8 @@ async function resolveViaUnpaywall(candidate, ctx) {
|
|
|
413
855
|
}
|
|
414
856
|
catch (error) {
|
|
415
857
|
const detail = error instanceof Error ? error.message : String(error);
|
|
416
|
-
|
|
858
|
+
ctx.log.warning('Unpaywall content fetch failed', { doi, error: detail });
|
|
859
|
+
return { unavailable: { reason: 'fetch-failed', detail } };
|
|
417
860
|
}
|
|
418
861
|
try {
|
|
419
862
|
if (content.kind === 'html') {
|
|
@@ -425,7 +868,6 @@ async function resolveViaUnpaywall(candidate, ctx) {
|
|
|
425
868
|
if (!body) {
|
|
426
869
|
return {
|
|
427
870
|
unavailable: {
|
|
428
|
-
pmid,
|
|
429
871
|
reason: 'parse-failed',
|
|
430
872
|
detail: 'HTML extraction produced empty content',
|
|
431
873
|
},
|
|
@@ -433,7 +875,7 @@ async function resolveViaUnpaywall(candidate, ctx) {
|
|
|
433
875
|
}
|
|
434
876
|
return {
|
|
435
877
|
article: buildUnpaywallArticle({
|
|
436
|
-
pmid,
|
|
878
|
+
...(pmid && { pmid }),
|
|
437
879
|
doi,
|
|
438
880
|
sourceUrl: content.fetchedUrl,
|
|
439
881
|
location: resolution.location,
|
|
@@ -448,16 +890,12 @@ async function resolveViaUnpaywall(candidate, ctx) {
|
|
|
448
890
|
const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
|
|
449
891
|
if (!text) {
|
|
450
892
|
return {
|
|
451
|
-
unavailable: {
|
|
452
|
-
pmid,
|
|
453
|
-
reason: 'parse-failed',
|
|
454
|
-
detail: 'PDF extraction produced empty text',
|
|
455
|
-
},
|
|
893
|
+
unavailable: { reason: 'parse-failed', detail: 'PDF extraction produced empty text' },
|
|
456
894
|
};
|
|
457
895
|
}
|
|
458
896
|
return {
|
|
459
897
|
article: buildUnpaywallArticle({
|
|
460
|
-
pmid,
|
|
898
|
+
...(pmid && { pmid }),
|
|
461
899
|
doi,
|
|
462
900
|
sourceUrl: content.fetchedUrl,
|
|
463
901
|
location: resolution.location,
|
|
@@ -470,16 +908,19 @@ async function resolveViaUnpaywall(candidate, ctx) {
|
|
|
470
908
|
catch (error) {
|
|
471
909
|
const detail = error instanceof Error ? error.message : String(error);
|
|
472
910
|
ctx.log.warning('Unpaywall content extraction failed', { pmid, doi, detail });
|
|
473
|
-
return { unavailable: {
|
|
911
|
+
return { unavailable: { reason: 'parse-failed', detail } };
|
|
474
912
|
}
|
|
475
913
|
}
|
|
476
914
|
function buildUnpaywallArticle(args) {
|
|
477
915
|
const { location } = args;
|
|
478
916
|
return {
|
|
479
917
|
source: 'unpaywall',
|
|
918
|
+
viaSource: 'unpaywall',
|
|
480
919
|
contentFormat: args.contentFormat,
|
|
481
|
-
|
|
482
|
-
|
|
920
|
+
...(args.pmid && {
|
|
921
|
+
pmid: args.pmid,
|
|
922
|
+
pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
|
|
923
|
+
}),
|
|
483
924
|
doi: args.doi,
|
|
484
925
|
sourceUrl: args.sourceUrl,
|
|
485
926
|
content: args.content,
|
|
@@ -491,10 +932,93 @@ function buildUnpaywallArticle(args) {
|
|
|
491
932
|
...(location.version && { version: location.version }),
|
|
492
933
|
};
|
|
493
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* Convert an EPMC stage outcome into the `triedTiers` entry stored on
|
|
937
|
+
* `chainByInput`. `hit` is filtered before calling — the chain only records
|
|
938
|
+
* failure outcomes since recovered ids never appear in `unavailable[]`.
|
|
939
|
+
*/
|
|
940
|
+
function epmcTierFromOutcome(outcome) {
|
|
941
|
+
switch (outcome.kind) {
|
|
942
|
+
case 'miss':
|
|
943
|
+
return { tier: 'europepmc', outcome: 'miss' };
|
|
944
|
+
case 'no-fulltext':
|
|
945
|
+
return {
|
|
946
|
+
tier: 'europepmc',
|
|
947
|
+
outcome: 'no-fulltext',
|
|
948
|
+
...(outcome.detail && { detail: outcome.detail }),
|
|
949
|
+
};
|
|
950
|
+
case 'service-error':
|
|
951
|
+
return { tier: 'europepmc', outcome: 'service-error', detail: outcome.detail };
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Map an Unpaywall-resolver `UnavailableReason` to its `TierOutcome`
|
|
956
|
+
* counterpart. The two enums overlap on the values the Unpaywall path can
|
|
957
|
+
* actually emit (`no-doi`, `no-oa`, `fetch-failed`, `parse-failed`,
|
|
958
|
+
* `service-error`). Defensive branches cover values the resolver returns under
|
|
959
|
+
* dead-code safety checks but never in normal flow.
|
|
960
|
+
*/
|
|
961
|
+
function unpaywallReasonToTierOutcome(reason) {
|
|
962
|
+
switch (reason) {
|
|
963
|
+
case 'no-doi':
|
|
964
|
+
case 'no-oa':
|
|
965
|
+
case 'fetch-failed':
|
|
966
|
+
case 'parse-failed':
|
|
967
|
+
case 'service-error':
|
|
968
|
+
return reason;
|
|
969
|
+
case 'no-pmc-fallback-disabled':
|
|
970
|
+
return 'not-attempted';
|
|
971
|
+
case 'no-epmc-fulltext':
|
|
972
|
+
return 'no-fulltext';
|
|
973
|
+
case 'not-found':
|
|
974
|
+
return 'miss';
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Derive the terminal `reason` shown on the unavailable entry from its chain.
|
|
979
|
+
* Skips `not-attempted` entries when summarizing — those record config state,
|
|
980
|
+
* not content state, so they make a misleading `reason` when an earlier tier
|
|
981
|
+
* produced a real signal (`pmc:miss`, `unpaywall:no-oa`, etc.). Only when every
|
|
982
|
+
* tier was skipped does `reason` fall back to `no-pmc-fallback-disabled`.
|
|
983
|
+
*/
|
|
984
|
+
function reasonFromChain(chain) {
|
|
985
|
+
let lastSignal;
|
|
986
|
+
for (const t of chain) {
|
|
987
|
+
if (t.outcome !== 'not-attempted')
|
|
988
|
+
lastSignal = t;
|
|
989
|
+
}
|
|
990
|
+
if (!lastSignal)
|
|
991
|
+
return 'no-pmc-fallback-disabled';
|
|
992
|
+
const key = `${lastSignal.tier}:${lastSignal.outcome}`;
|
|
993
|
+
switch (key) {
|
|
994
|
+
case 'pmc:miss':
|
|
995
|
+
case 'europepmc:miss':
|
|
996
|
+
return 'not-found';
|
|
997
|
+
case 'europepmc:no-fulltext':
|
|
998
|
+
return 'no-epmc-fulltext';
|
|
999
|
+
case 'unpaywall:no-doi':
|
|
1000
|
+
return 'no-doi';
|
|
1001
|
+
case 'unpaywall:no-oa':
|
|
1002
|
+
return 'no-oa';
|
|
1003
|
+
case 'unpaywall:fetch-failed':
|
|
1004
|
+
return 'fetch-failed';
|
|
1005
|
+
case 'unpaywall:parse-failed':
|
|
1006
|
+
return 'parse-failed';
|
|
1007
|
+
case 'pmc:service-error':
|
|
1008
|
+
case 'unpaywall:service-error':
|
|
1009
|
+
case 'europepmc:service-error':
|
|
1010
|
+
return 'service-error';
|
|
1011
|
+
default:
|
|
1012
|
+
return 'not-found';
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
494
1015
|
// ─── format() helpers ────────────────────────────────────────────────────────
|
|
495
1016
|
function formatPmcArticle(a, lines) {
|
|
496
1017
|
lines.push(`### ${a.title ?? a.pmcId}`);
|
|
497
|
-
|
|
1018
|
+
const sourceLabel = a.viaSource === 'europepmc'
|
|
1019
|
+
? `Europe PMC (structured JATS${a.epmcSource ? `, source: ${a.epmcSource}` : ''})`
|
|
1020
|
+
: 'PMC (structured JATS)';
|
|
1021
|
+
lines.push(`**Source:** ${sourceLabel}`);
|
|
498
1022
|
if (a.authors?.length) {
|
|
499
1023
|
lines.push(`\n**Authors (${a.authors.length}):**`);
|
|
500
1024
|
for (const au of a.authors)
|
|
@@ -526,12 +1050,16 @@ function formatPmcArticle(a, lines) {
|
|
|
526
1050
|
if (dateParts.length)
|
|
527
1051
|
lines.push(`**Published:** ${dateParts.join('-')}`);
|
|
528
1052
|
}
|
|
529
|
-
|
|
1053
|
+
if (a.pmcId)
|
|
1054
|
+
lines.push(`**PMCID:** ${a.pmcId}`);
|
|
1055
|
+
if (a.epmcId)
|
|
1056
|
+
lines.push(`**EPMC ID:** ${a.epmcId}${a.epmcSource ? ` (${a.epmcSource})` : ''}`);
|
|
530
1057
|
if (a.pmid)
|
|
531
1058
|
lines.push(`**PMID:** ${a.pmid}`);
|
|
532
1059
|
if (a.doi)
|
|
533
1060
|
lines.push(`**DOI:** ${a.doi}`);
|
|
534
|
-
|
|
1061
|
+
if (a.pmcUrl)
|
|
1062
|
+
lines.push(`**PMC:** ${a.pmcUrl}`);
|
|
535
1063
|
if (a.pubmedUrl)
|
|
536
1064
|
lines.push(`**PubMed:** ${a.pubmedUrl}`);
|
|
537
1065
|
if (a.keywords?.length)
|
|
@@ -561,15 +1089,17 @@ function formatPmcArticle(a, lines) {
|
|
|
561
1089
|
}
|
|
562
1090
|
}
|
|
563
1091
|
function formatUnpaywallArticle(a, lines) {
|
|
564
|
-
const heading = a.title ?? `PMID ${a.pmid}
|
|
1092
|
+
const heading = a.title ?? (a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`);
|
|
565
1093
|
const formatLabel = a.contentFormat === 'html-markdown'
|
|
566
1094
|
? 'Unpaywall (HTML → Markdown, best-effort)'
|
|
567
1095
|
: 'Unpaywall (PDF → plain text)';
|
|
568
1096
|
lines.push(`### ${heading}`);
|
|
569
1097
|
lines.push(`**Source:** ${formatLabel}`);
|
|
570
|
-
|
|
1098
|
+
if (a.pmid)
|
|
1099
|
+
lines.push(`**PMID:** ${a.pmid}`);
|
|
571
1100
|
lines.push(`**DOI:** ${a.doi}`);
|
|
572
|
-
|
|
1101
|
+
if (a.pubmedUrl)
|
|
1102
|
+
lines.push(`**PubMed:** ${a.pubmedUrl}`);
|
|
573
1103
|
lines.push(`**OA Copy:** ${a.sourceUrl}`);
|
|
574
1104
|
if (a.license)
|
|
575
1105
|
lines.push(`**License:** ${a.license}`);
|