@cyanheads/pubmed-mcp-server 2.10.6 → 2.10.7
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 +1 -1
- package/CLAUDE.md +1 -1
- 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 +238 -27
- 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/package.json +1 -1
- 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';
|
|
@@ -251,7 +252,10 @@ const UnavailableReasonSchema = z
|
|
|
251
252
|
'parse-failed',
|
|
252
253
|
'service-error',
|
|
253
254
|
])
|
|
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).');
|
|
255
|
+
.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.');
|
|
256
|
+
const UnqueriedTierSchema = z
|
|
257
|
+
.enum(['europepmc', 'unpaywall'])
|
|
258
|
+
.describe('A fallback tier this deployment has not configured');
|
|
255
259
|
const TierOutcomeSchema = z
|
|
256
260
|
.enum([
|
|
257
261
|
'not-attempted',
|
|
@@ -282,6 +286,10 @@ const UnavailableSchema = z
|
|
|
282
286
|
triedTiers: z
|
|
283
287
|
.array(TriedTierSchema)
|
|
284
288
|
.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.'),
|
|
289
|
+
unqueriedTiers: z
|
|
290
|
+
.array(UnqueriedTierSchema)
|
|
291
|
+
.optional()
|
|
292
|
+
.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
293
|
})
|
|
286
294
|
.describe('One identifier that could not be returned, with the full chain it traversed');
|
|
287
295
|
// ─── Character-budget schemas ────────────────────────────────────────────────
|
|
@@ -341,6 +349,28 @@ const TruncationSchema = z
|
|
|
341
349
|
.describe('Per-article accounting, covering only the articles the budget shortened'),
|
|
342
350
|
})
|
|
343
351
|
.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.');
|
|
352
|
+
const DeferredSchema = z
|
|
353
|
+
.object({
|
|
354
|
+
maxResponseCharacters: z
|
|
355
|
+
.number()
|
|
356
|
+
.describe('The `maxResponseCharacters` ceiling this response was budgeted against'),
|
|
357
|
+
returnedCharacters: z
|
|
358
|
+
.number()
|
|
359
|
+
.describe('Serialized characters the returned article records account for'),
|
|
360
|
+
deferredCount: z
|
|
361
|
+
.number()
|
|
362
|
+
.describe('Articles the chain resolved but withheld to stay under the ceiling'),
|
|
363
|
+
idType: z
|
|
364
|
+
.enum(['pmid', 'pmcid', 'doi'])
|
|
365
|
+
.describe('Which input branch the deferred ids belong to — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries.'),
|
|
366
|
+
ids: z
|
|
367
|
+
.array(z.string())
|
|
368
|
+
.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`.'),
|
|
369
|
+
nextDeferredCharacters: z
|
|
370
|
+
.number()
|
|
371
|
+
.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.'),
|
|
372
|
+
})
|
|
373
|
+
.describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
|
|
344
374
|
/** True when the request asked for any budget at all. Without one, every budget
|
|
345
375
|
* helper returns its input untouched so the response is byte-identical. */
|
|
346
376
|
function budgetRequested(budget) {
|
|
@@ -512,6 +542,29 @@ function buildTruncationNotice(truncation) {
|
|
|
512
542
|
].filter((k) => k !== undefined);
|
|
513
543
|
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
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* Compose the recovery notice for articles the whole-response budget withheld.
|
|
547
|
+
* Names what was spent, which identifiers are still retrievable, and the ceiling
|
|
548
|
+
* the next call has to clear — so a caller reading only `content[]` can resume
|
|
549
|
+
* without inspecting `deferred`. (#100)
|
|
550
|
+
*/
|
|
551
|
+
function buildDeferralNotice(deferred) {
|
|
552
|
+
const spent = deferred.returnedCharacters === 0
|
|
553
|
+
? `No article fits the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
|
|
554
|
+
: `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
|
|
555
|
+
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.`;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Body sections an article's per-article budget dropped, derived from that
|
|
559
|
+
* article's own accounting by the rule {@link applyPmcBudget} counts by:
|
|
560
|
+
* `outline` mode keeps every heading, so it drops none. Used to take a deferred
|
|
561
|
+
* article's contribution back out of the response-level roll-up. (#100)
|
|
562
|
+
*/
|
|
563
|
+
function countOmittedSections(entry, mode) {
|
|
564
|
+
if (mode !== 'truncate')
|
|
565
|
+
return 0;
|
|
566
|
+
return (entry.sections ?? []).filter((s) => s.originalCharacters > 0 && s.returnedCharacters === 0).length;
|
|
567
|
+
}
|
|
515
568
|
// ─── Tool Definition ─────────────────────────────────────────────────────────
|
|
516
569
|
/**
|
|
517
570
|
* Compose the tool description for the fallback tiers enabled in this
|
|
@@ -546,7 +599,8 @@ export function buildFulltextDescription(tiers) {
|
|
|
546
599
|
? '; DOIs with no PMC copy recover via Unpaywall open access'
|
|
547
600
|
: '';
|
|
548
601
|
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
|
-
|
|
602
|
+
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.';
|
|
603
|
+
return `${base} ${fallback} ${input} ${budget}`;
|
|
550
604
|
}
|
|
551
605
|
const serverConfig = getServerConfig();
|
|
552
606
|
export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
@@ -605,7 +659,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
605
659
|
.min(1)
|
|
606
660
|
.max(1_000_000)
|
|
607
661
|
.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.
|
|
662
|
+
.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
663
|
maxCharactersPerSection: z
|
|
610
664
|
.number()
|
|
611
665
|
.int()
|
|
@@ -613,6 +667,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
613
667
|
.max(1_000_000)
|
|
614
668
|
.optional()
|
|
615
669
|
.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.'),
|
|
670
|
+
maxResponseCharacters: z
|
|
671
|
+
.number()
|
|
672
|
+
.int()
|
|
673
|
+
.min(1)
|
|
674
|
+
.max(1_000_000)
|
|
675
|
+
.optional()
|
|
676
|
+
.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
677
|
overflowMode: z
|
|
617
678
|
.enum(['truncate', 'outline'])
|
|
618
679
|
.default('truncate')
|
|
@@ -623,27 +684,31 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
623
684
|
}),
|
|
624
685
|
output: z.object({
|
|
625
686
|
articles: z.array(ArticleSchema).describe('Full-text articles'),
|
|
626
|
-
totalReturned: z
|
|
687
|
+
totalReturned: z
|
|
688
|
+
.number()
|
|
689
|
+
.describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
|
|
627
690
|
unavailable: z
|
|
628
691
|
.array(UnavailableSchema)
|
|
629
692
|
.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.'),
|
|
693
|
+
.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
694
|
truncation: TruncationSchema.optional(),
|
|
695
|
+
deferred: DeferredSchema.optional(),
|
|
632
696
|
}),
|
|
633
|
-
// Recovery guidance for
|
|
697
|
+
// Recovery guidance for four cases — a `sections` filter that removed every
|
|
634
698
|
// body section (#80), a record the chain could only retrieve as front matter
|
|
635
|
-
// (#86),
|
|
636
|
-
//
|
|
637
|
-
// when none
|
|
699
|
+
// (#86), a body the per-article character budget shortened (#81), and articles
|
|
700
|
+
// the whole-response budget withheld (#100). Agent-facing context surfaced via
|
|
701
|
+
// ctx.enrich.notice() to structuredContent and content[]; absent when none
|
|
702
|
+
// applies.
|
|
638
703
|
enrichment: {
|
|
639
704
|
notice: z
|
|
640
705
|
.string()
|
|
641
706
|
.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.'),
|
|
707
|
+
.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
708
|
truncated: z
|
|
644
709
|
.boolean()
|
|
645
710
|
.optional()
|
|
646
|
-
.describe('True when a character budget shortened at least one returned body. Absent when every
|
|
711
|
+
.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
712
|
},
|
|
648
713
|
async handler(input, ctx) {
|
|
649
714
|
ctx.log.info('Executing pubmed_fetch_fulltext', {
|
|
@@ -659,6 +724,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
659
724
|
// for, so we can skip them when building `unavailable[]`.
|
|
660
725
|
const chainByInput = new Map();
|
|
661
726
|
const recoveredIds = new Set();
|
|
727
|
+
// Per-input-id set of tiers this deployment has not configured AND that
|
|
728
|
+
// could have served that id — the `unqueriedTiers` array on unavailable
|
|
729
|
+
// entries. Insertion order is chain order, so the array reads in the order
|
|
730
|
+
// the tiers would have run. A tier skipped as inapplicable is never marked;
|
|
731
|
+
// that is a settled answer, not an incomplete search. (#110)
|
|
732
|
+
const unqueriedByInput = new Map();
|
|
733
|
+
const markUnqueried = (inputId, tier) => {
|
|
734
|
+
const tiers = unqueriedByInput.get(inputId) ?? new Set();
|
|
735
|
+
tiers.add(tier);
|
|
736
|
+
unqueriedByInput.set(inputId, tiers);
|
|
737
|
+
};
|
|
662
738
|
// Back-map from a converter-resolved prefixed PMCID to the input id that
|
|
663
739
|
// seeded it — a PMID for `pmids` input, a DOI for `dois` input — so the PMC
|
|
664
740
|
// and EPMC stages attribute recoveries and misses to the original input id.
|
|
@@ -679,6 +755,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
679
755
|
// requested or nothing exceeded it (#81).
|
|
680
756
|
const truncatedArticles = [];
|
|
681
757
|
let omittedSections = 0;
|
|
758
|
+
// The input id each returned article was requested under, so the
|
|
759
|
+
// whole-response budget can hand deferred articles back as identifiers the
|
|
760
|
+
// caller can re-submit rather than whatever id the article happens to
|
|
761
|
+
// carry — a `pmids` request recovers articles keyed by PMCID. (#100)
|
|
762
|
+
const inputIdByArticle = new Map();
|
|
682
763
|
const budget = {
|
|
683
764
|
overflowMode: input.overflowMode,
|
|
684
765
|
...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
|
|
@@ -843,7 +924,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
843
924
|
...budgeted.truncation,
|
|
844
925
|
});
|
|
845
926
|
}
|
|
846
|
-
|
|
927
|
+
const article = {
|
|
928
|
+
source: 'pmc',
|
|
929
|
+
viaSource: 'pmc',
|
|
930
|
+
...budgeted.article,
|
|
931
|
+
};
|
|
932
|
+
parsed.push(article);
|
|
933
|
+
if (article.pmcId) {
|
|
934
|
+
inputIdByArticle.set(article, pmcidToInputId.get(article.pmcId) ?? article.pmcId);
|
|
935
|
+
}
|
|
847
936
|
}
|
|
848
937
|
pmcArticles = parsed;
|
|
849
938
|
const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
|
|
@@ -905,10 +994,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
905
994
|
sectionFilterMisses: [],
|
|
906
995
|
truncatedArticles: [],
|
|
907
996
|
omittedSections: 0,
|
|
997
|
+
articleInputIds: new Map(),
|
|
908
998
|
};
|
|
909
999
|
pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
|
|
910
1000
|
truncatedArticles.push(...epmcOutcomes.truncatedArticles);
|
|
911
1001
|
omittedSections += epmcOutcomes.omittedSections;
|
|
1002
|
+
for (const [article, candidateId] of epmcOutcomes.articleInputIds) {
|
|
1003
|
+
inputIdByArticle.set(article, pmcidToInputId.get(candidateId) ?? candidateId);
|
|
1004
|
+
}
|
|
912
1005
|
// Fold EPMC outcomes into each id's chain. EPMC-served articles count as
|
|
913
1006
|
// recovered, so their ids are added to `recoveredIds` here.
|
|
914
1007
|
if (!epmc) {
|
|
@@ -917,14 +1010,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
917
1010
|
outcome: 'not-attempted',
|
|
918
1011
|
detail: 'EUROPEPMC_ENABLED=false',
|
|
919
1012
|
};
|
|
1013
|
+
// EPMC searches by PMID, PMCID, and DOI alike, so it could have served
|
|
1014
|
+
// every candidate that reached this stage — no applicability test.
|
|
1015
|
+
const skipEpmc = (inputId) => {
|
|
1016
|
+
chainByInput.get(inputId)?.push(epmcDisabledEntry);
|
|
1017
|
+
markUnqueried(inputId, 'europepmc');
|
|
1018
|
+
};
|
|
920
1019
|
for (const c of pmidFallbackCandidates)
|
|
921
|
-
|
|
1020
|
+
skipEpmc(c.pmid);
|
|
922
1021
|
for (const c of pmcidFallbackCandidates) {
|
|
923
1022
|
const prefixed = withPmcPrefix(c.pmcid);
|
|
924
|
-
|
|
1023
|
+
skipEpmc(pmcidToInputId.get(prefixed) ?? prefixed);
|
|
925
1024
|
}
|
|
926
1025
|
for (const c of doiCandidates)
|
|
927
|
-
|
|
1026
|
+
skipEpmc(c.doi);
|
|
928
1027
|
}
|
|
929
1028
|
else {
|
|
930
1029
|
const foldEpmcOutcome = (inputId, outcome) => {
|
|
@@ -957,13 +1056,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
957
1056
|
// Converter, which returns DOIs for PMC-indexed records. (#88)
|
|
958
1057
|
if (pmcidFallbackCandidates.length > 0) {
|
|
959
1058
|
if (!unpaywall) {
|
|
1059
|
+
// The PMCID → DOI lookup below only runs when Unpaywall is configured,
|
|
1060
|
+
// so a candidate arrives here with a DOI only if the EPMC stage handed
|
|
1061
|
+
// one over. An absent DOI therefore means "never looked up", not "this
|
|
1062
|
+
// record has none" — the tier stays a genuine unknown and is marked.
|
|
960
1063
|
for (const c of pmcidFallbackCandidates) {
|
|
961
1064
|
const prefixed = withPmcPrefix(c.pmcid);
|
|
962
|
-
|
|
1065
|
+
const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
|
|
1066
|
+
chainByInput.get(inputId)?.push({
|
|
963
1067
|
tier: 'unpaywall',
|
|
964
1068
|
outcome: 'not-attempted',
|
|
965
1069
|
detail: 'UNPAYWALL_EMAIL is not set',
|
|
966
1070
|
});
|
|
1071
|
+
markUnqueried(inputId, 'unpaywall');
|
|
967
1072
|
}
|
|
968
1073
|
}
|
|
969
1074
|
else {
|
|
@@ -1009,6 +1114,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1009
1114
|
const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
|
|
1010
1115
|
if ('article' in result) {
|
|
1011
1116
|
fallbackArticles.push(result.article);
|
|
1117
|
+
inputIdByArticle.set(result.article, inputId);
|
|
1012
1118
|
if (result.truncation)
|
|
1013
1119
|
truncatedArticles.push(result.truncation);
|
|
1014
1120
|
recoveredIds.add(inputId);
|
|
@@ -1047,12 +1153,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1047
1153
|
}
|
|
1048
1154
|
}
|
|
1049
1155
|
if (!unpaywall) {
|
|
1156
|
+
// `fetchPubmedDois` has already run, so the DOI state is settled here.
|
|
1157
|
+
// A candidate with no DOI could not have reached Unpaywall configured
|
|
1158
|
+
// or not — that is `no-doi`, a real answer, not an incomplete search.
|
|
1050
1159
|
for (const c of pmidFallbackCandidates) {
|
|
1160
|
+
if (!c.doi) {
|
|
1161
|
+
chainByInput.get(c.pmid)?.push({ tier: 'unpaywall', outcome: 'no-doi' });
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1051
1164
|
chainByInput.get(c.pmid)?.push({
|
|
1052
1165
|
tier: 'unpaywall',
|
|
1053
1166
|
outcome: 'not-attempted',
|
|
1054
1167
|
detail: 'UNPAYWALL_EMAIL is not set',
|
|
1055
1168
|
});
|
|
1169
|
+
markUnqueried(c.pmid, 'unpaywall');
|
|
1056
1170
|
}
|
|
1057
1171
|
}
|
|
1058
1172
|
else {
|
|
@@ -1065,6 +1179,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1065
1179
|
for (const { candidate, result } of outcomes) {
|
|
1066
1180
|
if ('article' in result) {
|
|
1067
1181
|
fallbackArticles.push(result.article);
|
|
1182
|
+
inputIdByArticle.set(result.article, candidate.pmid);
|
|
1068
1183
|
if (result.truncation)
|
|
1069
1184
|
truncatedArticles.push(result.truncation);
|
|
1070
1185
|
recoveredIds.add(candidate.pmid);
|
|
@@ -1082,12 +1197,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1082
1197
|
}
|
|
1083
1198
|
if (doiCandidates.length > 0) {
|
|
1084
1199
|
if (!unpaywall) {
|
|
1200
|
+
// Every candidate on this branch is a DOI, so Unpaywall applies to all
|
|
1201
|
+
// of them.
|
|
1085
1202
|
for (const c of doiCandidates) {
|
|
1086
1203
|
chainByInput.get(c.doi)?.push({
|
|
1087
1204
|
tier: 'unpaywall',
|
|
1088
1205
|
outcome: 'not-attempted',
|
|
1089
1206
|
detail: 'UNPAYWALL_EMAIL is not set',
|
|
1090
1207
|
});
|
|
1208
|
+
markUnqueried(c.doi, 'unpaywall');
|
|
1091
1209
|
}
|
|
1092
1210
|
}
|
|
1093
1211
|
else {
|
|
@@ -1100,6 +1218,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1100
1218
|
for (const { doi, result } of outcomes) {
|
|
1101
1219
|
if ('article' in result) {
|
|
1102
1220
|
fallbackArticles.push(result.article);
|
|
1221
|
+
inputIdByArticle.set(result.article, doi);
|
|
1103
1222
|
if (result.truncation)
|
|
1104
1223
|
truncatedArticles.push(result.truncation);
|
|
1105
1224
|
recoveredIds.add(doi);
|
|
@@ -1120,14 +1239,50 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1120
1239
|
for (const [id, chain] of chainByInput) {
|
|
1121
1240
|
if (recoveredIds.has(id))
|
|
1122
1241
|
continue;
|
|
1242
|
+
const unqueried = unqueriedByInput.get(id);
|
|
1123
1243
|
unavailable.push({
|
|
1124
1244
|
id,
|
|
1125
1245
|
idType,
|
|
1126
1246
|
reason: reasonFromChain(chain),
|
|
1127
1247
|
triedTiers: chain,
|
|
1248
|
+
...(unqueried?.size && { unqueriedTiers: [...unqueried] }),
|
|
1128
1249
|
});
|
|
1129
1250
|
}
|
|
1130
|
-
|
|
1251
|
+
// Whole-response budget: fill with complete records in response order and
|
|
1252
|
+
// hand the rest back as identifiers the caller can re-submit. One ledger for
|
|
1253
|
+
// every tier — a PMC-served article and an Unpaywall-served one spend the
|
|
1254
|
+
// same characters. Without `maxResponseCharacters` nothing is measured and
|
|
1255
|
+
// the response is exactly what it was before the budget existed. (#100)
|
|
1256
|
+
const resolved = [...pmcArticles, ...fallbackArticles];
|
|
1257
|
+
const ceiling = input.maxResponseCharacters;
|
|
1258
|
+
const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
|
|
1259
|
+
const articles = fit?.kept ?? resolved;
|
|
1260
|
+
const nextDeferredCharacters = fit?.nextDeferredCharacters;
|
|
1261
|
+
const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
|
|
1262
|
+
? {
|
|
1263
|
+
maxResponseCharacters: ceiling,
|
|
1264
|
+
returnedCharacters: fit.keptCharacters,
|
|
1265
|
+
deferredCount: fit.deferred.length,
|
|
1266
|
+
idType,
|
|
1267
|
+
// Every recovery site records the input id; `articleDisplayId` is
|
|
1268
|
+
// the total-function fallback, not an expected path.
|
|
1269
|
+
ids: fit.deferred.map((a) => inputIdByArticle.get(a) ?? articleDisplayId(a)),
|
|
1270
|
+
nextDeferredCharacters,
|
|
1271
|
+
}
|
|
1272
|
+
: undefined;
|
|
1273
|
+
// A deferred article takes its body accounting out of the response with it —
|
|
1274
|
+
// those counts describe text the caller never received.
|
|
1275
|
+
if (fit) {
|
|
1276
|
+
for (const article of fit.deferred) {
|
|
1277
|
+
const id = articleDisplayId(article);
|
|
1278
|
+
const index = truncatedArticles.findIndex((t) => t.id === id);
|
|
1279
|
+
if (index === -1)
|
|
1280
|
+
continue;
|
|
1281
|
+
const [dropped] = truncatedArticles.splice(index, 1);
|
|
1282
|
+
if (dropped)
|
|
1283
|
+
omittedSections -= countOmittedSections(dropped, input.overflowMode);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1131
1286
|
ctx.log.info('pubmed_fetch_fulltext completed', {
|
|
1132
1287
|
requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
|
|
1133
1288
|
returned: articles.length,
|
|
@@ -1135,6 +1290,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1135
1290
|
epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
|
|
1136
1291
|
unpaywallHits: fallbackArticles.length,
|
|
1137
1292
|
unavailable: unavailable.length,
|
|
1293
|
+
...(deferred && { deferred: deferred.deferredCount }),
|
|
1138
1294
|
});
|
|
1139
1295
|
// Rolled up only when the budget actually removed characters, so an
|
|
1140
1296
|
// under-budget request returns exactly what it did before the budget
|
|
@@ -1165,6 +1321,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1165
1321
|
notices.push(buildTruncationNotice(truncation));
|
|
1166
1322
|
ctx.enrich({ truncated: true });
|
|
1167
1323
|
}
|
|
1324
|
+
if (deferred) {
|
|
1325
|
+
notices.push(buildDeferralNotice(deferred));
|
|
1326
|
+
ctx.enrich({ truncated: true });
|
|
1327
|
+
}
|
|
1168
1328
|
if (notices.length > 0)
|
|
1169
1329
|
ctx.enrich.notice(notices.join(' '));
|
|
1170
1330
|
return {
|
|
@@ -1172,14 +1332,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1172
1332
|
totalReturned: articles.length,
|
|
1173
1333
|
...(unavailable.length > 0 && { unavailable }),
|
|
1174
1334
|
...(truncation && { truncation }),
|
|
1335
|
+
...(deferred && { deferred }),
|
|
1175
1336
|
};
|
|
1176
1337
|
},
|
|
1177
1338
|
format: (result) => {
|
|
1178
1339
|
const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
|
|
1179
1340
|
if (result.unavailable?.length) {
|
|
1180
1341
|
lines.push(`\n**Unavailable (${result.unavailable.length}):**`);
|
|
1342
|
+
let anyUnqueried = false;
|
|
1181
1343
|
for (const u of result.unavailable) {
|
|
1182
1344
|
lines.push(`- [${u.idType}] ${u.id} — ${u.reason}`);
|
|
1345
|
+
if (u.unqueriedTiers?.length) {
|
|
1346
|
+
anyUnqueried = true;
|
|
1347
|
+
lines.push(` Not queried: ${formatUnqueriedTiers(u.unqueriedTiers, u.triedTiers)}`);
|
|
1348
|
+
}
|
|
1183
1349
|
const chain = u.triedTiers
|
|
1184
1350
|
.map((t) => {
|
|
1185
1351
|
const detail = t.detail ? sanitizeChainDetail(t.detail) : undefined;
|
|
@@ -1189,8 +1355,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1189
1355
|
if (chain)
|
|
1190
1356
|
lines.push(` chain: ${chain}`);
|
|
1191
1357
|
}
|
|
1358
|
+
// One explanation for the whole list — repeating it per entry buries the
|
|
1359
|
+
// ids it qualifies.
|
|
1360
|
+
if (anyUnqueried) {
|
|
1361
|
+
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.`);
|
|
1362
|
+
}
|
|
1192
1363
|
}
|
|
1193
|
-
if (result.
|
|
1364
|
+
if (result.deferred) {
|
|
1365
|
+
const d = result.deferred;
|
|
1366
|
+
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(', ')}`);
|
|
1367
|
+
}
|
|
1368
|
+
// An empty response under a budget is a deferral, not an absence — the
|
|
1369
|
+
// articles resolved and the ids above retrieve them.
|
|
1370
|
+
if (result.totalReturned === 0 && !result.deferred) {
|
|
1194
1371
|
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
1372
|
}
|
|
1196
1373
|
if (result.truncation)
|
|
@@ -1272,6 +1449,7 @@ async function runEpmcStage(epmc, args) {
|
|
|
1272
1449
|
Promise.all(args.doiCandidates.map(fetchForDoi)),
|
|
1273
1450
|
]);
|
|
1274
1451
|
const articles = [];
|
|
1452
|
+
const articleInputIds = new Map();
|
|
1275
1453
|
const remainingPmid = [];
|
|
1276
1454
|
const remainingPmcid = [];
|
|
1277
1455
|
const remainingDoi = [];
|
|
@@ -1281,8 +1459,9 @@ async function runEpmcStage(epmc, args) {
|
|
|
1281
1459
|
const sectionFilterMisses = [];
|
|
1282
1460
|
const truncatedArticles = [];
|
|
1283
1461
|
let omittedSections = 0;
|
|
1284
|
-
const collectHit = (run) => {
|
|
1462
|
+
const collectHit = (candidateId, run) => {
|
|
1285
1463
|
articles.push(run.article);
|
|
1464
|
+
articleInputIds.set(run.article, candidateId);
|
|
1286
1465
|
if (run.sectionFilterMiss)
|
|
1287
1466
|
sectionFilterMisses.push(articleDisplayId(run.article));
|
|
1288
1467
|
if (run.truncation)
|
|
@@ -1292,26 +1471,27 @@ async function runEpmcStage(epmc, args) {
|
|
|
1292
1471
|
for (const run of pmidResults) {
|
|
1293
1472
|
pmidOutcomes.set(run.c.pmid, run.outcome);
|
|
1294
1473
|
if (run.article)
|
|
1295
|
-
collectHit({ ...run, article: run.article });
|
|
1474
|
+
collectHit(run.c.pmid, { ...run, article: run.article });
|
|
1296
1475
|
else
|
|
1297
1476
|
remainingPmid.push(run.c);
|
|
1298
1477
|
}
|
|
1299
1478
|
for (const run of pmcidResults) {
|
|
1300
1479
|
pmcidOutcomes.set(run.c.normalized, run.outcome);
|
|
1301
1480
|
if (run.article)
|
|
1302
|
-
collectHit({ ...run, article: run.article });
|
|
1481
|
+
collectHit(run.c.normalized, { ...run, article: run.article });
|
|
1303
1482
|
else
|
|
1304
1483
|
remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
|
|
1305
1484
|
}
|
|
1306
1485
|
for (const run of doiResults) {
|
|
1307
1486
|
doiOutcomes.set(run.c.doi, run.outcome);
|
|
1308
1487
|
if (run.article)
|
|
1309
|
-
collectHit({ ...run, article: run.article });
|
|
1488
|
+
collectHit(run.c.doi, { ...run, article: run.article });
|
|
1310
1489
|
else
|
|
1311
1490
|
remainingDoi.push(run.c);
|
|
1312
1491
|
}
|
|
1313
1492
|
return {
|
|
1314
1493
|
articles,
|
|
1494
|
+
articleInputIds,
|
|
1315
1495
|
remainingPmid,
|
|
1316
1496
|
remainingPmcid,
|
|
1317
1497
|
remainingDoi,
|
|
@@ -1614,11 +1794,15 @@ function unpaywallReasonToTierOutcome(reason) {
|
|
|
1614
1794
|
}
|
|
1615
1795
|
}
|
|
1616
1796
|
/**
|
|
1617
|
-
* Derive the
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1797
|
+
* Derive the `reason` shown on the unavailable entry from its chain: the most
|
|
1798
|
+
* specific content signal the last tier that actually answered reported.
|
|
1799
|
+
*
|
|
1800
|
+
* Skipped tiers are deliberately not folded in here. A configuration note in
|
|
1801
|
+
* place of the content signal would erase the one specific thing the chain
|
|
1802
|
+
* learned; the incompleteness is reported alongside it, on `unqueriedTiers`,
|
|
1803
|
+
* where it adds to the answer instead of replacing it. A chain where no tier
|
|
1804
|
+
* was attempted at all has no signal to report and stays
|
|
1805
|
+
* `no-pmc-fallback-disabled`.
|
|
1622
1806
|
*/
|
|
1623
1807
|
function reasonFromChain(chain) {
|
|
1624
1808
|
let lastSignal;
|
|
@@ -1628,6 +1812,13 @@ function reasonFromChain(chain) {
|
|
|
1628
1812
|
}
|
|
1629
1813
|
if (!lastSignal)
|
|
1630
1814
|
return 'no-pmc-fallback-disabled';
|
|
1815
|
+
// Unpaywall answering `no-doi` for an id the content tiers never found adds
|
|
1816
|
+
// nothing: record absence is the specific signal, so it stays `not-found`.
|
|
1817
|
+
if (lastSignal.tier === 'unpaywall' && lastSignal.outcome === 'no-doi') {
|
|
1818
|
+
const lastContentSignal = chain.findLast((t) => t.tier !== 'unpaywall' && t.outcome !== 'not-attempted');
|
|
1819
|
+
if (lastContentSignal?.outcome === 'miss')
|
|
1820
|
+
return 'not-found';
|
|
1821
|
+
}
|
|
1631
1822
|
const key = `${lastSignal.tier}:${lastSignal.outcome}`;
|
|
1632
1823
|
switch (key) {
|
|
1633
1824
|
case 'pmc:miss':
|
|
@@ -1655,6 +1846,26 @@ function reasonFromChain(chain) {
|
|
|
1655
1846
|
}
|
|
1656
1847
|
}
|
|
1657
1848
|
// ─── format() helpers ────────────────────────────────────────────────────────
|
|
1849
|
+
/** Human-readable tier names for the unqueried-tier line. */
|
|
1850
|
+
const UNQUERIED_TIER_LABELS = {
|
|
1851
|
+
europepmc: 'Europe PMC',
|
|
1852
|
+
unpaywall: 'Unpaywall',
|
|
1853
|
+
};
|
|
1854
|
+
/**
|
|
1855
|
+
* Name each unqueried tier with the reason its chain entry gave for skipping it,
|
|
1856
|
+
* so a `content[]` reader learns which setting is missing without decoding the
|
|
1857
|
+
* chain line. The detail goes through the same sanitizer the chain does — it is
|
|
1858
|
+
* the same upstream string. (#110)
|
|
1859
|
+
*/
|
|
1860
|
+
function formatUnqueriedTiers(tiers, chain) {
|
|
1861
|
+
return tiers
|
|
1862
|
+
.map((tier) => {
|
|
1863
|
+
const detail = chain.find((t) => t.tier === tier && t.outcome === 'not-attempted')?.detail;
|
|
1864
|
+
const label = UNQUERIED_TIER_LABELS[tier];
|
|
1865
|
+
return detail ? `${label} (${sanitizeChainDetail(detail)})` : label;
|
|
1866
|
+
})
|
|
1867
|
+
.join(', ');
|
|
1868
|
+
}
|
|
1658
1869
|
/**
|
|
1659
1870
|
* Render the response-level character accounting. Every field is rendered
|
|
1660
1871
|
* unconditionally so `content[]` readers see the same budget detail
|