@cyanheads/pubmed-mcp-server 2.10.6 → 2.10.8
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 +9 -5
- package/CLAUDE.md +9 -5
- package/README.md +5 -2
- package/dist/mcp-server/tools/definitions/_budget.d.ts +42 -0
- package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_budget.js +49 -0
- package/dist/mcp-server/tools/definitions/_budget.js.map +1 -0
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +9 -0
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +89 -9
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +17 -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 +336 -53
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +7 -11
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +5 -0
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +33 -10
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +3 -3
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +73 -8
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +8 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +10 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +6 -2
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
|
@@ -28,6 +28,7 @@ import { parsePmcArticle } from '../../../services/ncbi/parsing/pmc-article-pars
|
|
|
28
28
|
import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers.js';
|
|
29
29
|
import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
|
|
30
30
|
import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
|
|
31
|
+
import { fitWholeItems } from './_budget.js';
|
|
31
32
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
|
|
32
33
|
import { pmidStringSchema } from './_schemas.js';
|
|
33
34
|
import { escapeMarkdownInline, sliceCodeUnits } from './_text.js';
|
|
@@ -41,6 +42,43 @@ function filterSections(sections, sectionFilter) {
|
|
|
41
42
|
const lowerFilter = sectionFilter.map((s) => s.toLowerCase());
|
|
42
43
|
return sections.filter((s) => s.title && lowerFilter.some((f) => s.title?.toLowerCase().includes(f)));
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Render a section subtree as text blocks, in document order: each section's
|
|
47
|
+
* heading on its own line above its text. Used for the levels past
|
|
48
|
+
* {@link MAX_SECTION_DEPTH}, which have no node of their own to live in. (#112)
|
|
49
|
+
*/
|
|
50
|
+
function flattenSectionText(section) {
|
|
51
|
+
const heading = section.title ? formatHeading(section.label, section.title) : undefined;
|
|
52
|
+
const block = [heading, section.text].filter(Boolean).join('\n');
|
|
53
|
+
return [...(block ? [block] : []), ...(section.subsections ?? []).flatMap(flattenSectionText)];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Clamp a section tree to the depth the output schema declares. A section at the
|
|
57
|
+
* deepest level absorbs its descendants into its own text instead of carrying
|
|
58
|
+
* them as subsections the schema would strip on validation — silently, from both
|
|
59
|
+
* `structuredContent` and `content[]`. Shallower trees pass through untouched.
|
|
60
|
+
* (#112)
|
|
61
|
+
*/
|
|
62
|
+
function clampSectionDepth(sections, depth = 1) {
|
|
63
|
+
return sections.map((section) => {
|
|
64
|
+
const subsections = section.subsections;
|
|
65
|
+
if (!subsections?.length)
|
|
66
|
+
return section;
|
|
67
|
+
if (depth < MAX_SECTION_DEPTH) {
|
|
68
|
+
return { ...section, subsections: clampSectionDepth(subsections, depth + 1) };
|
|
69
|
+
}
|
|
70
|
+
const { subsections: _dropped, ...rest } = section;
|
|
71
|
+
const tail = subsections.flatMap(flattenSectionText);
|
|
72
|
+
return { ...rest, text: [section.text, ...tail].filter(Boolean).join('\n\n') };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Apply the requested section/reference filters, then clamp the section tree to
|
|
77
|
+
* the depth the output schema carries. Both run here so every path producing a
|
|
78
|
+
* `pmc` article — PMC EFetch and the Europe PMC stage — shares one shape, and
|
|
79
|
+
* the budget helpers downstream count the text that will actually survive
|
|
80
|
+
* validation. (#112)
|
|
81
|
+
*/
|
|
44
82
|
function applyPmcFilters(article, filters) {
|
|
45
83
|
let out = article;
|
|
46
84
|
if (filters.sections?.length) {
|
|
@@ -53,7 +91,7 @@ function applyPmcFilters(article, filters) {
|
|
|
53
91
|
const { references: _, ...rest } = out;
|
|
54
92
|
out = rest;
|
|
55
93
|
}
|
|
56
|
-
return out;
|
|
94
|
+
return { ...out, sections: clampSectionDepth(out.sections) };
|
|
57
95
|
}
|
|
58
96
|
/**
|
|
59
97
|
* True when a `sections` filter removed every body section from an article that
|
|
@@ -109,11 +147,30 @@ function buildBodylessNotice(affectedIds) {
|
|
|
109
147
|
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
148
|
}
|
|
111
149
|
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
|
150
|
+
/**
|
|
151
|
+
* How many `<sec>` levels the output schema carries as structured nodes. JATS
|
|
152
|
+
* nesting is unbounded and the parser recurses without a cap, so the schema is
|
|
153
|
+
* what decides how deep a section survives output validation — anything past it
|
|
154
|
+
* used to be dropped silently from both output surfaces (#112). Sections deeper
|
|
155
|
+
* than this are now flattened into the deepest surviving node's text instead, so
|
|
156
|
+
* no body content is lost at any depth.
|
|
157
|
+
*
|
|
158
|
+
* Two is the ceiling the tool's own contract can verify, not a guess at how deep
|
|
159
|
+
* real records nest: `format-parity`'s sentinel walker stops after 8 schema hops,
|
|
160
|
+
* and `articles[]` → the article union → `sections[]` → `subsections[]` already
|
|
161
|
+
* spends them all. A third `subsections` level puts its own elements out of the
|
|
162
|
+
* walker's reach, so `format()` parity for that subtree would ship unverified.
|
|
163
|
+
* Levels are inlined rather than expressed with `z.lazy()` regardless — a
|
|
164
|
+
* self-referential schema emits `$defs`/`$ref`, which Gemini rejects.
|
|
165
|
+
*/
|
|
166
|
+
const MAX_SECTION_DEPTH = 2;
|
|
112
167
|
const SubsectionSchema = z
|
|
113
168
|
.object({
|
|
114
169
|
title: z.string().optional().describe('Subsection heading'),
|
|
115
170
|
label: z.string().optional().describe('Subsection label'),
|
|
116
|
-
text: z
|
|
171
|
+
text: z
|
|
172
|
+
.string()
|
|
173
|
+
.describe('Subsection body text. Sections nested deeper than this level are folded in here in document order, each heading rendered on its own line above its text.'),
|
|
117
174
|
})
|
|
118
175
|
.describe('Article subsection');
|
|
119
176
|
const SectionSchema = z
|
|
@@ -251,7 +308,10 @@ const UnavailableReasonSchema = z
|
|
|
251
308
|
'parse-failed',
|
|
252
309
|
'service-error',
|
|
253
310
|
])
|
|
254
|
-
.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).');
|
|
311
|
+
.describe('Why no full text was returned — the most specific signal any tier that answered reported. 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). A reason never means the chain ran to completion — read `unqueriedTiers` for that.');
|
|
312
|
+
const UnqueriedTierSchema = z
|
|
313
|
+
.enum(['europepmc', 'unpaywall'])
|
|
314
|
+
.describe('A fallback tier this deployment has not configured');
|
|
255
315
|
const TierOutcomeSchema = z
|
|
256
316
|
.enum([
|
|
257
317
|
'not-attempted',
|
|
@@ -282,6 +342,10 @@ const UnavailableSchema = z
|
|
|
282
342
|
triedTiers: z
|
|
283
343
|
.array(TriedTierSchema)
|
|
284
344
|
.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.'),
|
|
345
|
+
unqueriedTiers: z
|
|
346
|
+
.array(UnqueriedTierSchema)
|
|
347
|
+
.optional()
|
|
348
|
+
.describe('Tiers the chain skipped because this deployment has not configured them, and that could have served this id — the search was incomplete, and a deployment with these tiers configured may still resolve the id. `triedTiers` carries which environment variable each one is waiting on. Absent when every tier that could have served the id was actually queried; a tier skipped because it was inapplicable to this id (no DOI for Unpaywall) is never listed.'),
|
|
285
349
|
})
|
|
286
350
|
.describe('One identifier that could not be returned, with the full chain it traversed');
|
|
287
351
|
// ─── Character-budget schemas ────────────────────────────────────────────────
|
|
@@ -341,14 +405,55 @@ const TruncationSchema = z
|
|
|
341
405
|
.describe('Per-article accounting, covering only the articles the budget shortened'),
|
|
342
406
|
})
|
|
343
407
|
.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.');
|
|
408
|
+
const DeferredSchema = z
|
|
409
|
+
.object({
|
|
410
|
+
maxResponseCharacters: z
|
|
411
|
+
.number()
|
|
412
|
+
.describe('The `maxResponseCharacters` ceiling this response was budgeted against'),
|
|
413
|
+
returnedCharacters: z
|
|
414
|
+
.number()
|
|
415
|
+
.describe('Serialized characters the returned article records account for'),
|
|
416
|
+
deferredCount: z
|
|
417
|
+
.number()
|
|
418
|
+
.describe('Articles the chain resolved but withheld to stay under the ceiling'),
|
|
419
|
+
idType: z
|
|
420
|
+
.enum(['pmid', 'pmcid', 'doi'])
|
|
421
|
+
.describe('Which input branch the deferred ids belong to — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries.'),
|
|
422
|
+
ids: z
|
|
423
|
+
.array(z.string())
|
|
424
|
+
.describe('Identifiers of the deferred articles, in response order, keyed as they were requested (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` branch and the same other inputs. Never contains an id from `unavailable`.'),
|
|
425
|
+
nextDeferredCharacters: z
|
|
426
|
+
.number()
|
|
427
|
+
.describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped. Raise `maxResponseCharacters` to at least this to make progress; a smaller article further down `ids` cannot be reached until this one fits.'),
|
|
428
|
+
})
|
|
429
|
+
.describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
|
|
344
430
|
/** True when the request asked for any budget at all. Without one, every budget
|
|
345
431
|
* helper returns its input untouched so the response is byte-identical. */
|
|
346
432
|
function budgetRequested(budget) {
|
|
347
433
|
return budget.maxCharacters !== undefined || budget.maxCharactersPerSection !== undefined;
|
|
348
434
|
}
|
|
349
|
-
/**
|
|
435
|
+
/** Every text field in a section subtree, in document order, own text first. */
|
|
436
|
+
function sectionTextFields(section) {
|
|
437
|
+
return [section.text, ...(section.subsections ?? []).flatMap(sectionTextFields)];
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Body characters a section carries — its own text plus every nested
|
|
441
|
+
* subsection's. Measured off {@link sectionTextFields} rather than its own walk,
|
|
442
|
+
* so the count the budget reports as `originalCharacters` is always taken over
|
|
443
|
+
* exactly the fields {@link fitFields} shortens.
|
|
444
|
+
*/
|
|
350
445
|
function sectionCharacters(section) {
|
|
351
|
-
return (section.
|
|
446
|
+
return sectionTextFields(section).reduce((n, text) => n + text.length, 0);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Rebuild a section subtree from `fitted`, consuming one entry per node in the
|
|
450
|
+
* same document order {@link sectionTextFields} produced them. `cursor` walks
|
|
451
|
+
* the flat list across the whole subtree.
|
|
452
|
+
*/
|
|
453
|
+
function withFittedTexts(section, fitted, cursor) {
|
|
454
|
+
const text = fitted[cursor.i++] ?? '';
|
|
455
|
+
const subsections = section.subsections?.map((sub) => withFittedTexts(sub, fitted, cursor));
|
|
456
|
+
return { ...section, text, ...(subsections && { subsections }) };
|
|
352
457
|
}
|
|
353
458
|
/**
|
|
354
459
|
* Shorten an ordered list of text fields so their combined length fits
|
|
@@ -448,7 +553,7 @@ function applyPmcBudget(article, budget) {
|
|
|
448
553
|
let returnedCharacters = 0;
|
|
449
554
|
article.sections.forEach((section, i) => {
|
|
450
555
|
const original = sizes[i] ?? 0;
|
|
451
|
-
const fitted = fitFields(
|
|
556
|
+
const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
|
|
452
557
|
const returned = fitted.reduce((sum, text) => sum + text.length, 0);
|
|
453
558
|
returnedCharacters += returned;
|
|
454
559
|
sectionReports.push({
|
|
@@ -461,13 +566,7 @@ function applyPmcBudget(article, budget) {
|
|
|
461
566
|
omittedSections += 1;
|
|
462
567
|
return;
|
|
463
568
|
}
|
|
464
|
-
kept.push({
|
|
465
|
-
...section,
|
|
466
|
-
text: fitted[0] ?? '',
|
|
467
|
-
...(section.subsections && {
|
|
468
|
-
subsections: section.subsections.map((sub, j) => ({ ...sub, text: fitted[j + 1] ?? '' })),
|
|
469
|
-
}),
|
|
470
|
-
});
|
|
569
|
+
kept.push(withFittedTexts(section, fitted, { i: 0 }));
|
|
471
570
|
});
|
|
472
571
|
if (returnedCharacters === originalCharacters && omittedSections === 0) {
|
|
473
572
|
return { article, omittedSections: 0 };
|
|
@@ -512,6 +611,29 @@ function buildTruncationNotice(truncation) {
|
|
|
512
611
|
].filter((k) => k !== undefined);
|
|
513
612
|
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.`;
|
|
514
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Compose the recovery notice for articles the whole-response budget withheld.
|
|
616
|
+
* Names what was spent, which identifiers are still retrievable, and the ceiling
|
|
617
|
+
* the next call has to clear — so a caller reading only `content[]` can resume
|
|
618
|
+
* without inspecting `deferred`. (#100)
|
|
619
|
+
*/
|
|
620
|
+
function buildDeferralNotice(deferred) {
|
|
621
|
+
const spent = deferred.returnedCharacters === 0
|
|
622
|
+
? `No article fits the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
|
|
623
|
+
: `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
|
|
624
|
+
return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. Re-call pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\` to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Body sections an article's per-article budget dropped, derived from that
|
|
628
|
+
* article's own accounting by the rule {@link applyPmcBudget} counts by:
|
|
629
|
+
* `outline` mode keeps every heading, so it drops none. Used to take a deferred
|
|
630
|
+
* article's contribution back out of the response-level roll-up. (#100)
|
|
631
|
+
*/
|
|
632
|
+
function countOmittedSections(entry, mode) {
|
|
633
|
+
if (mode !== 'truncate')
|
|
634
|
+
return 0;
|
|
635
|
+
return (entry.sections ?? []).filter((s) => s.originalCharacters > 0 && s.returnedCharacters === 0).length;
|
|
636
|
+
}
|
|
515
637
|
// ─── Tool Definition ─────────────────────────────────────────────────────────
|
|
516
638
|
/**
|
|
517
639
|
* Compose the tool description for the fallback tiers enabled in this
|
|
@@ -546,7 +668,8 @@ export function buildFulltextDescription(tiers) {
|
|
|
546
668
|
? '; DOIs with no PMC copy recover via Unpaywall open access'
|
|
547
669
|
: '';
|
|
548
670
|
const input = `Provide exactly one of \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), or \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}).`;
|
|
549
|
-
|
|
671
|
+
const budget = 'Two independent character controls: `maxCharacters` caps body text per article, `maxResponseCharacters` caps the whole response and defers articles past the ceiling whole, listing them in `deferred.ids` for a follow-up call.';
|
|
672
|
+
return `${base} ${fallback} ${input} ${budget}`;
|
|
550
673
|
}
|
|
551
674
|
const serverConfig = getServerConfig();
|
|
552
675
|
export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
@@ -605,7 +728,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
605
728
|
.min(1)
|
|
606
729
|
.max(1_000_000)
|
|
607
730
|
.optional()
|
|
608
|
-
.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.
|
|
731
|
+
.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. This knob alone bounds only bodies: the response-wide ceiling it implies is this value times the number of articles returned, plus every uncounted field. Use `maxResponseCharacters` for a true whole-response ceiling. Omit for the full body.'),
|
|
609
732
|
maxCharactersPerSection: z
|
|
610
733
|
.number()
|
|
611
734
|
.int()
|
|
@@ -613,6 +736,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
613
736
|
.max(1_000_000)
|
|
614
737
|
.optional()
|
|
615
738
|
.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.'),
|
|
739
|
+
maxResponseCharacters: z
|
|
740
|
+
.number()
|
|
741
|
+
.int()
|
|
742
|
+
.min(1)
|
|
743
|
+
.max(1_000_000)
|
|
744
|
+
.optional()
|
|
745
|
+
.describe('Opt-in ceiling for the whole response, in characters — the true response-wide counterpart to the per-article `maxCharacters`. Each article is measured as the JSON record it is returned as, after every filter and the per-article body budget: title, abstract, body sections, references, identifiers, license and source metadata — every field it carries. One ledger covers all tiers, so PMC-, Europe PMC-, and Unpaywall-served articles spend the same budget. Articles are kept in response order until the next one would cross the ceiling; that article and the rest are deferred whole (never partially populated) and listed in `deferred.ids`. Response envelope fields — counts, `unavailable`, `truncation`, `deferred` itself — are not counted. Omit to return every resolved article.'),
|
|
616
746
|
overflowMode: z
|
|
617
747
|
.enum(['truncate', 'outline'])
|
|
618
748
|
.default('truncate')
|
|
@@ -623,27 +753,31 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
623
753
|
}),
|
|
624
754
|
output: z.object({
|
|
625
755
|
articles: z.array(ArticleSchema).describe('Full-text articles'),
|
|
626
|
-
totalReturned: z
|
|
756
|
+
totalReturned: z
|
|
757
|
+
.number()
|
|
758
|
+
.describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
|
|
627
759
|
unavailable: z
|
|
628
760
|
.array(UnavailableSchema)
|
|
629
761
|
.optional()
|
|
630
|
-
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
|
|
762
|
+
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both.'),
|
|
631
763
|
truncation: TruncationSchema.optional(),
|
|
764
|
+
deferred: DeferredSchema.optional(),
|
|
632
765
|
}),
|
|
633
|
-
// Recovery guidance for
|
|
766
|
+
// Recovery guidance for four cases — a `sections` filter that removed every
|
|
634
767
|
// body section (#80), a record the chain could only retrieve as front matter
|
|
635
|
-
// (#86),
|
|
636
|
-
//
|
|
637
|
-
// when none
|
|
768
|
+
// (#86), a body the per-article character budget shortened (#81), and articles
|
|
769
|
+
// the whole-response budget withheld (#100). Agent-facing context surfaced via
|
|
770
|
+
// ctx.enrich.notice() to structuredContent and content[]; absent when none
|
|
771
|
+
// applies.
|
|
638
772
|
enrichment: {
|
|
639
773
|
notice: z
|
|
640
774
|
.string()
|
|
641
775
|
.optional()
|
|
642
|
-
.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.'),
|
|
776
|
+
.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`. A response-wide budget that deferred articles names the ids to re-request. Absent when none of those applies.'),
|
|
643
777
|
truncated: z
|
|
644
778
|
.boolean()
|
|
645
779
|
.optional()
|
|
646
|
-
.describe('True when a character budget shortened at least one returned body. Absent when every
|
|
780
|
+
.describe('True when a character budget shortened at least one returned body, or withheld a whole article. Absent when every resolved article is present with its full post-filter body. The per-article body accounting is in `truncation`; the withheld ids are in `deferred`.'),
|
|
647
781
|
},
|
|
648
782
|
async handler(input, ctx) {
|
|
649
783
|
ctx.log.info('Executing pubmed_fetch_fulltext', {
|
|
@@ -659,6 +793,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
659
793
|
// for, so we can skip them when building `unavailable[]`.
|
|
660
794
|
const chainByInput = new Map();
|
|
661
795
|
const recoveredIds = new Set();
|
|
796
|
+
// Per-input-id set of tiers this deployment has not configured AND that
|
|
797
|
+
// could have served that id — the `unqueriedTiers` array on unavailable
|
|
798
|
+
// entries. Insertion order is chain order, so the array reads in the order
|
|
799
|
+
// the tiers would have run. A tier skipped as inapplicable is never marked;
|
|
800
|
+
// that is a settled answer, not an incomplete search. (#110)
|
|
801
|
+
const unqueriedByInput = new Map();
|
|
802
|
+
const markUnqueried = (inputId, tier) => {
|
|
803
|
+
const tiers = unqueriedByInput.get(inputId) ?? new Set();
|
|
804
|
+
tiers.add(tier);
|
|
805
|
+
unqueriedByInput.set(inputId, tiers);
|
|
806
|
+
};
|
|
662
807
|
// Back-map from a converter-resolved prefixed PMCID to the input id that
|
|
663
808
|
// seeded it — a PMID for `pmids` input, a DOI for `dois` input — so the PMC
|
|
664
809
|
// and EPMC stages attribute recoveries and misses to the original input id.
|
|
@@ -679,6 +824,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
679
824
|
// requested or nothing exceeded it (#81).
|
|
680
825
|
const truncatedArticles = [];
|
|
681
826
|
let omittedSections = 0;
|
|
827
|
+
// The input id each returned article was requested under, so the
|
|
828
|
+
// whole-response budget can hand deferred articles back as identifiers the
|
|
829
|
+
// caller can re-submit rather than whatever id the article happens to
|
|
830
|
+
// carry — a `pmids` request recovers articles keyed by PMCID. (#100)
|
|
831
|
+
const inputIdByArticle = new Map();
|
|
682
832
|
const budget = {
|
|
683
833
|
overflowMode: input.overflowMode,
|
|
684
834
|
...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
|
|
@@ -843,7 +993,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
843
993
|
...budgeted.truncation,
|
|
844
994
|
});
|
|
845
995
|
}
|
|
846
|
-
|
|
996
|
+
const article = {
|
|
997
|
+
source: 'pmc',
|
|
998
|
+
viaSource: 'pmc',
|
|
999
|
+
...budgeted.article,
|
|
1000
|
+
};
|
|
1001
|
+
parsed.push(article);
|
|
1002
|
+
if (article.pmcId) {
|
|
1003
|
+
inputIdByArticle.set(article, pmcidToInputId.get(article.pmcId) ?? article.pmcId);
|
|
1004
|
+
}
|
|
847
1005
|
}
|
|
848
1006
|
pmcArticles = parsed;
|
|
849
1007
|
const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
|
|
@@ -905,10 +1063,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
905
1063
|
sectionFilterMisses: [],
|
|
906
1064
|
truncatedArticles: [],
|
|
907
1065
|
omittedSections: 0,
|
|
1066
|
+
articleInputIds: new Map(),
|
|
908
1067
|
};
|
|
909
1068
|
pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
|
|
910
1069
|
truncatedArticles.push(...epmcOutcomes.truncatedArticles);
|
|
911
1070
|
omittedSections += epmcOutcomes.omittedSections;
|
|
1071
|
+
for (const [article, candidateId] of epmcOutcomes.articleInputIds) {
|
|
1072
|
+
inputIdByArticle.set(article, pmcidToInputId.get(candidateId) ?? candidateId);
|
|
1073
|
+
}
|
|
912
1074
|
// Fold EPMC outcomes into each id's chain. EPMC-served articles count as
|
|
913
1075
|
// recovered, so their ids are added to `recoveredIds` here.
|
|
914
1076
|
if (!epmc) {
|
|
@@ -917,14 +1079,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
917
1079
|
outcome: 'not-attempted',
|
|
918
1080
|
detail: 'EUROPEPMC_ENABLED=false',
|
|
919
1081
|
};
|
|
1082
|
+
// EPMC searches by PMID, PMCID, and DOI alike, so it could have served
|
|
1083
|
+
// every candidate that reached this stage — no applicability test.
|
|
1084
|
+
const skipEpmc = (inputId) => {
|
|
1085
|
+
chainByInput.get(inputId)?.push(epmcDisabledEntry);
|
|
1086
|
+
markUnqueried(inputId, 'europepmc');
|
|
1087
|
+
};
|
|
920
1088
|
for (const c of pmidFallbackCandidates)
|
|
921
|
-
|
|
1089
|
+
skipEpmc(c.pmid);
|
|
922
1090
|
for (const c of pmcidFallbackCandidates) {
|
|
923
1091
|
const prefixed = withPmcPrefix(c.pmcid);
|
|
924
|
-
|
|
1092
|
+
skipEpmc(pmcidToInputId.get(prefixed) ?? prefixed);
|
|
925
1093
|
}
|
|
926
1094
|
for (const c of doiCandidates)
|
|
927
|
-
|
|
1095
|
+
skipEpmc(c.doi);
|
|
928
1096
|
}
|
|
929
1097
|
else {
|
|
930
1098
|
const foldEpmcOutcome = (inputId, outcome) => {
|
|
@@ -957,13 +1125,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
957
1125
|
// Converter, which returns DOIs for PMC-indexed records. (#88)
|
|
958
1126
|
if (pmcidFallbackCandidates.length > 0) {
|
|
959
1127
|
if (!unpaywall) {
|
|
1128
|
+
// The PMCID → DOI lookup below only runs when Unpaywall is configured,
|
|
1129
|
+
// so a candidate arrives here with a DOI only if the EPMC stage handed
|
|
1130
|
+
// one over. An absent DOI therefore means "never looked up", not "this
|
|
1131
|
+
// record has none" — the tier stays a genuine unknown and is marked.
|
|
960
1132
|
for (const c of pmcidFallbackCandidates) {
|
|
961
1133
|
const prefixed = withPmcPrefix(c.pmcid);
|
|
962
|
-
|
|
1134
|
+
const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
|
|
1135
|
+
chainByInput.get(inputId)?.push({
|
|
963
1136
|
tier: 'unpaywall',
|
|
964
1137
|
outcome: 'not-attempted',
|
|
965
1138
|
detail: 'UNPAYWALL_EMAIL is not set',
|
|
966
1139
|
});
|
|
1140
|
+
markUnqueried(inputId, 'unpaywall');
|
|
967
1141
|
}
|
|
968
1142
|
}
|
|
969
1143
|
else {
|
|
@@ -1009,6 +1183,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1009
1183
|
const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
|
|
1010
1184
|
if ('article' in result) {
|
|
1011
1185
|
fallbackArticles.push(result.article);
|
|
1186
|
+
inputIdByArticle.set(result.article, inputId);
|
|
1012
1187
|
if (result.truncation)
|
|
1013
1188
|
truncatedArticles.push(result.truncation);
|
|
1014
1189
|
recoveredIds.add(inputId);
|
|
@@ -1047,12 +1222,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1047
1222
|
}
|
|
1048
1223
|
}
|
|
1049
1224
|
if (!unpaywall) {
|
|
1225
|
+
// `fetchPubmedDois` has already run, so the DOI state is settled here.
|
|
1226
|
+
// A candidate with no DOI could not have reached Unpaywall configured
|
|
1227
|
+
// or not — that is `no-doi`, a real answer, not an incomplete search.
|
|
1050
1228
|
for (const c of pmidFallbackCandidates) {
|
|
1229
|
+
if (!c.doi) {
|
|
1230
|
+
chainByInput.get(c.pmid)?.push({ tier: 'unpaywall', outcome: 'no-doi' });
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1051
1233
|
chainByInput.get(c.pmid)?.push({
|
|
1052
1234
|
tier: 'unpaywall',
|
|
1053
1235
|
outcome: 'not-attempted',
|
|
1054
1236
|
detail: 'UNPAYWALL_EMAIL is not set',
|
|
1055
1237
|
});
|
|
1238
|
+
markUnqueried(c.pmid, 'unpaywall');
|
|
1056
1239
|
}
|
|
1057
1240
|
}
|
|
1058
1241
|
else {
|
|
@@ -1065,6 +1248,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1065
1248
|
for (const { candidate, result } of outcomes) {
|
|
1066
1249
|
if ('article' in result) {
|
|
1067
1250
|
fallbackArticles.push(result.article);
|
|
1251
|
+
inputIdByArticle.set(result.article, candidate.pmid);
|
|
1068
1252
|
if (result.truncation)
|
|
1069
1253
|
truncatedArticles.push(result.truncation);
|
|
1070
1254
|
recoveredIds.add(candidate.pmid);
|
|
@@ -1082,12 +1266,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1082
1266
|
}
|
|
1083
1267
|
if (doiCandidates.length > 0) {
|
|
1084
1268
|
if (!unpaywall) {
|
|
1269
|
+
// Every candidate on this branch is a DOI, so Unpaywall applies to all
|
|
1270
|
+
// of them.
|
|
1085
1271
|
for (const c of doiCandidates) {
|
|
1086
1272
|
chainByInput.get(c.doi)?.push({
|
|
1087
1273
|
tier: 'unpaywall',
|
|
1088
1274
|
outcome: 'not-attempted',
|
|
1089
1275
|
detail: 'UNPAYWALL_EMAIL is not set',
|
|
1090
1276
|
});
|
|
1277
|
+
markUnqueried(c.doi, 'unpaywall');
|
|
1091
1278
|
}
|
|
1092
1279
|
}
|
|
1093
1280
|
else {
|
|
@@ -1100,6 +1287,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1100
1287
|
for (const { doi, result } of outcomes) {
|
|
1101
1288
|
if ('article' in result) {
|
|
1102
1289
|
fallbackArticles.push(result.article);
|
|
1290
|
+
inputIdByArticle.set(result.article, doi);
|
|
1103
1291
|
if (result.truncation)
|
|
1104
1292
|
truncatedArticles.push(result.truncation);
|
|
1105
1293
|
recoveredIds.add(doi);
|
|
@@ -1120,14 +1308,50 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1120
1308
|
for (const [id, chain] of chainByInput) {
|
|
1121
1309
|
if (recoveredIds.has(id))
|
|
1122
1310
|
continue;
|
|
1311
|
+
const unqueried = unqueriedByInput.get(id);
|
|
1123
1312
|
unavailable.push({
|
|
1124
1313
|
id,
|
|
1125
1314
|
idType,
|
|
1126
1315
|
reason: reasonFromChain(chain),
|
|
1127
1316
|
triedTiers: chain,
|
|
1317
|
+
...(unqueried?.size && { unqueriedTiers: [...unqueried] }),
|
|
1128
1318
|
});
|
|
1129
1319
|
}
|
|
1130
|
-
|
|
1320
|
+
// Whole-response budget: fill with complete records in response order and
|
|
1321
|
+
// hand the rest back as identifiers the caller can re-submit. One ledger for
|
|
1322
|
+
// every tier — a PMC-served article and an Unpaywall-served one spend the
|
|
1323
|
+
// same characters. Without `maxResponseCharacters` nothing is measured and
|
|
1324
|
+
// the response is exactly what it was before the budget existed. (#100)
|
|
1325
|
+
const resolved = [...pmcArticles, ...fallbackArticles];
|
|
1326
|
+
const ceiling = input.maxResponseCharacters;
|
|
1327
|
+
const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
|
|
1328
|
+
const articles = fit?.kept ?? resolved;
|
|
1329
|
+
const nextDeferredCharacters = fit?.nextDeferredCharacters;
|
|
1330
|
+
const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
|
|
1331
|
+
? {
|
|
1332
|
+
maxResponseCharacters: ceiling,
|
|
1333
|
+
returnedCharacters: fit.keptCharacters,
|
|
1334
|
+
deferredCount: fit.deferred.length,
|
|
1335
|
+
idType,
|
|
1336
|
+
// Every recovery site records the input id; `articleDisplayId` is
|
|
1337
|
+
// the total-function fallback, not an expected path.
|
|
1338
|
+
ids: fit.deferred.map((a) => inputIdByArticle.get(a) ?? articleDisplayId(a)),
|
|
1339
|
+
nextDeferredCharacters,
|
|
1340
|
+
}
|
|
1341
|
+
: undefined;
|
|
1342
|
+
// A deferred article takes its body accounting out of the response with it —
|
|
1343
|
+
// those counts describe text the caller never received.
|
|
1344
|
+
if (fit) {
|
|
1345
|
+
for (const article of fit.deferred) {
|
|
1346
|
+
const id = articleDisplayId(article);
|
|
1347
|
+
const index = truncatedArticles.findIndex((t) => t.id === id);
|
|
1348
|
+
if (index === -1)
|
|
1349
|
+
continue;
|
|
1350
|
+
const [dropped] = truncatedArticles.splice(index, 1);
|
|
1351
|
+
if (dropped)
|
|
1352
|
+
omittedSections -= countOmittedSections(dropped, input.overflowMode);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1131
1355
|
ctx.log.info('pubmed_fetch_fulltext completed', {
|
|
1132
1356
|
requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
|
|
1133
1357
|
returned: articles.length,
|
|
@@ -1135,6 +1359,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1135
1359
|
epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
|
|
1136
1360
|
unpaywallHits: fallbackArticles.length,
|
|
1137
1361
|
unavailable: unavailable.length,
|
|
1362
|
+
...(deferred && { deferred: deferred.deferredCount }),
|
|
1138
1363
|
});
|
|
1139
1364
|
// Rolled up only when the budget actually removed characters, so an
|
|
1140
1365
|
// under-budget request returns exactly what it did before the budget
|
|
@@ -1165,6 +1390,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1165
1390
|
notices.push(buildTruncationNotice(truncation));
|
|
1166
1391
|
ctx.enrich({ truncated: true });
|
|
1167
1392
|
}
|
|
1393
|
+
if (deferred) {
|
|
1394
|
+
notices.push(buildDeferralNotice(deferred));
|
|
1395
|
+
ctx.enrich({ truncated: true });
|
|
1396
|
+
}
|
|
1168
1397
|
if (notices.length > 0)
|
|
1169
1398
|
ctx.enrich.notice(notices.join(' '));
|
|
1170
1399
|
return {
|
|
@@ -1172,14 +1401,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1172
1401
|
totalReturned: articles.length,
|
|
1173
1402
|
...(unavailable.length > 0 && { unavailable }),
|
|
1174
1403
|
...(truncation && { truncation }),
|
|
1404
|
+
...(deferred && { deferred }),
|
|
1175
1405
|
};
|
|
1176
1406
|
},
|
|
1177
1407
|
format: (result) => {
|
|
1178
1408
|
const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
|
|
1179
1409
|
if (result.unavailable?.length) {
|
|
1180
1410
|
lines.push(`\n**Unavailable (${result.unavailable.length}):**`);
|
|
1411
|
+
let anyUnqueried = false;
|
|
1181
1412
|
for (const u of result.unavailable) {
|
|
1182
1413
|
lines.push(`- [${u.idType}] ${u.id} — ${u.reason}`);
|
|
1414
|
+
if (u.unqueriedTiers?.length) {
|
|
1415
|
+
anyUnqueried = true;
|
|
1416
|
+
lines.push(` Not queried: ${formatUnqueriedTiers(u.unqueriedTiers, u.triedTiers)}`);
|
|
1417
|
+
}
|
|
1183
1418
|
const chain = u.triedTiers
|
|
1184
1419
|
.map((t) => {
|
|
1185
1420
|
const detail = t.detail ? sanitizeChainDetail(t.detail) : undefined;
|
|
@@ -1189,8 +1424,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1189
1424
|
if (chain)
|
|
1190
1425
|
lines.push(` chain: ${chain}`);
|
|
1191
1426
|
}
|
|
1427
|
+
// One explanation for the whole list — repeating it per entry buries the
|
|
1428
|
+
// ids it qualifies.
|
|
1429
|
+
if (anyUnqueried) {
|
|
1430
|
+
lines.push(`\n> Tiers marked "Not queried" were skipped because this deployment has not configured them, so those searches are incomplete — a deployment with those tiers configured may still resolve the affected ids.`);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (result.deferred) {
|
|
1434
|
+
const d = result.deferred;
|
|
1435
|
+
lines.push(`\n**Deferred by the response budget:** ${d.deferredCount} article(s) — ${d.returnedCharacters} of ${d.maxResponseCharacters} budgeted characters returned; next deferred article ${d.nextDeferredCharacters} characters`, `Re-call \`pubmed_fetch_fulltext\` with these ${d.idType} ids as \`${d.idType}s\`: ${d.ids.join(', ')}`);
|
|
1192
1436
|
}
|
|
1193
|
-
|
|
1437
|
+
// An empty response under a budget is a deferral, not an absence — the
|
|
1438
|
+
// articles resolved and the ids above retrieve them.
|
|
1439
|
+
if (result.totalReturned === 0 && !result.deferred) {
|
|
1194
1440
|
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\`.`);
|
|
1195
1441
|
}
|
|
1196
1442
|
if (result.truncation)
|
|
@@ -1272,6 +1518,7 @@ async function runEpmcStage(epmc, args) {
|
|
|
1272
1518
|
Promise.all(args.doiCandidates.map(fetchForDoi)),
|
|
1273
1519
|
]);
|
|
1274
1520
|
const articles = [];
|
|
1521
|
+
const articleInputIds = new Map();
|
|
1275
1522
|
const remainingPmid = [];
|
|
1276
1523
|
const remainingPmcid = [];
|
|
1277
1524
|
const remainingDoi = [];
|
|
@@ -1281,8 +1528,9 @@ async function runEpmcStage(epmc, args) {
|
|
|
1281
1528
|
const sectionFilterMisses = [];
|
|
1282
1529
|
const truncatedArticles = [];
|
|
1283
1530
|
let omittedSections = 0;
|
|
1284
|
-
const collectHit = (run) => {
|
|
1531
|
+
const collectHit = (candidateId, run) => {
|
|
1285
1532
|
articles.push(run.article);
|
|
1533
|
+
articleInputIds.set(run.article, candidateId);
|
|
1286
1534
|
if (run.sectionFilterMiss)
|
|
1287
1535
|
sectionFilterMisses.push(articleDisplayId(run.article));
|
|
1288
1536
|
if (run.truncation)
|
|
@@ -1292,26 +1540,27 @@ async function runEpmcStage(epmc, args) {
|
|
|
1292
1540
|
for (const run of pmidResults) {
|
|
1293
1541
|
pmidOutcomes.set(run.c.pmid, run.outcome);
|
|
1294
1542
|
if (run.article)
|
|
1295
|
-
collectHit({ ...run, article: run.article });
|
|
1543
|
+
collectHit(run.c.pmid, { ...run, article: run.article });
|
|
1296
1544
|
else
|
|
1297
1545
|
remainingPmid.push(run.c);
|
|
1298
1546
|
}
|
|
1299
1547
|
for (const run of pmcidResults) {
|
|
1300
1548
|
pmcidOutcomes.set(run.c.normalized, run.outcome);
|
|
1301
1549
|
if (run.article)
|
|
1302
|
-
collectHit({ ...run, article: run.article });
|
|
1550
|
+
collectHit(run.c.normalized, { ...run, article: run.article });
|
|
1303
1551
|
else
|
|
1304
1552
|
remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
|
|
1305
1553
|
}
|
|
1306
1554
|
for (const run of doiResults) {
|
|
1307
1555
|
doiOutcomes.set(run.c.doi, run.outcome);
|
|
1308
1556
|
if (run.article)
|
|
1309
|
-
collectHit({ ...run, article: run.article });
|
|
1557
|
+
collectHit(run.c.doi, { ...run, article: run.article });
|
|
1310
1558
|
else
|
|
1311
1559
|
remainingDoi.push(run.c);
|
|
1312
1560
|
}
|
|
1313
1561
|
return {
|
|
1314
1562
|
articles,
|
|
1563
|
+
articleInputIds,
|
|
1315
1564
|
remainingPmid,
|
|
1316
1565
|
remainingPmcid,
|
|
1317
1566
|
remainingDoi,
|
|
@@ -1614,11 +1863,15 @@ function unpaywallReasonToTierOutcome(reason) {
|
|
|
1614
1863
|
}
|
|
1615
1864
|
}
|
|
1616
1865
|
/**
|
|
1617
|
-
* Derive the
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1866
|
+
* Derive the `reason` shown on the unavailable entry from its chain: the most
|
|
1867
|
+
* specific content signal the last tier that actually answered reported.
|
|
1868
|
+
*
|
|
1869
|
+
* Skipped tiers are deliberately not folded in here. A configuration note in
|
|
1870
|
+
* place of the content signal would erase the one specific thing the chain
|
|
1871
|
+
* learned; the incompleteness is reported alongside it, on `unqueriedTiers`,
|
|
1872
|
+
* where it adds to the answer instead of replacing it. A chain where no tier
|
|
1873
|
+
* was attempted at all has no signal to report and stays
|
|
1874
|
+
* `no-pmc-fallback-disabled`.
|
|
1622
1875
|
*/
|
|
1623
1876
|
function reasonFromChain(chain) {
|
|
1624
1877
|
let lastSignal;
|
|
@@ -1628,6 +1881,13 @@ function reasonFromChain(chain) {
|
|
|
1628
1881
|
}
|
|
1629
1882
|
if (!lastSignal)
|
|
1630
1883
|
return 'no-pmc-fallback-disabled';
|
|
1884
|
+
// Unpaywall answering `no-doi` for an id the content tiers never found adds
|
|
1885
|
+
// nothing: record absence is the specific signal, so it stays `not-found`.
|
|
1886
|
+
if (lastSignal.tier === 'unpaywall' && lastSignal.outcome === 'no-doi') {
|
|
1887
|
+
const lastContentSignal = chain.findLast((t) => t.tier !== 'unpaywall' && t.outcome !== 'not-attempted');
|
|
1888
|
+
if (lastContentSignal?.outcome === 'miss')
|
|
1889
|
+
return 'not-found';
|
|
1890
|
+
}
|
|
1631
1891
|
const key = `${lastSignal.tier}:${lastSignal.outcome}`;
|
|
1632
1892
|
switch (key) {
|
|
1633
1893
|
case 'pmc:miss':
|
|
@@ -1655,6 +1915,26 @@ function reasonFromChain(chain) {
|
|
|
1655
1915
|
}
|
|
1656
1916
|
}
|
|
1657
1917
|
// ─── format() helpers ────────────────────────────────────────────────────────
|
|
1918
|
+
/** Human-readable tier names for the unqueried-tier line. */
|
|
1919
|
+
const UNQUERIED_TIER_LABELS = {
|
|
1920
|
+
europepmc: 'Europe PMC',
|
|
1921
|
+
unpaywall: 'Unpaywall',
|
|
1922
|
+
};
|
|
1923
|
+
/**
|
|
1924
|
+
* Name each unqueried tier with the reason its chain entry gave for skipping it,
|
|
1925
|
+
* so a `content[]` reader learns which setting is missing without decoding the
|
|
1926
|
+
* chain line. The detail goes through the same sanitizer the chain does — it is
|
|
1927
|
+
* the same upstream string. (#110)
|
|
1928
|
+
*/
|
|
1929
|
+
function formatUnqueriedTiers(tiers, chain) {
|
|
1930
|
+
return tiers
|
|
1931
|
+
.map((tier) => {
|
|
1932
|
+
const detail = chain.find((t) => t.tier === tier && t.outcome === 'not-attempted')?.detail;
|
|
1933
|
+
const label = UNQUERIED_TIER_LABELS[tier];
|
|
1934
|
+
return detail ? `${label} (${sanitizeChainDetail(detail)})` : label;
|
|
1935
|
+
})
|
|
1936
|
+
.join(', ');
|
|
1937
|
+
}
|
|
1658
1938
|
/**
|
|
1659
1939
|
* Render the response-level character accounting. Every field is rendered
|
|
1660
1940
|
* unconditionally so `content[]` readers see the same budget detail
|
|
@@ -1739,20 +2019,8 @@ function formatPmcArticle(a, lines, truncation) {
|
|
|
1739
2019
|
lines.push(truncationNote(truncation));
|
|
1740
2020
|
if (a.abstract)
|
|
1741
2021
|
lines.push(`\n#### Abstract\n${a.abstract}`);
|
|
1742
|
-
for (const sec of a.sections)
|
|
1743
|
-
|
|
1744
|
-
lines.push(`\n#### ${formatHeading(sec.label, sec.title)}`);
|
|
1745
|
-
if (sec.text)
|
|
1746
|
-
lines.push(sec.text);
|
|
1747
|
-
if (sec.subsections?.length) {
|
|
1748
|
-
for (const sub of sec.subsections) {
|
|
1749
|
-
if (sub.title)
|
|
1750
|
-
lines.push(`\n##### ${formatHeading(sub.label, sub.title)}`);
|
|
1751
|
-
if (sub.text)
|
|
1752
|
-
lines.push(sub.text);
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
2022
|
+
for (const sec of a.sections)
|
|
2023
|
+
formatSection(sec, lines, 4);
|
|
1756
2024
|
if (a.references?.length) {
|
|
1757
2025
|
lines.push(`\n#### References (${a.references.length})`);
|
|
1758
2026
|
for (const ref of a.references) {
|
|
@@ -1804,6 +2072,21 @@ function formatPmcAuthor(au) {
|
|
|
1804
2072
|
function formatHeading(label, title) {
|
|
1805
2073
|
return label ? `${label} ${title}` : title;
|
|
1806
2074
|
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Render one body section and everything nested under it, one markdown heading
|
|
2077
|
+
* level per nesting level. Walks the full depth the output schema carries, so
|
|
2078
|
+
* `content[]` shows every section `structuredContent` does. Headings stop
|
|
2079
|
+
* deepening at `######`, the deepest markdown supports. (#112)
|
|
2080
|
+
*/
|
|
2081
|
+
function formatSection(section, lines, depth) {
|
|
2082
|
+
if (section.title) {
|
|
2083
|
+
lines.push(`\n${'#'.repeat(Math.min(depth, 6))} ${formatHeading(section.label, section.title)}`);
|
|
2084
|
+
}
|
|
2085
|
+
if (section.text)
|
|
2086
|
+
lines.push(section.text);
|
|
2087
|
+
for (const sub of section.subsections ?? [])
|
|
2088
|
+
formatSection(sub, lines, depth + 1);
|
|
2089
|
+
}
|
|
1807
2090
|
/**
|
|
1808
2091
|
* Strip absolute URLs from chain detail strings. Upstream errors (e.g.
|
|
1809
2092
|
* `Fetch failed for <eutils URL>. Status: 400`) leak endpoint paths and query
|