@cyanheads/pubmed-mcp-server 2.9.9 → 2.10.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/AGENTS.md +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +19 -4
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_text.d.ts +22 -0
- package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_text.js +32 -0
- package/dist/mcp-server/tools/definitions/_text.js.map +1 -0
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +35 -0
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +634 -123
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +87 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +195 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +29 -8
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts +14 -1
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +21 -25
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +21 -7
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +39 -6
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +9 -0
- package/dist/services/europe-pmc/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts +15 -1
- package/dist/services/openalex/api-client.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +21 -24
- package/dist/services/openalex/api-client.js.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -30,6 +30,7 @@ import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
|
|
|
30
30
|
import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
|
|
31
31
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
|
|
32
32
|
import { pmidStringSchema } from './_schemas.js';
|
|
33
|
+
import { sliceCodeUnits } from './_text.js';
|
|
33
34
|
function normalizePmcId(id) {
|
|
34
35
|
return id.replace(/^PMC/i, '');
|
|
35
36
|
}
|
|
@@ -64,10 +65,22 @@ function applyPmcFilters(article, filters) {
|
|
|
64
65
|
function isSectionFilterMiss(before, after, sectionFilter) {
|
|
65
66
|
return (Boolean(sectionFilter?.length) && before.sections.length > 0 && after.sections.length === 0);
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
68
|
+
/**
|
|
69
|
+
* True when the upstream JATS carried no body sections at all — front matter and
|
|
70
|
+
* abstract only. Publishers that block full-text XML download still return an
|
|
71
|
+
* `<article>` with a populated `<front>`, so the parsed article looks like a hit
|
|
72
|
+
* while carrying nothing to read. Distinct from {@link isSectionFilterMiss},
|
|
73
|
+
* which needs a non-empty pre-filter body: the two never overlap. Evaluated
|
|
74
|
+
* against the *pre-filter* article so a `sections` filter can't be mistaken for
|
|
75
|
+
* an upstream absence. (#86)
|
|
76
|
+
*/
|
|
77
|
+
function isBodylessArticle(before) {
|
|
78
|
+
return before.sections.length === 0;
|
|
79
|
+
}
|
|
80
|
+
/** Pick the best human-readable identifier for an article, for recovery notices
|
|
81
|
+
* and character-budget accounting. Treats empty strings as absent — EPMC-only
|
|
69
82
|
* records carry an empty `pmcId`. */
|
|
70
|
-
function
|
|
83
|
+
function articleDisplayId(a) {
|
|
71
84
|
return [a.pmcId, a.pmid, a.doi, a.epmcId].find((v) => v && v.length > 0) ?? 'article';
|
|
72
85
|
}
|
|
73
86
|
/**
|
|
@@ -80,6 +93,21 @@ function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
|
|
|
80
93
|
const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
|
|
81
94
|
return `No body sections matched the requested section filter (${terms}) for ${subject}. The full text was retrieved but every body section was filtered out. Retry without \`sections\`, or filter on broader headings such as Introduction, Methods, Results, or Discussion.`;
|
|
82
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Compose the recovery notice for identifiers whose only retrievable record was
|
|
98
|
+
* metadata-only — PMC or Europe PMC returned front matter with no body, and no
|
|
99
|
+
* later tier recovered a full-text copy. Points at the tool that still serves
|
|
100
|
+
* the abstract so the metadata isn't simply lost.
|
|
101
|
+
*
|
|
102
|
+
* States what the chain observed rather than asserting the article has no body:
|
|
103
|
+
* a later tier may well have located an open-access copy and failed to download
|
|
104
|
+
* it (`unpaywall:fetch-failed`), so the per-tier outcomes are the honest answer
|
|
105
|
+
* and the notice defers to them. (#86)
|
|
106
|
+
*/
|
|
107
|
+
function buildBodylessNotice(affectedIds) {
|
|
108
|
+
const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
|
|
109
|
+
return `No body text could be retrieved for ${subject} — the full-text source returned front matter and abstract only, and no later tier recovered a copy. See \`triedTiers\` on the \`unavailable\` entry for what each tier reported, and use \`pubmed_fetch_articles\` for the abstract and metadata.`;
|
|
110
|
+
}
|
|
83
111
|
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
|
84
112
|
const SubsectionSchema = z
|
|
85
113
|
.object({
|
|
@@ -173,10 +201,14 @@ const UnpaywallArticleSchema = z
|
|
|
173
201
|
contentFormat: z
|
|
174
202
|
.enum(['html-markdown', 'pdf-text'])
|
|
175
203
|
.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.'),
|
|
204
|
+
pmcId: z
|
|
205
|
+
.string()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('PMC ID this article was requested under, in `PMC<digits>` form — present for `pmcids` input, absent for `pmids` and `dois` input. Ties the article back to the requested identifier, which `unavailable[]` keys on for the ids that found nothing.'),
|
|
176
208
|
pmid: z
|
|
177
209
|
.string()
|
|
178
210
|
.optional()
|
|
179
|
-
.describe('PubMed ID when input was `pmids`; absent for `dois` input'),
|
|
211
|
+
.describe('PubMed ID when input was `pmids`; absent for `pmcids` and `dois` input'),
|
|
180
212
|
pubmedUrl: z.string().optional().describe('PubMed URL — present when `pmid` is set'),
|
|
181
213
|
doi: z.string().describe('DOI used to locate the open-access copy'),
|
|
182
214
|
sourceUrl: z.string().describe('URL the content was fetched from'),
|
|
@@ -209,25 +241,27 @@ const UnavailableReasonSchema = z
|
|
|
209
241
|
'not-found',
|
|
210
242
|
'no-pmc-fallback-disabled',
|
|
211
243
|
'no-epmc-fulltext',
|
|
244
|
+
'no-body',
|
|
212
245
|
'no-doi',
|
|
213
246
|
'no-oa',
|
|
214
247
|
'fetch-failed',
|
|
215
248
|
'parse-failed',
|
|
216
249
|
'service-error',
|
|
217
250
|
])
|
|
218
|
-
.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).');
|
|
251
|
+
.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-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. 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).');
|
|
219
252
|
const TierOutcomeSchema = z
|
|
220
253
|
.enum([
|
|
221
254
|
'not-attempted',
|
|
222
255
|
'miss',
|
|
223
256
|
'no-fulltext',
|
|
257
|
+
'no-body',
|
|
224
258
|
'no-doi',
|
|
225
259
|
'no-oa',
|
|
226
260
|
'fetch-failed',
|
|
227
261
|
'parse-failed',
|
|
228
262
|
'service-error',
|
|
229
263
|
])
|
|
230
|
-
.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.');
|
|
264
|
+
.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-body: the tier returned a record with front matter and abstract but no body sections, so the chain continued. 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.');
|
|
231
265
|
const TriedTierSchema = z
|
|
232
266
|
.object({
|
|
233
267
|
tier: z.enum(['pmc', 'europepmc', 'unpaywall']).describe('Which tier in the resolution chain'),
|
|
@@ -247,6 +281,234 @@ const UnavailableSchema = z
|
|
|
247
281
|
.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.'),
|
|
248
282
|
})
|
|
249
283
|
.describe('One identifier that could not be returned, with the full chain it traversed');
|
|
284
|
+
// ─── Character-budget schemas ────────────────────────────────────────────────
|
|
285
|
+
const TruncatedSectionSchema = z
|
|
286
|
+
.object({
|
|
287
|
+
title: z.string().optional().describe('Section heading, when the section carries one'),
|
|
288
|
+
originalCharacters: z
|
|
289
|
+
.number()
|
|
290
|
+
.describe('Body characters this section carried before the budget pass'),
|
|
291
|
+
returnedCharacters: z
|
|
292
|
+
.number()
|
|
293
|
+
.describe('Body characters this section carries in the response. Zero means the section was dropped in `truncate` mode, or kept as a heading-only entry in `outline` mode.'),
|
|
294
|
+
truncated: z
|
|
295
|
+
.boolean()
|
|
296
|
+
.describe('True when the section returned fewer characters than it originally carried'),
|
|
297
|
+
})
|
|
298
|
+
.describe('Character accounting for one body section of a budgeted article');
|
|
299
|
+
const TruncatedArticleSchema = z
|
|
300
|
+
.object({
|
|
301
|
+
id: z
|
|
302
|
+
.string()
|
|
303
|
+
.describe('Identifier for the article — PMCID, PMID, DOI, or Europe PMC id, whichever the article carries first'),
|
|
304
|
+
source: z
|
|
305
|
+
.enum(['pmc', 'unpaywall'])
|
|
306
|
+
.describe('Which output shape was budgeted: `pmc` budgets body sections and subsections, `unpaywall` budgets the single `content` body'),
|
|
307
|
+
originalCharacters: z
|
|
308
|
+
.number()
|
|
309
|
+
.describe('Body characters this article carried before the budget pass'),
|
|
310
|
+
returnedCharacters: z.number().describe('Body characters this article carries in the response'),
|
|
311
|
+
sections: z
|
|
312
|
+
.array(TruncatedSectionSchema)
|
|
313
|
+
.optional()
|
|
314
|
+
.describe('Per-section accounting for `source: pmc` articles, in document order, including sections dropped for budget. Absent for `source: unpaywall`, whose body has no section structure.'),
|
|
315
|
+
})
|
|
316
|
+
.describe('Character accounting for one article the budget shortened');
|
|
317
|
+
const TruncationSchema = z
|
|
318
|
+
.object({
|
|
319
|
+
mode: z
|
|
320
|
+
.enum(['truncate', 'outline'])
|
|
321
|
+
.describe('The `overflowMode` that produced these results'),
|
|
322
|
+
maxCharacters: z.number().optional().describe('The `maxCharacters` budget applied, when set'),
|
|
323
|
+
maxCharactersPerSection: z
|
|
324
|
+
.number()
|
|
325
|
+
.optional()
|
|
326
|
+
.describe('The `maxCharactersPerSection` budget applied, when set'),
|
|
327
|
+
originalCharacters: z
|
|
328
|
+
.number()
|
|
329
|
+
.describe('Body characters the shortened articles carried before the budget pass'),
|
|
330
|
+
returnedCharacters: z
|
|
331
|
+
.number()
|
|
332
|
+
.describe('Body characters the shortened articles carry in this response'),
|
|
333
|
+
omittedSections: z
|
|
334
|
+
.number()
|
|
335
|
+
.describe('Body sections dropped entirely because an article budget was exhausted before reaching them. Always 0 in `outline` mode, which keeps every heading.'),
|
|
336
|
+
articles: z
|
|
337
|
+
.array(TruncatedArticleSchema)
|
|
338
|
+
.describe('Per-article accounting, covering only the articles the budget shortened'),
|
|
339
|
+
})
|
|
340
|
+
.describe('Character accounting for full text the budget shortened. Present only when a budget actually removed characters — its absence means every returned article carries its full post-filter body.');
|
|
341
|
+
/** True when the request asked for any budget at all. Without one, every budget
|
|
342
|
+
* helper returns its input untouched so the response is byte-identical. */
|
|
343
|
+
function budgetRequested(budget) {
|
|
344
|
+
return budget.maxCharacters !== undefined || budget.maxCharactersPerSection !== undefined;
|
|
345
|
+
}
|
|
346
|
+
/** Body characters a top-level section carries — its own text plus its subsections'. */
|
|
347
|
+
function sectionCharacters(section) {
|
|
348
|
+
return (section.text.length + (section.subsections?.reduce((n, sub) => n + sub.text.length, 0) ?? 0));
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Shorten an ordered list of text fields so their combined length fits
|
|
352
|
+
* `allowance`. Fields are filled in order, so earlier fields survive whole and
|
|
353
|
+
* later ones absorb the shortfall — the section's own text before its
|
|
354
|
+
* subsections. Cuts at the character boundary with no appended marker so the
|
|
355
|
+
* reported `returnedCharacters` is exact; `format()` carries the human-visible
|
|
356
|
+
* note. A cut that would split a surrogate pair backs off a code unit, so a
|
|
357
|
+
* field can return one character under its share — counts are measured off the
|
|
358
|
+
* returned text, never off the allowance. (#93)
|
|
359
|
+
*/
|
|
360
|
+
function fitFields(fields, allowance) {
|
|
361
|
+
let remaining = Math.max(allowance, 0);
|
|
362
|
+
return fields.map((text) => {
|
|
363
|
+
const kept = sliceCodeUnits(text, remaining);
|
|
364
|
+
remaining -= kept.length;
|
|
365
|
+
return kept;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Split `total` evenly across sections, then hand the leftover from sections
|
|
370
|
+
* that need less than their share back to the ones still capped, until the
|
|
371
|
+
* budget is spent or every section holds all it can. Equal shares alone would
|
|
372
|
+
* strand budget on short sections — a ten-section article with two one-line
|
|
373
|
+
* sections would return well under what the caller asked for.
|
|
374
|
+
*/
|
|
375
|
+
function evenShares(caps, total) {
|
|
376
|
+
const allowances = caps.map(() => 0);
|
|
377
|
+
let remaining = total;
|
|
378
|
+
while (remaining > 0) {
|
|
379
|
+
const hungry = caps.reduce((acc, cap, i) => {
|
|
380
|
+
if ((allowances[i] ?? 0) < cap)
|
|
381
|
+
acc.push(i);
|
|
382
|
+
return acc;
|
|
383
|
+
}, []);
|
|
384
|
+
if (hungry.length === 0)
|
|
385
|
+
break;
|
|
386
|
+
const share = Math.floor(remaining / hungry.length);
|
|
387
|
+
// Fewer characters left than sections still wanting them: hand out the
|
|
388
|
+
// remainder one character at a time so the budget is fully spent.
|
|
389
|
+
for (const i of hungry) {
|
|
390
|
+
const want = (caps[i] ?? 0) - (allowances[i] ?? 0);
|
|
391
|
+
const give = Math.min(share === 0 ? 1 : share, want, remaining);
|
|
392
|
+
allowances[i] = (allowances[i] ?? 0) + give;
|
|
393
|
+
remaining -= give;
|
|
394
|
+
if (remaining === 0)
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return allowances;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Decide how many characters each top-level section may keep.
|
|
402
|
+
*
|
|
403
|
+
* `truncate` fills sections greedily in document order: early sections keep
|
|
404
|
+
* their full text and sections reached after the budget is spent get nothing.
|
|
405
|
+
* `outline` spreads `maxCharacters` across every section instead, so each
|
|
406
|
+
* heading survives with an excerpt rather than the budget being consumed by the
|
|
407
|
+
* first sections. `maxCharactersPerSection` caps each section under either mode.
|
|
408
|
+
*/
|
|
409
|
+
function allotSectionBudgets(sizes, budget) {
|
|
410
|
+
const perSection = budget.maxCharactersPerSection;
|
|
411
|
+
const total = budget.maxCharacters;
|
|
412
|
+
if (budget.overflowMode === 'outline' && total !== undefined) {
|
|
413
|
+
return evenShares(sizes.map((size) => Math.min(perSection ?? size, size)), total);
|
|
414
|
+
}
|
|
415
|
+
let remaining = total ?? sizes.reduce((sum, size) => sum + size, 0);
|
|
416
|
+
return sizes.map((size) => {
|
|
417
|
+
const allowance = Math.min(perSection ?? size, size, remaining);
|
|
418
|
+
remaining -= allowance;
|
|
419
|
+
return allowance;
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Apply the character budget to a JATS article's body. Runs as a pure
|
|
424
|
+
* post-processing pass after `applyPmcFilters`, so `sections` / `maxSections` /
|
|
425
|
+
* `includeReferences` and the empty-body signals they feed are unaffected.
|
|
426
|
+
* Titles, abstracts, identifiers, and references are never counted or cut —
|
|
427
|
+
* the budget only spends on body text, keeping every article citable.
|
|
428
|
+
*
|
|
429
|
+
* Returns the article untouched (same object identity) when no budget was
|
|
430
|
+
* requested or nothing exceeded it. A section left with zero characters is
|
|
431
|
+
* dropped in `truncate` mode and counted as omitted; `outline` keeps it as a
|
|
432
|
+
* heading-only entry. Dropped sections still appear in the accounting so the
|
|
433
|
+
* caller can see which headings exist. (#81)
|
|
434
|
+
*/
|
|
435
|
+
function applyPmcBudget(article, budget) {
|
|
436
|
+
if (!budgetRequested(budget) || article.sections.length === 0) {
|
|
437
|
+
return { article, omittedSections: 0 };
|
|
438
|
+
}
|
|
439
|
+
const sizes = article.sections.map(sectionCharacters);
|
|
440
|
+
const originalCharacters = sizes.reduce((sum, size) => sum + size, 0);
|
|
441
|
+
const allowances = allotSectionBudgets(sizes, budget);
|
|
442
|
+
const kept = [];
|
|
443
|
+
const sectionReports = [];
|
|
444
|
+
let omittedSections = 0;
|
|
445
|
+
let returnedCharacters = 0;
|
|
446
|
+
article.sections.forEach((section, i) => {
|
|
447
|
+
const original = sizes[i] ?? 0;
|
|
448
|
+
const fitted = fitFields([section.text, ...(section.subsections?.map((sub) => sub.text) ?? [])], allowances[i] ?? 0);
|
|
449
|
+
const returned = fitted.reduce((sum, text) => sum + text.length, 0);
|
|
450
|
+
returnedCharacters += returned;
|
|
451
|
+
sectionReports.push({
|
|
452
|
+
...(section.title !== undefined && { title: section.title }),
|
|
453
|
+
originalCharacters: original,
|
|
454
|
+
returnedCharacters: returned,
|
|
455
|
+
truncated: returned < original,
|
|
456
|
+
});
|
|
457
|
+
if (returned === 0 && original > 0 && budget.overflowMode === 'truncate') {
|
|
458
|
+
omittedSections += 1;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
kept.push({
|
|
462
|
+
...section,
|
|
463
|
+
text: fitted[0] ?? '',
|
|
464
|
+
...(section.subsections && {
|
|
465
|
+
subsections: section.subsections.map((sub, j) => ({ ...sub, text: fitted[j + 1] ?? '' })),
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
if (returnedCharacters === originalCharacters && omittedSections === 0) {
|
|
470
|
+
return { article, omittedSections: 0 };
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
article: { ...article, sections: kept },
|
|
474
|
+
omittedSections,
|
|
475
|
+
truncation: { originalCharacters, returnedCharacters, sections: sectionReports },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Apply the character budget to an Unpaywall body. That body is one
|
|
480
|
+
* unstructured blob — HTML-as-Markdown or PDF-as-text — so only `maxCharacters`
|
|
481
|
+
* applies, and `outline` mode has no headings to preserve and behaves like
|
|
482
|
+
* `truncate`. (#81)
|
|
483
|
+
*/
|
|
484
|
+
function applyContentBudget(content, budget) {
|
|
485
|
+
const cap = budget.maxCharacters;
|
|
486
|
+
if (cap === undefined || content.length <= cap)
|
|
487
|
+
return { content };
|
|
488
|
+
const kept = sliceCodeUnits(content, cap);
|
|
489
|
+
return {
|
|
490
|
+
content: kept,
|
|
491
|
+
truncation: { originalCharacters: content.length, returnedCharacters: kept.length },
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Compose the recovery notice for a budgeted response. Names what was spent and
|
|
496
|
+
* where the detail lives so an agent reading only `content[]` knows the body it
|
|
497
|
+
* received is partial. (#81)
|
|
498
|
+
*/
|
|
499
|
+
function buildTruncationNotice(truncation) {
|
|
500
|
+
const subject = truncation.articles.length === 1 ? '1 article' : `${truncation.articles.length} articles`;
|
|
501
|
+
const omitted = truncation.omittedSections > 0
|
|
502
|
+
? ` ${truncation.omittedSections} section(s) were dropped once the budget ran out.`
|
|
503
|
+
: '';
|
|
504
|
+
// Name only the budgets the request actually set — pointing at `maxCharacters`
|
|
505
|
+
// when the caller only capped per-section sends them to a knob that is unset.
|
|
506
|
+
const knobs = [
|
|
507
|
+
truncation.maxCharacters !== undefined ? '`maxCharacters`' : undefined,
|
|
508
|
+
truncation.maxCharactersPerSection !== undefined ? '`maxCharactersPerSection`' : undefined,
|
|
509
|
+
].filter((k) => k !== undefined);
|
|
510
|
+
return `Full text was shortened to fit the requested character budget: ${truncation.returnedCharacters} of ${truncation.originalCharacters} body characters returned across ${subject} in ${truncation.mode} mode.${omitted} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
|
|
511
|
+
}
|
|
250
512
|
// ─── Tool Definition ─────────────────────────────────────────────────────────
|
|
251
513
|
/**
|
|
252
514
|
* Compose the tool description for the fallback tiers enabled in this
|
|
@@ -306,7 +568,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
306
568
|
.min(1)
|
|
307
569
|
.max(10)
|
|
308
570
|
.optional()
|
|
309
|
-
.describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`.'),
|
|
571
|
+
.describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`. PMC IDs with no retrievable full text fall through to Europe PMC, then to Unpaywall on the DOI the chain resolves for them.'),
|
|
310
572
|
pmids: z
|
|
311
573
|
.array(pmidStringSchema)
|
|
312
574
|
.min(1)
|
|
@@ -334,6 +596,24 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
334
596
|
.array(z.string())
|
|
335
597
|
.optional()
|
|
336
598
|
.describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
|
|
599
|
+
maxCharacters: z
|
|
600
|
+
.number()
|
|
601
|
+
.int()
|
|
602
|
+
.min(1)
|
|
603
|
+
.max(1_000_000)
|
|
604
|
+
.optional()
|
|
605
|
+
.describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, so semantic filtering is unaffected. The response-wide ceiling is this value times the number of articles returned. Omit for the full body.'),
|
|
606
|
+
maxCharactersPerSection: z
|
|
607
|
+
.number()
|
|
608
|
+
.int()
|
|
609
|
+
.min(1)
|
|
610
|
+
.max(1_000_000)
|
|
611
|
+
.optional()
|
|
612
|
+
.describe('Budget for a single top-level body section, in characters, counting the section text plus its subsections. Combine with `maxCharacters` to cap both one section and the article; the tighter of the two wins. Applies to `source=pmc` results only.'),
|
|
613
|
+
overflowMode: z
|
|
614
|
+
.enum(['truncate', 'outline'])
|
|
615
|
+
.default('truncate')
|
|
616
|
+
.describe('How to spend `maxCharacters` across an article that exceeds it. truncate: fill sections in document order, so early sections stay whole and sections past the budget are dropped (counted in `truncation.omittedSections`). outline: split the budget evenly so every section keeps its heading, and an excerpt as far as the budget reaches — use it to survey what an article contains before requesting specific `sections`. Ignored when no budget is set, and identical for `source=unpaywall` bodies, which have no headings to preserve.'),
|
|
337
617
|
})
|
|
338
618
|
.refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
|
|
339
619
|
message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
|
|
@@ -345,15 +625,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
345
625
|
.array(UnavailableSchema)
|
|
346
626
|
.optional()
|
|
347
627
|
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
|
|
628
|
+
truncation: TruncationSchema.optional(),
|
|
348
629
|
}),
|
|
349
|
-
// Recovery guidance
|
|
350
|
-
//
|
|
351
|
-
//
|
|
630
|
+
// Recovery guidance for three cases — a `sections` filter that removed every
|
|
631
|
+
// body section (#80), a record the chain could only retrieve as front matter
|
|
632
|
+
// (#86), and a body the character budget shortened (#81). Agent-facing context
|
|
633
|
+
// surfaced via ctx.enrich.notice() to structuredContent and content[]; absent
|
|
634
|
+
// when none applies.
|
|
352
635
|
enrichment: {
|
|
353
636
|
notice: z
|
|
354
637
|
.string()
|
|
355
638
|
.optional()
|
|
356
|
-
.describe('Optional guidance
|
|
639
|
+
.describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. Absent when none of those applies.'),
|
|
357
640
|
},
|
|
358
641
|
async handler(input, ctx) {
|
|
359
642
|
ctx.log.info('Executing pubmed_fetch_fulltext', {
|
|
@@ -380,6 +663,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
380
663
|
// section — collected across the PMC and EPMC stages to drive one recovery
|
|
381
664
|
// notice via ctx.enrich.notice (#80).
|
|
382
665
|
const sectionFilterMisses = [];
|
|
666
|
+
// Input ids whose PMC or EPMC record carried no body sections at all. Those
|
|
667
|
+
// records are not full-text hits, so the chain continues past them; ids still
|
|
668
|
+
// unrecovered at the end drive the metadata-only recovery notice (#86).
|
|
669
|
+
const bodylessInputIds = new Set();
|
|
670
|
+
// Per-article character accounting collected across all three stages, plus
|
|
671
|
+
// the running count of sections the budget dropped. Empty when no budget was
|
|
672
|
+
// requested or nothing exceeded it (#81).
|
|
673
|
+
const truncatedArticles = [];
|
|
674
|
+
let omittedSections = 0;
|
|
675
|
+
const budget = {
|
|
676
|
+
overflowMode: input.overflowMode,
|
|
677
|
+
...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
|
|
678
|
+
...(input.maxCharactersPerSection !== undefined && {
|
|
679
|
+
maxCharactersPerSection: input.maxCharactersPerSection,
|
|
680
|
+
}),
|
|
681
|
+
};
|
|
383
682
|
const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
|
|
384
683
|
// ── Branch routing → produce buckets the staged chain consumes ──────────
|
|
385
684
|
let pmcIds = [];
|
|
@@ -511,19 +810,35 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
511
810
|
if (!articleSet) {
|
|
512
811
|
throw new Error('PMC EFetch response missing pmc-articleset wrapper');
|
|
513
812
|
}
|
|
514
|
-
|
|
813
|
+
// A parsed article with no body sections is front matter only — PMC
|
|
814
|
+
// returns one whenever the publisher blocks full-text XML download. It
|
|
815
|
+
// is not a hit: it never enters `articles[]`, and its id is routed to
|
|
816
|
+
// the remaining tiers like any other PMC miss. (#86)
|
|
817
|
+
const bodylessPmcIds = new Set();
|
|
818
|
+
const parsed = [];
|
|
819
|
+
for (const node of findAll(articleSet, 'article')) {
|
|
515
820
|
const before = parsePmcArticle(node);
|
|
821
|
+
if (isBodylessArticle(before)) {
|
|
822
|
+
if (before.pmcId)
|
|
823
|
+
bodylessPmcIds.add(before.pmcId);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
516
826
|
const after = applyPmcFilters(before, input);
|
|
517
827
|
if (isSectionFilterMiss(before, after, input.sections)) {
|
|
518
|
-
sectionFilterMisses.push(
|
|
828
|
+
sectionFilterMisses.push(articleDisplayId(after));
|
|
519
829
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
830
|
+
const budgeted = applyPmcBudget(after, budget);
|
|
831
|
+
omittedSections += budgeted.omittedSections;
|
|
832
|
+
if (budgeted.truncation) {
|
|
833
|
+
truncatedArticles.push({
|
|
834
|
+
id: articleDisplayId(after),
|
|
835
|
+
source: 'pmc',
|
|
836
|
+
...budgeted.truncation,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
parsed.push({ source: 'pmc', viaSource: 'pmc', ...budgeted.article });
|
|
840
|
+
}
|
|
841
|
+
pmcArticles = parsed;
|
|
527
842
|
const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
|
|
528
843
|
for (const prefixed of returnedPmcIds) {
|
|
529
844
|
recoveredIds.add(pmcidToInputId.get(prefixed) ?? prefixed);
|
|
@@ -533,7 +848,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
533
848
|
.filter((id) => !returnedPmcIds.has(id));
|
|
534
849
|
for (const prefixed of missing) {
|
|
535
850
|
const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
|
|
536
|
-
|
|
851
|
+
if (bodylessPmcIds.has(prefixed)) {
|
|
852
|
+
bodylessInputIds.add(inputId);
|
|
853
|
+
chainByInput.get(inputId)?.push({
|
|
854
|
+
tier: 'pmc',
|
|
855
|
+
outcome: 'no-body',
|
|
856
|
+
detail: 'PMC returned front matter and abstract only, with no body sections',
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
|
|
861
|
+
}
|
|
537
862
|
}
|
|
538
863
|
routePmcMissesToFallback(missing);
|
|
539
864
|
}
|
|
@@ -559,6 +884,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
559
884
|
pmcidFallbackCandidates,
|
|
560
885
|
doiCandidates,
|
|
561
886
|
input,
|
|
887
|
+
budget,
|
|
562
888
|
ctx,
|
|
563
889
|
})
|
|
564
890
|
: {
|
|
@@ -570,8 +896,12 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
570
896
|
pmcidOutcomes: new Map(),
|
|
571
897
|
doiOutcomes: new Map(),
|
|
572
898
|
sectionFilterMisses: [],
|
|
899
|
+
truncatedArticles: [],
|
|
900
|
+
omittedSections: 0,
|
|
573
901
|
};
|
|
574
902
|
pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
|
|
903
|
+
truncatedArticles.push(...epmcOutcomes.truncatedArticles);
|
|
904
|
+
omittedSections += epmcOutcomes.omittedSections;
|
|
575
905
|
// Fold EPMC outcomes into each id's chain. EPMC-served articles count as
|
|
576
906
|
// recovered, so their ids are added to `recoveredIds` here.
|
|
577
907
|
if (!epmc) {
|
|
@@ -590,28 +920,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
590
920
|
chainByInput.get(c.doi)?.push(epmcDisabledEntry);
|
|
591
921
|
}
|
|
592
922
|
else {
|
|
593
|
-
|
|
594
|
-
if (outcome.kind === 'hit') {
|
|
595
|
-
recoveredIds.add(pmid);
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
chainByInput.get(pmid)?.push(epmcTierFromOutcome(outcome));
|
|
599
|
-
}
|
|
600
|
-
for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
|
|
601
|
-
const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
|
|
923
|
+
const foldEpmcOutcome = (inputId, outcome) => {
|
|
602
924
|
if (outcome.kind === 'hit') {
|
|
603
925
|
recoveredIds.add(inputId);
|
|
604
|
-
|
|
926
|
+
return;
|
|
605
927
|
}
|
|
928
|
+
if (outcome.kind === 'no-body')
|
|
929
|
+
bodylessInputIds.add(inputId);
|
|
606
930
|
chainByInput.get(inputId)?.push(epmcTierFromOutcome(outcome));
|
|
931
|
+
};
|
|
932
|
+
for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes)
|
|
933
|
+
foldEpmcOutcome(pmid, outcome);
|
|
934
|
+
for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
|
|
935
|
+
foldEpmcOutcome(pmcidToInputId.get(prefixed) ?? prefixed, outcome);
|
|
607
936
|
}
|
|
608
|
-
for (const [doi, outcome] of epmcOutcomes.doiOutcomes)
|
|
609
|
-
|
|
610
|
-
recoveredIds.add(doi);
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
chainByInput.get(doi)?.push(epmcTierFromOutcome(outcome));
|
|
614
|
-
}
|
|
937
|
+
for (const [doi, outcome] of epmcOutcomes.doiOutcomes)
|
|
938
|
+
foldEpmcOutcome(doi, outcome);
|
|
615
939
|
}
|
|
616
940
|
pmidFallbackCandidates = epmcOutcomes.remainingPmid;
|
|
617
941
|
pmcidFallbackCandidates = epmcOutcomes.remainingPmcid;
|
|
@@ -620,15 +944,78 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
620
944
|
// ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
|
|
621
945
|
const unpaywall = getUnpaywallService();
|
|
622
946
|
const fallbackArticles = [];
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
947
|
+
// `pmcids` input reaches Unpaywall on the DOI the chain already holds: the
|
|
948
|
+
// EPMC stage searches by PMCID and its hit carries one, captured on non-hit
|
|
949
|
+
// outcomes too. PMCIDs EPMC never resolved fall back to the PMC ID
|
|
950
|
+
// Converter, which returns DOIs for PMC-indexed records. (#88)
|
|
951
|
+
if (pmcidFallbackCandidates.length > 0) {
|
|
952
|
+
if (!unpaywall) {
|
|
953
|
+
for (const c of pmcidFallbackCandidates) {
|
|
954
|
+
const prefixed = withPmcPrefix(c.pmcid);
|
|
955
|
+
chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push({
|
|
956
|
+
tier: 'unpaywall',
|
|
957
|
+
outcome: 'not-attempted',
|
|
958
|
+
detail: 'UNPAYWALL_EMAIL is not set',
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
const needDoi = pmcidFallbackCandidates
|
|
964
|
+
.filter((c) => !c.doi)
|
|
965
|
+
.map((c) => withPmcPrefix(c.pmcid));
|
|
966
|
+
if (needDoi.length > 0) {
|
|
967
|
+
try {
|
|
968
|
+
const records = await getNcbiService().idConvert(needDoi, 'pmcid', ctx.signal ? { signal: ctx.signal } : undefined);
|
|
969
|
+
const doiByPmcid = new Map();
|
|
970
|
+
for (const r of records) {
|
|
971
|
+
if (r.pmcid && r.doi) {
|
|
972
|
+
doiByPmcid.set(withPmcPrefix(normalizePmcId(String(r.pmcid))), String(r.doi));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
pmcidFallbackCandidates = pmcidFallbackCandidates.map((c) => {
|
|
976
|
+
if (c.doi)
|
|
977
|
+
return c;
|
|
978
|
+
const doi = doiByPmcid.get(withPmcPrefix(c.pmcid));
|
|
979
|
+
return doi ? { ...c, doi } : c;
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
ctx.log.warning('Failed to resolve PMCID → DOI for the Unpaywall fallback', {
|
|
984
|
+
error: error instanceof Error ? error.message : String(error),
|
|
985
|
+
pmcidCount: needDoi.length,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const outcomes = await Promise.all(pmcidFallbackCandidates.map(async (candidate) => {
|
|
990
|
+
// The prefixed PMCID is the id `unavailable[]` keys on, so stamping
|
|
991
|
+
// it on the article makes a partially-recovered batch report its
|
|
992
|
+
// successes and its failures under the same identifier. (#92)
|
|
993
|
+
const pmcId = withPmcPrefix(candidate.pmcid);
|
|
994
|
+
return {
|
|
995
|
+
pmcId,
|
|
996
|
+
result: candidate.doi
|
|
997
|
+
? await resolveUnpaywall({ pmcId, doi: candidate.doi, budget }, unpaywall, ctx)
|
|
998
|
+
: { unavailable: { reason: 'no-doi' } },
|
|
999
|
+
};
|
|
1000
|
+
}));
|
|
1001
|
+
for (const { pmcId, result } of outcomes) {
|
|
1002
|
+
const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
|
|
1003
|
+
if ('article' in result) {
|
|
1004
|
+
fallbackArticles.push(result.article);
|
|
1005
|
+
if (result.truncation)
|
|
1006
|
+
truncatedArticles.push(result.truncation);
|
|
1007
|
+
recoveredIds.add(inputId);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
const u = result.unavailable;
|
|
1011
|
+
chainByInput.get(inputId)?.push({
|
|
1012
|
+
tier: 'unpaywall',
|
|
1013
|
+
outcome: unpaywallReasonToTierOutcome(u.reason),
|
|
1014
|
+
...(u.detail && { detail: u.detail }),
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
632
1019
|
}
|
|
633
1020
|
if (pmidFallbackCandidates.length > 0) {
|
|
634
1021
|
// The PMC ID Converter only returns DOIs for articles it has in PMC, so
|
|
@@ -665,12 +1052,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
665
1052
|
const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
|
|
666
1053
|
candidate,
|
|
667
1054
|
result: candidate.doi
|
|
668
|
-
? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi }, unpaywall, ctx)
|
|
1055
|
+
? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi, budget }, unpaywall, ctx)
|
|
669
1056
|
: { unavailable: { reason: 'no-doi' } },
|
|
670
1057
|
})));
|
|
671
1058
|
for (const { candidate, result } of outcomes) {
|
|
672
1059
|
if ('article' in result) {
|
|
673
1060
|
fallbackArticles.push(result.article);
|
|
1061
|
+
if (result.truncation)
|
|
1062
|
+
truncatedArticles.push(result.truncation);
|
|
674
1063
|
recoveredIds.add(candidate.pmid);
|
|
675
1064
|
}
|
|
676
1065
|
else {
|
|
@@ -699,11 +1088,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
699
1088
|
// doesn't reject under normal operation.
|
|
700
1089
|
const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
|
|
701
1090
|
doi: c.doi,
|
|
702
|
-
result: await resolveUnpaywall({ doi: c.doi }, unpaywall, ctx),
|
|
1091
|
+
result: await resolveUnpaywall({ doi: c.doi, budget }, unpaywall, ctx),
|
|
703
1092
|
})));
|
|
704
1093
|
for (const { doi, result } of outcomes) {
|
|
705
1094
|
if ('article' in result) {
|
|
706
1095
|
fallbackArticles.push(result.article);
|
|
1096
|
+
if (result.truncation)
|
|
1097
|
+
truncatedArticles.push(result.truncation);
|
|
707
1098
|
recoveredIds.add(doi);
|
|
708
1099
|
}
|
|
709
1100
|
else {
|
|
@@ -738,13 +1129,40 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
738
1129
|
unpaywallHits: fallbackArticles.length,
|
|
739
1130
|
unavailable: unavailable.length,
|
|
740
1131
|
});
|
|
1132
|
+
// Rolled up only when the budget actually removed characters, so an
|
|
1133
|
+
// under-budget request returns exactly what it did before the budget
|
|
1134
|
+
// controls existed. (#81)
|
|
1135
|
+
const truncation = truncatedArticles.length > 0
|
|
1136
|
+
? {
|
|
1137
|
+
mode: input.overflowMode,
|
|
1138
|
+
...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
|
|
1139
|
+
...(input.maxCharactersPerSection !== undefined && {
|
|
1140
|
+
maxCharactersPerSection: input.maxCharactersPerSection,
|
|
1141
|
+
}),
|
|
1142
|
+
originalCharacters: truncatedArticles.reduce((n, a) => n + a.originalCharacters, 0),
|
|
1143
|
+
returnedCharacters: truncatedArticles.reduce((n, a) => n + a.returnedCharacters, 0),
|
|
1144
|
+
omittedSections,
|
|
1145
|
+
articles: truncatedArticles,
|
|
1146
|
+
}
|
|
1147
|
+
: undefined;
|
|
1148
|
+
// Only the last ctx.enrich.notice survives, so the applicable fragments are
|
|
1149
|
+
// collected and emitted once.
|
|
1150
|
+
const notices = [];
|
|
741
1151
|
if (input.sections?.length && sectionFilterMisses.length > 0) {
|
|
742
|
-
|
|
1152
|
+
notices.push(buildSectionFilterMissNotice(sectionFilterMisses, input.sections));
|
|
743
1153
|
}
|
|
1154
|
+
const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
|
|
1155
|
+
if (unrecoveredBodyless.length > 0)
|
|
1156
|
+
notices.push(buildBodylessNotice(unrecoveredBodyless));
|
|
1157
|
+
if (truncation)
|
|
1158
|
+
notices.push(buildTruncationNotice(truncation));
|
|
1159
|
+
if (notices.length > 0)
|
|
1160
|
+
ctx.enrich.notice(notices.join(' '));
|
|
744
1161
|
return {
|
|
745
1162
|
articles,
|
|
746
1163
|
totalReturned: articles.length,
|
|
747
1164
|
...(unavailable.length > 0 && { unavailable }),
|
|
1165
|
+
...(truncation && { truncation }),
|
|
748
1166
|
};
|
|
749
1167
|
},
|
|
750
1168
|
format: (result) => {
|
|
@@ -766,12 +1184,16 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
766
1184
|
if (result.totalReturned === 0) {
|
|
767
1185
|
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\`.`);
|
|
768
1186
|
}
|
|
1187
|
+
if (result.truncation)
|
|
1188
|
+
formatTruncation(result.truncation, lines);
|
|
1189
|
+
const truncationById = new Map(result.truncation?.articles.map((t) => [t.id, t]) ?? []);
|
|
769
1190
|
for (const a of result.articles) {
|
|
770
1191
|
lines.push('');
|
|
1192
|
+
const t = truncationById.get(articleDisplayId(a));
|
|
771
1193
|
if (a.source === 'pmc')
|
|
772
|
-
formatPmcArticle(a, lines);
|
|
1194
|
+
formatPmcArticle(a, lines, t);
|
|
773
1195
|
else
|
|
774
|
-
formatUnpaywallArticle(a, lines);
|
|
1196
|
+
formatUnpaywallArticle(a, lines, t);
|
|
775
1197
|
}
|
|
776
1198
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
777
1199
|
},
|
|
@@ -795,27 +1217,44 @@ async function runEpmcStage(epmc, args) {
|
|
|
795
1217
|
}
|
|
796
1218
|
if (search.kind === 'miss')
|
|
797
1219
|
return { c, outcome: { kind: 'miss' } };
|
|
1220
|
+
const doi = search.hit.doi ? { doi: search.hit.doi } : {};
|
|
798
1221
|
const fetched = await fetchEpmcArticle(epmc, search.hit, args, contextPmid);
|
|
799
1222
|
if (fetched.kind === 'error') {
|
|
800
|
-
return { c, outcome: { kind: 'service-error', detail: fetched.detail } };
|
|
1223
|
+
return { c, ...doi, outcome: { kind: 'service-error', detail: fetched.detail } };
|
|
801
1224
|
}
|
|
802
1225
|
if (fetched.kind === 'no-fulltext') {
|
|
803
1226
|
return {
|
|
804
1227
|
c,
|
|
1228
|
+
...doi,
|
|
805
1229
|
outcome: { kind: 'no-fulltext', ...(fetched.detail && { detail: fetched.detail }) },
|
|
806
1230
|
};
|
|
807
1231
|
}
|
|
1232
|
+
if (fetched.kind === 'no-body') {
|
|
1233
|
+
return { c, ...doi, outcome: { kind: 'no-body', detail: fetched.detail } };
|
|
1234
|
+
}
|
|
808
1235
|
return {
|
|
809
1236
|
c,
|
|
1237
|
+
...doi,
|
|
810
1238
|
outcome: { kind: 'hit' },
|
|
811
1239
|
article: fetched.article,
|
|
812
1240
|
sectionFilterMiss: fetched.sectionFilterMiss,
|
|
1241
|
+
omittedSections: fetched.omittedSections,
|
|
1242
|
+
...(fetched.truncation && { truncation: fetched.truncation }),
|
|
813
1243
|
};
|
|
814
1244
|
};
|
|
815
|
-
|
|
1245
|
+
/**
|
|
1246
|
+
* Query shapes are load-bearing and not interchangeable with their quoted
|
|
1247
|
+
* variants. Europe PMC matches zero records for `EXT_ID:"<pmid>" AND SRC:MED`
|
|
1248
|
+
* and `PMCID:"PMC<digits>"` — the quotes only survive as long as no `AND SRC:`
|
|
1249
|
+
* clause follows. `SRC:PMC` is likewise wrong for a PMCID lookup: EPMC's
|
|
1250
|
+
* canonical record for a PMC-indexed article has `source: MED` and carries the
|
|
1251
|
+
* PMCID as a field, so the filter excludes the very record being sought. DOIs
|
|
1252
|
+
* keep their quotes — they carry slashes and dots that need them. (#85)
|
|
1253
|
+
*/
|
|
1254
|
+
const fetchForPmid = (c) => runOne(c, `EXT_ID:${c.pmid} AND SRC:MED`, c.pmid);
|
|
816
1255
|
const fetchForPmcid = (c) => {
|
|
817
1256
|
const normalized = withPmcPrefix(c.pmcid);
|
|
818
|
-
return runOne({ c, normalized }, `PMCID
|
|
1257
|
+
return runOne({ c, normalized }, `PMCID:${normalized}`, undefined);
|
|
819
1258
|
};
|
|
820
1259
|
const fetchForDoi = (c) => runOne(c, `DOI:"${c.doi}"`, undefined);
|
|
821
1260
|
const [pmidResults, pmcidResults, doiResults] = await Promise.all([
|
|
@@ -831,35 +1270,36 @@ async function runEpmcStage(epmc, args) {
|
|
|
831
1270
|
const pmcidOutcomes = new Map();
|
|
832
1271
|
const doiOutcomes = new Map();
|
|
833
1272
|
const sectionFilterMisses = [];
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1273
|
+
const truncatedArticles = [];
|
|
1274
|
+
let omittedSections = 0;
|
|
1275
|
+
const collectHit = (run) => {
|
|
1276
|
+
articles.push(run.article);
|
|
1277
|
+
if (run.sectionFilterMiss)
|
|
1278
|
+
sectionFilterMisses.push(articleDisplayId(run.article));
|
|
1279
|
+
if (run.truncation)
|
|
1280
|
+
truncatedArticles.push(run.truncation);
|
|
1281
|
+
omittedSections += run.omittedSections ?? 0;
|
|
1282
|
+
};
|
|
1283
|
+
for (const run of pmidResults) {
|
|
1284
|
+
pmidOutcomes.set(run.c.pmid, run.outcome);
|
|
1285
|
+
if (run.article)
|
|
1286
|
+
collectHit({ ...run, article: run.article });
|
|
841
1287
|
else
|
|
842
|
-
remainingPmid.push(c);
|
|
1288
|
+
remainingPmid.push(run.c);
|
|
843
1289
|
}
|
|
844
|
-
for (const
|
|
845
|
-
pmcidOutcomes.set(
|
|
846
|
-
if (article)
|
|
847
|
-
|
|
848
|
-
if (sectionFilterMiss)
|
|
849
|
-
sectionFilterMisses.push(articleSectionMissId(article));
|
|
850
|
-
}
|
|
1290
|
+
for (const run of pmcidResults) {
|
|
1291
|
+
pmcidOutcomes.set(run.c.normalized, run.outcome);
|
|
1292
|
+
if (run.article)
|
|
1293
|
+
collectHit({ ...run, article: run.article });
|
|
851
1294
|
else
|
|
852
|
-
remainingPmcid.push(
|
|
1295
|
+
remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
|
|
853
1296
|
}
|
|
854
|
-
for (const
|
|
855
|
-
doiOutcomes.set(c.doi, outcome);
|
|
856
|
-
if (article)
|
|
857
|
-
|
|
858
|
-
if (sectionFilterMiss)
|
|
859
|
-
sectionFilterMisses.push(articleSectionMissId(article));
|
|
860
|
-
}
|
|
1297
|
+
for (const run of doiResults) {
|
|
1298
|
+
doiOutcomes.set(run.c.doi, run.outcome);
|
|
1299
|
+
if (run.article)
|
|
1300
|
+
collectHit({ ...run, article: run.article });
|
|
861
1301
|
else
|
|
862
|
-
remainingDoi.push(c);
|
|
1302
|
+
remainingDoi.push(run.c);
|
|
863
1303
|
}
|
|
864
1304
|
return {
|
|
865
1305
|
articles,
|
|
@@ -870,6 +1310,8 @@ async function runEpmcStage(epmc, args) {
|
|
|
870
1310
|
pmcidOutcomes,
|
|
871
1311
|
doiOutcomes,
|
|
872
1312
|
sectionFilterMisses,
|
|
1313
|
+
truncatedArticles,
|
|
1314
|
+
omittedSections,
|
|
873
1315
|
};
|
|
874
1316
|
}
|
|
875
1317
|
/**
|
|
@@ -923,30 +1365,46 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
|
|
|
923
1365
|
return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
|
|
924
1366
|
}
|
|
925
1367
|
const beforeFilter = parsePmcArticle(articleNode);
|
|
1368
|
+
if (isBodylessArticle(beforeFilter)) {
|
|
1369
|
+
return {
|
|
1370
|
+
kind: 'no-body',
|
|
1371
|
+
detail: 'EPMC fullTextXML carried front matter and abstract only, with no body sections',
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
926
1374
|
const parsed = applyPmcFilters(beforeFilter, args.input);
|
|
927
1375
|
const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
|
|
1376
|
+
const budgeted = applyPmcBudget(parsed, args.budget);
|
|
928
1377
|
// `parsePmcArticle` always returns string fields (sometimes empty). Strip
|
|
929
1378
|
// empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
|
|
930
1379
|
// optional shape is respected — agents read `epmcId`/`epmcSource` for those.
|
|
931
|
-
const { pmcId, pmcUrl, ...rest } =
|
|
1380
|
+
const { pmcId, pmcUrl, ...rest } = budgeted.article;
|
|
932
1381
|
const pmid = rest.pmid ?? hit.pmid ?? contextPmid;
|
|
933
1382
|
const doi = rest.doi ?? hit.doi;
|
|
1383
|
+
const article = {
|
|
1384
|
+
source: 'pmc',
|
|
1385
|
+
viaSource: 'europepmc',
|
|
1386
|
+
...rest,
|
|
1387
|
+
...(pmcId && { pmcId, pmcUrl }),
|
|
1388
|
+
...(pmid && {
|
|
1389
|
+
pmid,
|
|
1390
|
+
pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
|
|
1391
|
+
}),
|
|
1392
|
+
...(doi && { doi }),
|
|
1393
|
+
epmcId: hit.id,
|
|
1394
|
+
epmcSource: hit.source,
|
|
1395
|
+
};
|
|
934
1396
|
return {
|
|
935
1397
|
kind: 'article',
|
|
936
1398
|
sectionFilterMiss,
|
|
937
|
-
article
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
...(doi && { doi }),
|
|
947
|
-
epmcId: hit.id,
|
|
948
|
-
epmcSource: hit.source,
|
|
949
|
-
},
|
|
1399
|
+
article,
|
|
1400
|
+
omittedSections: budgeted.omittedSections,
|
|
1401
|
+
...(budgeted.truncation && {
|
|
1402
|
+
truncation: {
|
|
1403
|
+
id: articleDisplayId(article),
|
|
1404
|
+
source: 'pmc',
|
|
1405
|
+
...budgeted.truncation,
|
|
1406
|
+
},
|
|
1407
|
+
}),
|
|
950
1408
|
};
|
|
951
1409
|
}
|
|
952
1410
|
catch (error) {
|
|
@@ -987,12 +1445,28 @@ async function fetchPubmedDois(pmids, signal) {
|
|
|
987
1445
|
return out;
|
|
988
1446
|
}
|
|
989
1447
|
/**
|
|
990
|
-
* Resolve a DOI to an open-access article via Unpaywall. `pmid`,
|
|
991
|
-
*
|
|
992
|
-
*
|
|
1448
|
+
* Resolve a DOI to an open-access article via Unpaywall. `pmcId` and `pmid`,
|
|
1449
|
+
* when set, are stamped onto the resulting article so the branch that requested
|
|
1450
|
+
* it carries its identifier through — Unpaywall itself only knows the DOI.
|
|
993
1451
|
*/
|
|
994
1452
|
async function resolveUnpaywall(args, service, ctx) {
|
|
995
|
-
const { pmid, doi } = args;
|
|
1453
|
+
const { pmcId, pmid, doi, budget } = args;
|
|
1454
|
+
const requestedIds = { ...(pmcId && { pmcId }), ...(pmid && { pmid }) };
|
|
1455
|
+
/** Budget the extracted body, then pair the article with its accounting. */
|
|
1456
|
+
const budgeted = (build, content) => {
|
|
1457
|
+
const capped = applyContentBudget(content, budget);
|
|
1458
|
+
const article = build(capped.content);
|
|
1459
|
+
return {
|
|
1460
|
+
article,
|
|
1461
|
+
...(capped.truncation && {
|
|
1462
|
+
truncation: {
|
|
1463
|
+
id: articleDisplayId(article),
|
|
1464
|
+
source: 'unpaywall',
|
|
1465
|
+
...capped.truncation,
|
|
1466
|
+
},
|
|
1467
|
+
}),
|
|
1468
|
+
};
|
|
1469
|
+
};
|
|
996
1470
|
let resolution;
|
|
997
1471
|
try {
|
|
998
1472
|
resolution = await service.resolve(doi, ctx.signal);
|
|
@@ -1029,18 +1503,16 @@ async function resolveUnpaywall(args, service, ctx) {
|
|
|
1029
1503
|
},
|
|
1030
1504
|
};
|
|
1031
1505
|
}
|
|
1032
|
-
return {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
}),
|
|
1043
|
-
};
|
|
1506
|
+
return budgeted((text) => buildUnpaywallArticle({
|
|
1507
|
+
...requestedIds,
|
|
1508
|
+
doi,
|
|
1509
|
+
sourceUrl: content.fetchedUrl,
|
|
1510
|
+
location: resolution.location,
|
|
1511
|
+
contentFormat: 'html-markdown',
|
|
1512
|
+
content: text,
|
|
1513
|
+
title: extracted.title,
|
|
1514
|
+
wordCount: extracted.wordCount,
|
|
1515
|
+
}), body);
|
|
1044
1516
|
}
|
|
1045
1517
|
const extracted = await pdfParser.extractText(content.body, { mergePages: true });
|
|
1046
1518
|
const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
|
|
@@ -1049,17 +1521,15 @@ async function resolveUnpaywall(args, service, ctx) {
|
|
|
1049
1521
|
unavailable: { reason: 'parse-failed', detail: 'PDF extraction produced empty text' },
|
|
1050
1522
|
};
|
|
1051
1523
|
}
|
|
1052
|
-
return {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
}),
|
|
1062
|
-
};
|
|
1524
|
+
return budgeted((body) => buildUnpaywallArticle({
|
|
1525
|
+
...requestedIds,
|
|
1526
|
+
doi,
|
|
1527
|
+
sourceUrl: content.fetchedUrl,
|
|
1528
|
+
location: resolution.location,
|
|
1529
|
+
contentFormat: 'pdf-text',
|
|
1530
|
+
content: body,
|
|
1531
|
+
totalPages: extracted.totalPages,
|
|
1532
|
+
}), text);
|
|
1063
1533
|
}
|
|
1064
1534
|
catch (error) {
|
|
1065
1535
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -1073,6 +1543,7 @@ function buildUnpaywallArticle(args) {
|
|
|
1073
1543
|
source: 'unpaywall',
|
|
1074
1544
|
viaSource: 'unpaywall',
|
|
1075
1545
|
contentFormat: args.contentFormat,
|
|
1546
|
+
...(args.pmcId && { pmcId: args.pmcId }),
|
|
1076
1547
|
...(args.pmid && {
|
|
1077
1548
|
pmid: args.pmid,
|
|
1078
1549
|
pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
|
|
@@ -1103,6 +1574,8 @@ function epmcTierFromOutcome(outcome) {
|
|
|
1103
1574
|
outcome: 'no-fulltext',
|
|
1104
1575
|
...(outcome.detail && { detail: outcome.detail }),
|
|
1105
1576
|
};
|
|
1577
|
+
case 'no-body':
|
|
1578
|
+
return { tier: 'europepmc', outcome: 'no-body', detail: outcome.detail };
|
|
1106
1579
|
case 'service-error':
|
|
1107
1580
|
return { tier: 'europepmc', outcome: 'service-error', detail: outcome.detail };
|
|
1108
1581
|
}
|
|
@@ -1116,6 +1589,7 @@ function epmcTierFromOutcome(outcome) {
|
|
|
1116
1589
|
*/
|
|
1117
1590
|
function unpaywallReasonToTierOutcome(reason) {
|
|
1118
1591
|
switch (reason) {
|
|
1592
|
+
case 'no-body':
|
|
1119
1593
|
case 'no-doi':
|
|
1120
1594
|
case 'no-oa':
|
|
1121
1595
|
case 'fetch-failed':
|
|
@@ -1152,6 +1626,9 @@ function reasonFromChain(chain) {
|
|
|
1152
1626
|
return 'not-found';
|
|
1153
1627
|
case 'europepmc:no-fulltext':
|
|
1154
1628
|
return 'no-epmc-fulltext';
|
|
1629
|
+
case 'pmc:no-body':
|
|
1630
|
+
case 'europepmc:no-body':
|
|
1631
|
+
return 'no-body';
|
|
1155
1632
|
case 'unpaywall:no-doi':
|
|
1156
1633
|
return 'no-doi';
|
|
1157
1634
|
case 'unpaywall:no-oa':
|
|
@@ -1169,7 +1646,34 @@ function reasonFromChain(chain) {
|
|
|
1169
1646
|
}
|
|
1170
1647
|
}
|
|
1171
1648
|
// ─── format() helpers ────────────────────────────────────────────────────────
|
|
1172
|
-
|
|
1649
|
+
/**
|
|
1650
|
+
* Render the response-level character accounting. Every field is rendered
|
|
1651
|
+
* unconditionally so `content[]` readers see the same budget detail
|
|
1652
|
+
* `structuredContent` readers get. Counts are printed raw — no thousands
|
|
1653
|
+
* separators — so the numbers stay greppable. (#81)
|
|
1654
|
+
*/
|
|
1655
|
+
function formatTruncation(t, lines) {
|
|
1656
|
+
lines.push(`\n**Truncated (${t.mode} mode):** ${t.returnedCharacters} of ${t.originalCharacters} body characters returned across ${t.articles.length} article(s); ${t.omittedSections} section(s) omitted`);
|
|
1657
|
+
const budgets = [
|
|
1658
|
+
t.maxCharacters === undefined ? undefined : `maxCharacters ${t.maxCharacters}`,
|
|
1659
|
+
t.maxCharactersPerSection === undefined
|
|
1660
|
+
? undefined
|
|
1661
|
+
: `maxCharactersPerSection ${t.maxCharactersPerSection}`,
|
|
1662
|
+
].filter((b) => b !== undefined);
|
|
1663
|
+
if (budgets.length)
|
|
1664
|
+
lines.push(`Budget applied: ${budgets.join(', ')}`);
|
|
1665
|
+
for (const a of t.articles) {
|
|
1666
|
+
lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters`);
|
|
1667
|
+
for (const s of a.sections ?? []) {
|
|
1668
|
+
lines.push(` - ${s.title ?? 'untitled section'} — ${s.returnedCharacters} of ${s.originalCharacters} characters (truncated: ${s.truncated})`);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/** Per-article inline marker so a reader of one article's body knows it is partial. */
|
|
1673
|
+
function truncationNote(t) {
|
|
1674
|
+
return `\n> Body shortened to fit the requested character budget — ${t.returnedCharacters} of ${t.originalCharacters} characters returned. See \`truncation\` for per-section counts.`;
|
|
1675
|
+
}
|
|
1676
|
+
function formatPmcArticle(a, lines, truncation) {
|
|
1173
1677
|
lines.push(`### ${a.title ?? a.pmcId}`);
|
|
1174
1678
|
const sourceLabel = a.viaSource === 'europepmc'
|
|
1175
1679
|
? `Europe PMC (structured JATS${a.epmcSource ? `, source: ${a.epmcSource}` : ''})`
|
|
@@ -1220,6 +1724,8 @@ function formatPmcArticle(a, lines) {
|
|
|
1220
1724
|
lines.push(`**PubMed:** ${a.pubmedUrl}`);
|
|
1221
1725
|
if (a.keywords?.length)
|
|
1222
1726
|
lines.push(`**Keywords:** ${a.keywords.join(', ')}`);
|
|
1727
|
+
if (truncation)
|
|
1728
|
+
lines.push(truncationNote(truncation));
|
|
1223
1729
|
if (a.abstract)
|
|
1224
1730
|
lines.push(`\n#### Abstract\n${a.abstract}`);
|
|
1225
1731
|
for (const sec of a.sections) {
|
|
@@ -1244,13 +1750,16 @@ function formatPmcArticle(a, lines) {
|
|
|
1244
1750
|
}
|
|
1245
1751
|
}
|
|
1246
1752
|
}
|
|
1247
|
-
function formatUnpaywallArticle(a, lines) {
|
|
1248
|
-
const
|
|
1753
|
+
function formatUnpaywallArticle(a, lines, truncation) {
|
|
1754
|
+
const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
|
|
1755
|
+
const heading = a.title ?? requestedId;
|
|
1249
1756
|
const formatLabel = a.contentFormat === 'html-markdown'
|
|
1250
1757
|
? 'Unpaywall (HTML → Markdown, best-effort)'
|
|
1251
1758
|
: 'Unpaywall (PDF → plain text)';
|
|
1252
1759
|
lines.push(`### ${heading}`);
|
|
1253
1760
|
lines.push(`**Source:** ${formatLabel}`);
|
|
1761
|
+
if (a.pmcId)
|
|
1762
|
+
lines.push(`**PMCID:** ${a.pmcId}`);
|
|
1254
1763
|
if (a.pmid)
|
|
1255
1764
|
lines.push(`**PMID:** ${a.pmid}`);
|
|
1256
1765
|
lines.push(`**DOI:** ${a.doi}`);
|
|
@@ -1268,6 +1777,8 @@ function formatUnpaywallArticle(a, lines) {
|
|
|
1268
1777
|
if (a.totalPages !== undefined)
|
|
1269
1778
|
lines.push(`**Pages:** ${a.totalPages}`);
|
|
1270
1779
|
lines.push(`\n> Section structure is not guaranteed for this source. Treat the content as best-effort raw text. OA location metadata courtesy of Unpaywall (https://unpaywall.org).`);
|
|
1780
|
+
if (truncation)
|
|
1781
|
+
lines.push(truncationNote(truncation));
|
|
1271
1782
|
lines.push(`\n#### Full Text\n${a.content}`);
|
|
1272
1783
|
}
|
|
1273
1784
|
function formatPmcAuthor(au) {
|