@cyanheads/pubmed-mcp-server 2.10.20 → 2.10.22
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 +3 -3
- package/CLAUDE.md +3 -3
- package/Dockerfile +3 -3
- package/README.md +14 -11
- package/changelog/2.10.x/2.10.21.md +42 -0
- package/changelog/2.10.x/2.10.22.md +44 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_budget.d.ts +35 -6
- package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_budget.js +26 -3
- package/dist/mcp-server/tools/definitions/_budget.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_notices.d.ts +33 -0
- package/dist/mcp-server/tools/definitions/_notices.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_notices.js +35 -0
- package/dist/mcp-server/tools/definitions/_notices.js.map +1 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts +14 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_schemas.js +9 -0
- package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +11 -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 +64 -19
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +22 -4
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +386 -167
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +41 -8
- 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 +8 -6
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +66 -76
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +6 -5
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +11 -18
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -4
- 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 +9 -8
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +4 -4
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +12 -8
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +1 -1
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +1 -1
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +4 -3
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +31 -11
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +122 -47
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +5 -1
- package/dist/services/europe-pmc/types.d.ts.map +1 -1
- package/dist/services/fetch-redaction.d.ts +23 -0
- package/dist/services/fetch-redaction.d.ts.map +1 -0
- package/dist/services/fetch-redaction.js +62 -0
- package/dist/services/fetch-redaction.js.map +1 -0
- package/dist/services/ncbi/api-client.d.ts +3 -0
- package/dist/services/ncbi/api-client.d.ts.map +1 -1
- package/dist/services/ncbi/api-client.js +21 -8
- package/dist/services/ncbi/api-client.js.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.d.ts +19 -3
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +410 -138
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +22 -0
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +115 -18
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.js +89 -36
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +13 -8
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +326 -72
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +42 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +275 -12
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.d.ts +10 -0
- package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.js +19 -0
- package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +0 -11
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +24 -33
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +72 -32
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +4 -3
- package/dist/services/openalex/api-client.js.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.js +18 -8
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
- package/package.json +7 -7
- package/server.json +3 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Full-text fetch tool. Resolves full-text articles through a
|
|
3
3
|
* three-stage chain: NCBI PMC EFetch → Europe PMC `fullTextXML` → Unpaywall.
|
|
4
|
-
* Accepts three input fields, alone or together, naming at most
|
|
5
|
-
* identifiers between them
|
|
4
|
+
* Accepts three input fields, alone or together, naming at most 50 distinct
|
|
5
|
+
* identifiers between them. The first 10 in routing order are fetched; the rest
|
|
6
|
+
* are returned unfetched in `overLimit`, before any upstream request:
|
|
6
7
|
*
|
|
7
8
|
* - `pmcids` — fetch directly by PMC ID, once per record however it is
|
|
8
9
|
* spelled (`PMC123`, `pmc123`, `123`, zero-padded `PMC0123`), and reported
|
|
@@ -17,12 +18,16 @@
|
|
|
17
18
|
* DOIs are case-insensitive: every casing of one DOI runs the chain once,
|
|
18
19
|
* and `unavailable[]` reports each casing as the caller wrote it.
|
|
19
20
|
*
|
|
21
|
+
* The singular `pmcid`, `pmcId`, `pmid`, and `doi` are unadvertised aliases of
|
|
22
|
+
* these fields, and a lone string under any of them reads as a one-element
|
|
23
|
+
* array. (#221)
|
|
24
|
+
*
|
|
20
25
|
* Routing runs `pmcids`, then `pmids`, then `dois`. Every tier keys its work on
|
|
21
26
|
* the record — PMC on the PMCID, Europe PMC on the search hit's `source` + `id`,
|
|
22
27
|
* Unpaywall on the lowercased DOI — and the first id to reach a record owns it:
|
|
23
28
|
* a later id naming the same article joins the owner's chain as another caller
|
|
24
|
-
* id, so the article is fetched and returned once. Each `unavailable[]` entry
|
|
25
|
-
* and
|
|
29
|
+
* id, so the article is fetched and returned once. Each `unavailable[]` entry,
|
|
30
|
+
* deferred id, and over-limit id carries the field its id was sent in.
|
|
26
31
|
*
|
|
27
32
|
* Output uses a discriminated union on `source` (`pmc` | `unpaywall`) with an
|
|
28
33
|
* extra `viaSource` discriminator that records which layer produced the
|
|
@@ -50,7 +55,7 @@ import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers
|
|
|
50
55
|
import { toDisplayText } from '../../../services/ncbi/parsing/text-helpers.js';
|
|
51
56
|
import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
|
|
52
57
|
import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
|
|
53
|
-
import { fitWholeItems } from './_budget.js';
|
|
58
|
+
import { buildDeferralNotice, fitWholeItems } from './_budget.js';
|
|
54
59
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
|
|
55
60
|
import { doiStringSchema, normalizePmid, pmcidStringSchema, pmidStringSchema } from './_schemas.js';
|
|
56
61
|
import { escapeMarkdownInline, escapeMarkdownTableCell, sliceAtWordBoundary } from './_text.js';
|
|
@@ -286,6 +291,28 @@ function applyAssetFilters(article, filters) {
|
|
|
286
291
|
const surviving = matchedSectionTitles(article.sections, filters.sections.map(lowerCase));
|
|
287
292
|
return withAssets(article, assets.filter((a) => a.sectionTitle !== undefined && surviving.has(a.sectionTitle)));
|
|
288
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The top-level sections the `sections` filter and the `maxSections` slice
|
|
296
|
+
* select, in order. Both act on each top-level section independently or cut a
|
|
297
|
+
* prefix, so applied to the body alone this returns exactly the body prefix of
|
|
298
|
+
* what it returns for body plus back matter — which is how {@link
|
|
299
|
+
* bodySectionCount} finds where the back matter starts.
|
|
300
|
+
*/
|
|
301
|
+
function selectSections(sections, filters) {
|
|
302
|
+
const pruned = filters.sections?.length
|
|
303
|
+
? pruneSections(sections, filters.sections.map(lowerCase))
|
|
304
|
+
: sections;
|
|
305
|
+
return filters.maxSections === undefined ? pruned : pruned.slice(0, filters.maxSections);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* How many leading entries of the filtered `sections` are body sections, the
|
|
309
|
+
* rest being back matter: the filter applied to the parsed body alone. The depth
|
|
310
|
+
* clamp that follows the filter maps sections one to one, so the count holds for
|
|
311
|
+
* the filtered article. (#206)
|
|
312
|
+
*/
|
|
313
|
+
function bodySectionCount(parsed, filters) {
|
|
314
|
+
return selectSections(parsed.sections, filters).length;
|
|
315
|
+
}
|
|
289
316
|
/**
|
|
290
317
|
* Apply the requested section/reference/table/asset filters, then clamp the
|
|
291
318
|
* section tree to the depth the output schema carries. All of it runs here so every path
|
|
@@ -303,13 +330,7 @@ function applyAssetFilters(article, filters) {
|
|
|
303
330
|
* folded-away section survives in the text it was folded into. (#111, #130)
|
|
304
331
|
*/
|
|
305
332
|
function applyPmcFilters(article, filters) {
|
|
306
|
-
let out = article;
|
|
307
|
-
if (filters.sections?.length) {
|
|
308
|
-
out = { ...out, sections: pruneSections(out.sections, filters.sections.map(lowerCase)) };
|
|
309
|
-
}
|
|
310
|
-
if (filters.maxSections !== undefined) {
|
|
311
|
-
out = { ...out, sections: out.sections.slice(0, filters.maxSections) };
|
|
312
|
-
}
|
|
333
|
+
let out = { ...article, sections: selectSections(article.sections, filters) };
|
|
313
334
|
if (!filters.includeReferences) {
|
|
314
335
|
const { references: _, ...rest } = out;
|
|
315
336
|
out = rest;
|
|
@@ -319,11 +340,12 @@ function applyPmcFilters(article, filters) {
|
|
|
319
340
|
return { ...out, sections: clampSectionDepth(out.sections) };
|
|
320
341
|
}
|
|
321
342
|
/**
|
|
322
|
-
* True when a `sections` filter removed every
|
|
323
|
-
* actually had sections upstream — the signal that the
|
|
324
|
-
* matched nothing, as opposed to the article genuinely
|
|
325
|
-
* the `sections` filter can zero a non-empty list:
|
|
326
|
-
* `.min(1)` floor, so it never reduces to zero. (#80
|
|
343
|
+
* True when a `sections` filter removed every section, back matter included,
|
|
344
|
+
* from an article that actually had sections upstream — the signal that the
|
|
345
|
+
* requested headings matched nothing, as opposed to the article genuinely
|
|
346
|
+
* shipping no body. Only the `sections` filter can zero a non-empty list:
|
|
347
|
+
* `maxSections` carries a `.min(1)` floor, so it never reduces to zero. (#80,
|
|
348
|
+
* #206)
|
|
327
349
|
*/
|
|
328
350
|
function isSectionFilterMiss(before, after, sectionFilter) {
|
|
329
351
|
return (Boolean(sectionFilter?.length) && before.sections.length > 0 && after.sections.length === 0);
|
|
@@ -335,10 +357,21 @@ function isSectionFilterMiss(before, after, sectionFilter) {
|
|
|
335
357
|
* while carrying nothing to read. Distinct from {@link isSectionFilterMiss},
|
|
336
358
|
* which needs a non-empty pre-filter body: the two never overlap. Evaluated
|
|
337
359
|
* against the *pre-filter* article so a `sections` filter can't be mistaken for
|
|
338
|
-
* an upstream absence
|
|
360
|
+
* an upstream absence, and before {@link withBackMatter}, so acknowledgments and
|
|
361
|
+
* a data-availability note alone are no body and the chain still moves on to the
|
|
362
|
+
* next tier. (#86, #206)
|
|
363
|
+
*/
|
|
364
|
+
function isBodylessArticle(parsed) {
|
|
365
|
+
return parsed.sections.length === 0;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The parsed article with its back-matter sections appended after the body
|
|
369
|
+
* ones, where every filter, slice, and budget downstream treats them as ordinary
|
|
370
|
+
* sections, and with no `backSections` field of its own left to reach the
|
|
371
|
+
* output. (#206)
|
|
339
372
|
*/
|
|
340
|
-
function
|
|
341
|
-
return
|
|
373
|
+
function withBackMatter({ backSections, ...article }) {
|
|
374
|
+
return backSections ? { ...article, sections: [...article.sections, ...backSections] } : article;
|
|
342
375
|
}
|
|
343
376
|
/** Pick the best human-readable identifier for an article, for recovery notices
|
|
344
377
|
* and character-budget accounting. Treats empty strings as absent — EPMC-only
|
|
@@ -359,7 +392,7 @@ function articleDisplayId(a) {
|
|
|
359
392
|
function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
|
|
360
393
|
const terms = sectionFilter.join(', ');
|
|
361
394
|
const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
|
|
362
|
-
return `No section or subsection title, at any nesting depth, matched the requested section filter (${terms}) for ${subject}. The full text was retrieved but every
|
|
395
|
+
return `No section or subsection title, at any nesting depth, matched the requested section filter (${terms}) for ${subject}. The full text was retrieved but every section, back matter included, was filtered out. Retry without \`sections\`, or filter on broader headings such as Introduction, Methods, Results, or Discussion.`;
|
|
363
396
|
}
|
|
364
397
|
/**
|
|
365
398
|
* Compose the recovery notice for identifiers whose only retrievable record was
|
|
@@ -410,12 +443,16 @@ const SectionSchema = z
|
|
|
410
443
|
text: z.string().describe('Section body text'),
|
|
411
444
|
subsections: z.array(SubsectionSchema).optional().describe('Nested subsections'),
|
|
412
445
|
})
|
|
413
|
-
.describe('Article body
|
|
446
|
+
.describe('Article section, from the body or from the back matter after it');
|
|
414
447
|
const AuthorSchema = z
|
|
415
448
|
.object({
|
|
416
449
|
collectiveName: z.string().optional().describe('Group name'),
|
|
417
450
|
givenNames: z.string().optional().describe('Given names'),
|
|
418
451
|
lastName: z.string().optional().describe('Last name'),
|
|
452
|
+
suffix: z
|
|
453
|
+
.string()
|
|
454
|
+
.optional()
|
|
455
|
+
.describe('Generational name suffix verbatim from the JATS `<suffix>` (e.g. "Jr", "IV"), never normalized. Absent when the deposit gives none.'),
|
|
419
456
|
})
|
|
420
457
|
.describe('Author entry');
|
|
421
458
|
const JournalSchema = z
|
|
@@ -457,7 +494,10 @@ const ReferenceSchema = z
|
|
|
457
494
|
*/
|
|
458
495
|
const TableSchema = z
|
|
459
496
|
.object({
|
|
460
|
-
label: z
|
|
497
|
+
label: z
|
|
498
|
+
.string()
|
|
499
|
+
.optional()
|
|
500
|
+
.describe('Table label as printed, e.g. `TABLE 1`, less one trailing ` |`, `.` or `:` (`Table 1.` reads `Table 1`)'),
|
|
461
501
|
caption: z.string().optional().describe('Caption text, with the label excluded'),
|
|
462
502
|
id: z
|
|
463
503
|
.string()
|
|
@@ -466,7 +506,7 @@ const TableSchema = z
|
|
|
466
506
|
sectionTitle: z
|
|
467
507
|
.string()
|
|
468
508
|
.optional()
|
|
469
|
-
.describe('Title of the innermost section enclosing the table
|
|
509
|
+
.describe('Title of the innermost titled section enclosing the table — a body section or a back-matter one such as an appendix — as that section is titled in `sections`, or, for a heading nested deeper than `sections` carries, as its heading line inside the text of the section that contains it. A `sections` filter keeps the table with that section. In back matter the section name is the only positional cue there is. Absent for a table inside no titled section, such as a `<floats-group>` deposit.'),
|
|
470
510
|
headerRowCount: z
|
|
471
511
|
.number()
|
|
472
512
|
.describe('How many leading `rows` entries are header rows — a `<thead>` block, or leading rows made entirely of `<th>`. 0 when the table declares none. Several header rows stack: read one column top to bottom for its full header path.'),
|
|
@@ -505,7 +545,10 @@ const AssetSchema = z
|
|
|
505
545
|
assetType: z
|
|
506
546
|
.enum(['figure', 'supplementary-material'])
|
|
507
547
|
.describe('Which captioned element this came from — `figure` for a `<fig>`, `supplementary-material` for a `<supplementary-material>` deposit'),
|
|
508
|
-
label: z
|
|
548
|
+
label: z
|
|
549
|
+
.string()
|
|
550
|
+
.optional()
|
|
551
|
+
.describe('Display label as printed, e.g. `Fig. 1`, less one trailing ` |`, `.` or `:` (`Figure 1.` reads `Figure 1`). Some supplements carry a bare number (`1`), kept as deposited here'),
|
|
509
552
|
caption: z.string().optional().describe('Caption text, with the label excluded'),
|
|
510
553
|
id: z
|
|
511
554
|
.string()
|
|
@@ -514,7 +557,7 @@ const AssetSchema = z
|
|
|
514
557
|
sectionTitle: z
|
|
515
558
|
.string()
|
|
516
559
|
.optional()
|
|
517
|
-
.describe('Title of the innermost section enclosing the asset
|
|
560
|
+
.describe('Title of the innermost titled section enclosing the asset — a body section or a back-matter one such as an appendix — as that section is titled in `sections`, or, for a heading nested deeper than `sections` carries, as its heading line inside the text of the section that contains it. Absent for an asset inside no titled section, such as a `<floats-group>` deposit.'),
|
|
518
561
|
href: z
|
|
519
562
|
.string()
|
|
520
563
|
.optional()
|
|
@@ -555,7 +598,9 @@ const PmcArticleSchema = z
|
|
|
555
598
|
keywords: z.array(z.string()).optional().describe('Keywords'),
|
|
556
599
|
articleType: z.string().optional().describe('Article type'),
|
|
557
600
|
publicationDate: PublicationDateSchema.optional(),
|
|
558
|
-
sections: z
|
|
601
|
+
sections: z
|
|
602
|
+
.array(SectionSchema)
|
|
603
|
+
.describe('Article sections in source order: the body, then the back matter — acknowledgments, notes and declarations, footnotes, glossary, appendices. A back-matter element with no heading of its own is titled by its kind, such as `Footnotes` or `Appendix`.'),
|
|
559
604
|
tables: z
|
|
560
605
|
.array(TableSchema)
|
|
561
606
|
.optional()
|
|
@@ -563,7 +608,7 @@ const PmcArticleSchema = z
|
|
|
563
608
|
assets: z
|
|
564
609
|
.array(AssetSchema)
|
|
565
610
|
.optional()
|
|
566
|
-
.describe('Every `<fig>` and `<supplementary-material>` the article carries, in document order — from the body and from `<floats-group>`, `<back>` and appendices alike. Each one lifted from
|
|
611
|
+
.describe('Every `<fig>` and `<supplementary-material>` the article carries, in document order — from the body and from `<floats-group>`, `<back>` and appendices alike. Each one lifted from a section, body or back matter, leaves a `[Figure: <label>]` or `[Supplementary: <label>]` marker at its position in the section text, so reading order survives the lift. Absent when the article deposits none, when `includeAssets` is false, or when a `sections` filter left none standing.'),
|
|
567
612
|
references: z.array(ReferenceSchema).optional().describe('Reference list'),
|
|
568
613
|
epmcId: z
|
|
569
614
|
.string()
|
|
@@ -738,7 +783,7 @@ const TruncatedSectionSchema = z
|
|
|
738
783
|
.optional()
|
|
739
784
|
.describe('Per-subsection accounting for a shortened section, in document order, including subsections dropped for budget — where inside the section the cut landed. Absent when the section was returned whole or carries no subsections.'),
|
|
740
785
|
})
|
|
741
|
-
.describe('Character accounting for one
|
|
786
|
+
.describe('Character accounting for one section of a budgeted article');
|
|
742
787
|
const TruncatedArticleSchema = z
|
|
743
788
|
.object({
|
|
744
789
|
id: z
|
|
@@ -746,7 +791,7 @@ const TruncatedArticleSchema = z
|
|
|
746
791
|
.describe('Identifier for the article — PMCID, PMID, DOI, or Europe PMC id, whichever the article carries first'),
|
|
747
792
|
source: z
|
|
748
793
|
.enum(['pmc', 'unpaywall'])
|
|
749
|
-
.describe('Which output shape was budgeted: `pmc` budgets
|
|
794
|
+
.describe('Which output shape was budgeted: `pmc` budgets sections and subsections, `unpaywall` budgets the single `content` body'),
|
|
750
795
|
originalCharacters: z
|
|
751
796
|
.number()
|
|
752
797
|
.describe('Body characters this article carried before the budget pass'),
|
|
@@ -766,11 +811,11 @@ const TruncatedArticleSchema = z
|
|
|
766
811
|
omittedAssets: z
|
|
767
812
|
.number()
|
|
768
813
|
.optional()
|
|
769
|
-
.describe('Figures and supplementary items this article dropped whole because the budget left no room once sections and tables were served. An asset is never returned with a truncated caption, so it is either returned complete or counted here. Absent when none were dropped.'),
|
|
814
|
+
.describe('Figures and supplementary items this article dropped whole because the budget left no room once body sections and tables were served. An asset is never returned with a truncated caption, so it is either returned complete or counted here. Absent when none were dropped.'),
|
|
770
815
|
omittedAssetNames: z
|
|
771
816
|
.array(z.string())
|
|
772
817
|
.optional()
|
|
773
|
-
.describe("The dropped assets by name, in document order — each asset's label, else its `id`, else `asset <n>` for its position in the article. Contiguous for the same reason `omittedTableNames` is: admission stops at the first asset that did not fit rather than skipping ahead to a smaller one. Absent when none were dropped."),
|
|
818
|
+
.describe("The dropped assets by name, in document order — each asset's label (a bare-number label led by its type, `Supplementary material 1`), else its `id`, else `asset <n>` for its position in the article. Contiguous for the same reason `omittedTableNames` is: admission stops at the first asset that did not fit rather than skipping ahead to a smaller one. Absent when none were dropped."),
|
|
774
819
|
})
|
|
775
820
|
.describe('Character accounting for one article the budget shortened');
|
|
776
821
|
const TruncationSchema = z
|
|
@@ -791,7 +836,7 @@ const TruncationSchema = z
|
|
|
791
836
|
.describe('Body characters the shortened articles carry in this response'),
|
|
792
837
|
omittedSections: z
|
|
793
838
|
.number()
|
|
794
|
-
.describe('
|
|
839
|
+
.describe('Sections and subsections, back matter included, dropped entirely because an article budget was exhausted before reaching them. Back matter is reached last, after tables and assets. A dropped section counts once, together with its subsections. Always 0 in `outline` mode, which keeps every heading.'),
|
|
795
840
|
omittedTables: z
|
|
796
841
|
.number()
|
|
797
842
|
.optional()
|
|
@@ -822,24 +867,50 @@ const DeferredSchema = z
|
|
|
822
867
|
.describe('The input field every deferred id was sent in — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries. Absent when the deferred ids came from more than one field; `pmcids` / `pmids` / `dois` then group them.'),
|
|
823
868
|
ids: z
|
|
824
869
|
.array(z.string())
|
|
825
|
-
.describe('Identifiers of the deferred articles, in
|
|
870
|
+
.describe('Identifiers of the deferred articles, in routing order — `pmcids`, then `pmids`, then `dois`, request order within each — keyed as they were requested (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` field — or, when `idType` is absent, with the `pmcids` / `pmids` / `dois` lists below — and the same other inputs. Never contains an id from `unavailable`.'),
|
|
826
871
|
pmcids: z
|
|
827
872
|
.array(z.string())
|
|
828
873
|
.optional()
|
|
829
|
-
.describe('The deferred ids sent as PMC IDs, in
|
|
874
|
+
.describe('The deferred ids sent as PMC IDs, in routing order — re-submit them as `pmcids`. Present only when the deferred ids came from more than one field (so `idType` is absent) and one of them was a PMC ID.'),
|
|
830
875
|
pmids: z
|
|
831
876
|
.array(z.string())
|
|
832
877
|
.optional()
|
|
833
|
-
.describe('The deferred ids sent as PMIDs, in
|
|
878
|
+
.describe('The deferred ids sent as PMIDs, in routing order — re-submit them as `pmids`. Present only when the deferred ids came from more than one field (so `idType` is absent) and one of them was a PMID.'),
|
|
834
879
|
dois: z
|
|
835
880
|
.array(z.string())
|
|
836
881
|
.optional()
|
|
837
|
-
.describe('The deferred ids sent as DOIs, in
|
|
882
|
+
.describe('The deferred ids sent as DOIs, in routing order — re-submit them as `dois`. Present only when the deferred ids came from more than one field (so `idType` is absent) and one of them was a DOI.'),
|
|
838
883
|
nextDeferredCharacters: z
|
|
839
884
|
.number()
|
|
840
|
-
.describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped
|
|
885
|
+
.describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped — and the continuation ceiling: re-calling with the deferred ids needs `maxResponseCharacters` ≥ this to return anything, and a smaller article further down `ids` cannot be reached until this one fits. Repeating this same call needs `maxResponseCharacters` ≥ `returnedCharacters` + this to return this article as well.'),
|
|
841
886
|
})
|
|
842
887
|
.describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
|
|
888
|
+
const OverLimitSchema = z
|
|
889
|
+
.object({
|
|
890
|
+
limit: z
|
|
891
|
+
.number()
|
|
892
|
+
.describe('Distinct identifiers one call fetches. The first this many, in routing order — `pmcids`, then `pmids`, then `dois`, request order within each — were fetched; the ids below were not.'),
|
|
893
|
+
idType: z
|
|
894
|
+
.enum(['pmid', 'pmcid', 'doi'])
|
|
895
|
+
.optional()
|
|
896
|
+
.describe('The input field every over-limit id was sent in — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Absent when the over-limit ids came from more than one field; `pmcids` / `pmids` / `dois` then group them.'),
|
|
897
|
+
ids: z
|
|
898
|
+
.array(z.string())
|
|
899
|
+
.describe('Identifiers past the per-call limit, none of them fetched, in routing order — each listed once, under the first spelling sent (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` field — or, when `idType` is absent, with the `pmcids` / `pmids` / `dois` lists below — and the same other inputs, at most `limit` per call. Never contains an id from `unavailable` or `deferred`.'),
|
|
900
|
+
pmcids: z
|
|
901
|
+
.array(z.string())
|
|
902
|
+
.optional()
|
|
903
|
+
.describe('The over-limit ids sent as PMC IDs, in routing order — re-submit them as `pmcids`. Present only when the over-limit ids came from more than one field (so `idType` is absent) and one of them was a PMC ID.'),
|
|
904
|
+
pmids: z
|
|
905
|
+
.array(z.string())
|
|
906
|
+
.optional()
|
|
907
|
+
.describe('The over-limit ids sent as PMIDs, in routing order — re-submit them as `pmids`. Present only when the over-limit ids came from more than one field (so `idType` is absent) and one of them was a PMID.'),
|
|
908
|
+
dois: z
|
|
909
|
+
.array(z.string())
|
|
910
|
+
.optional()
|
|
911
|
+
.describe('The over-limit ids sent as DOIs, in routing order — re-submit them as `dois`. Present only when the over-limit ids came from more than one field (so `idType` is absent) and one of them was a DOI.'),
|
|
912
|
+
})
|
|
913
|
+
.describe('Identifiers the call named past the per-call fetch limit, returned unfetched for a follow-up call. Present only when the call named more than `limit` distinct identifiers. Separate from `deferred`, which lists articles that were fetched but withheld by `maxResponseCharacters`.');
|
|
843
914
|
/** True when the request asked for any budget at all. Without one, every budget
|
|
844
915
|
* helper returns its input untouched so the response is byte-identical. */
|
|
845
916
|
function budgetRequested(budget) {
|
|
@@ -1045,11 +1116,16 @@ function allotSectionBudgets(sizes, budget) {
|
|
|
1045
1116
|
* or cut — the budget spends on body text and table content, keeping every
|
|
1046
1117
|
* article citable.
|
|
1047
1118
|
*
|
|
1048
|
-
* Body sections are served first, then tables, then assets,
|
|
1049
|
-
* `maxCharacters` has left, in document order
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1119
|
+
* Body sections are served first, then tables, then assets, then back-matter
|
|
1120
|
+
* sections, each spending what `maxCharacters` has left, in document order. A
|
|
1121
|
+
* table or asset that does not fit is dropped whole and counted, never
|
|
1122
|
+
* truncated into a partial grid or a caption cut short. Back matter comes last
|
|
1123
|
+
* so it never displaces a table or figure: it is shortened or dropped the way
|
|
1124
|
+
* body sections are, from whatever the rest left. The first `bodySections`
|
|
1125
|
+
* entries of `article.sections` are the body; spend order aside, `sections` and
|
|
1126
|
+
* the ledger stay in document order. With no `maxCharacters` — a bare
|
|
1127
|
+
* `maxCharactersPerSection` request — nothing bounds the tables or assets and
|
|
1128
|
+
* every entry is kept. (#111, #130, #206)
|
|
1053
1129
|
*
|
|
1054
1130
|
* Returns the article untouched (same object identity) when no budget was
|
|
1055
1131
|
* requested or nothing exceeded it. A section or subsection left with zero
|
|
@@ -1057,7 +1133,7 @@ function allotSectionBudgets(sizes, budget) {
|
|
|
1057
1133
|
* keeps it as a heading-only entry. Dropped nodes still appear in the accounting
|
|
1058
1134
|
* so the caller can see which headings exist. (#81, #143)
|
|
1059
1135
|
*/
|
|
1060
|
-
function applyPmcBudget(article, budget) {
|
|
1136
|
+
function applyPmcBudget(article, budget, bodySections) {
|
|
1061
1137
|
const tables = article.tables ?? [];
|
|
1062
1138
|
const assets = article.assets ?? [];
|
|
1063
1139
|
if (!budgetRequested(budget) ||
|
|
@@ -1068,31 +1144,37 @@ function applyPmcBudget(article, budget) {
|
|
|
1068
1144
|
const tablesOriginal = tables.reduce((sum, table) => sum + tableCharacters(table), 0);
|
|
1069
1145
|
const assetsOriginal = assets.reduce((sum, asset) => sum + assetCharacters(asset), 0);
|
|
1070
1146
|
const originalCharacters = sizes.reduce((sum, size) => sum + size, 0) + tablesOriginal + assetsOriginal;
|
|
1071
|
-
const allowances = allotSectionBudgets(sizes, budget);
|
|
1072
1147
|
const kept = [];
|
|
1073
1148
|
const sectionReports = [];
|
|
1074
1149
|
let returnedCharacters = 0;
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const fit = fitSectionTree(section, fitted, { i: 0 }, budget.overflowMode);
|
|
1078
|
-
returnedCharacters += fit.entry.returnedCharacters;
|
|
1079
|
-
sectionReports.push(fit.entry);
|
|
1080
|
-
if (fit.kept)
|
|
1081
|
-
kept.push(fit.kept);
|
|
1082
|
-
});
|
|
1083
|
-
const omittedSections = countDroppedSections(sectionReports, budget.overflowMode);
|
|
1084
|
-
// Sections are served first, then tables, then assets — each spending whatever
|
|
1085
|
-
// `maxCharacters` has left. A bare per-section budget sets no total, so nothing
|
|
1086
|
-
// bounds either list.
|
|
1150
|
+
// Each stage spends whatever `maxCharacters` has left. A bare per-section
|
|
1151
|
+
// budget sets no total, so nothing bounds the tables or assets.
|
|
1087
1152
|
const remainingAllowance = () => budget.maxCharacters === undefined
|
|
1088
1153
|
? Number.POSITIVE_INFINITY
|
|
1089
1154
|
: Math.max(budget.maxCharacters - returnedCharacters, 0);
|
|
1155
|
+
const fitSections = (sections, total) => {
|
|
1156
|
+
const allowances = allotSectionBudgets(sections.map(sectionCharacters), {
|
|
1157
|
+
...budget,
|
|
1158
|
+
maxCharacters: total,
|
|
1159
|
+
});
|
|
1160
|
+
sections.forEach((section, i) => {
|
|
1161
|
+
const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
|
|
1162
|
+
const fit = fitSectionTree(section, fitted, { i: 0 }, budget.overflowMode);
|
|
1163
|
+
returnedCharacters += fit.entry.returnedCharacters;
|
|
1164
|
+
sectionReports.push(fit.entry);
|
|
1165
|
+
if (fit.kept)
|
|
1166
|
+
kept.push(fit.kept);
|
|
1167
|
+
});
|
|
1168
|
+
};
|
|
1169
|
+
fitSections(article.sections.slice(0, bodySections), budget.maxCharacters);
|
|
1090
1170
|
const fittedTables = fitWholeNamed(tables, remainingAllowance(), tableCharacters, tableDisplayName);
|
|
1091
1171
|
returnedCharacters += fittedTables.spent;
|
|
1092
1172
|
const omittedTables = fittedTables.omittedNames.length;
|
|
1093
1173
|
const fittedAssets = fitWholeNamed(assets, remainingAllowance(), assetCharacters, assetDisplayName);
|
|
1094
1174
|
returnedCharacters += fittedAssets.spent;
|
|
1095
1175
|
const omittedAssets = fittedAssets.omittedNames.length;
|
|
1176
|
+
fitSections(article.sections.slice(bodySections), budget.maxCharacters === undefined ? undefined : remainingAllowance());
|
|
1177
|
+
const omittedSections = countDroppedSections(sectionReports, budget.overflowMode);
|
|
1096
1178
|
if (returnedCharacters === originalCharacters &&
|
|
1097
1179
|
omittedSections === 0 &&
|
|
1098
1180
|
omittedTables === 0 &&
|
|
@@ -1145,7 +1227,23 @@ function tableDisplayName(table, index) {
|
|
|
1145
1227
|
}
|
|
1146
1228
|
/** How an asset is named in a notice: its label, else its id, else its position. */
|
|
1147
1229
|
function assetDisplayName(asset, index) {
|
|
1148
|
-
return asset
|
|
1230
|
+
return assetLabelName(asset) ?? asset.id ?? `asset ${index + 1}`;
|
|
1231
|
+
}
|
|
1232
|
+
/** The word a bare-number asset label is named with, by asset type. */
|
|
1233
|
+
const ASSET_TYPE_NAMES = {
|
|
1234
|
+
figure: 'Figure',
|
|
1235
|
+
'supplementary-material': 'Supplementary material',
|
|
1236
|
+
};
|
|
1237
|
+
/**
|
|
1238
|
+
* An asset's label as a name in a list or heading, its type leading a label that
|
|
1239
|
+
* is a bare number: NIH-manuscript supplements are labelled `1`, which reads as a
|
|
1240
|
+
* count among names (`7 asset(s) dropped whole: 1, Figure 1`). `label` and the
|
|
1241
|
+
* prose marker keep the number as deposited — the marker (`[Supplementary: 1]`)
|
|
1242
|
+
* names the kind already. (#240)
|
|
1243
|
+
*/
|
|
1244
|
+
function assetLabelName(asset) {
|
|
1245
|
+
const { label } = asset;
|
|
1246
|
+
return label && /^\d+$/.test(label) ? `${ASSET_TYPE_NAMES[asset.assetType]} ${label}` : label;
|
|
1149
1247
|
}
|
|
1150
1248
|
/**
|
|
1151
1249
|
* Collect the returned tables that carry no cell values. Read off the articles
|
|
@@ -1225,33 +1323,38 @@ function buildTruncationNotice(truncation) {
|
|
|
1225
1323
|
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}${omittedTables}${omittedAssets} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
|
|
1226
1324
|
}
|
|
1227
1325
|
/**
|
|
1228
|
-
* Compose the recovery notice for
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1326
|
+
* Compose the recovery notice for identifiers past the per-call fetch limit:
|
|
1327
|
+
* how many were left unfetched, which ones, and where to resend them — so a
|
|
1328
|
+
* caller reading only `content[]` can retrieve the rest without inspecting
|
|
1329
|
+
* `overLimit`. (#222)
|
|
1232
1330
|
*/
|
|
1233
|
-
function
|
|
1234
|
-
const
|
|
1235
|
-
? `
|
|
1236
|
-
: `
|
|
1237
|
-
|
|
1238
|
-
? `Re-call pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\``
|
|
1239
|
-
: `Re-call pubmed_fetch_fulltext with each id under its own field (${deferredFieldLists(deferred)})`;
|
|
1240
|
-
return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. ${resend} to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
|
|
1331
|
+
function buildOverLimitNotice(overLimit) {
|
|
1332
|
+
const resend = overLimit.idType
|
|
1333
|
+
? `Re-call pubmed_fetch_fulltext with those ids under \`${overLimit.idType}s\``
|
|
1334
|
+
: `Re-call pubmed_fetch_fulltext with each id under its own field (${idFieldLists(overLimit)})`;
|
|
1335
|
+
return `This call fetched the first ${overLimit.limit} distinct identifiers; ${overLimit.ids.length} more were not fetched: ${overLimit.ids.join(', ')}. ${resend}, at most ${overLimit.limit} per call, to retrieve them.`;
|
|
1241
1336
|
}
|
|
1242
1337
|
/**
|
|
1243
|
-
* The per-field lists of
|
|
1338
|
+
* The per-field lists of ids handed back across fields, in field order —
|
|
1244
1339
|
* `` `pmcids`: PMC1; `dois`: 10.1/x `` — or `''` when `idType` covers them all.
|
|
1245
1340
|
*/
|
|
1246
|
-
function
|
|
1341
|
+
function idFieldLists(group) {
|
|
1247
1342
|
return ['pmcids', 'pmids', 'dois']
|
|
1248
1343
|
.flatMap((field) => {
|
|
1249
|
-
const ids =
|
|
1344
|
+
const ids = group[field];
|
|
1250
1345
|
return ids?.length ? [`\`${field}\`: ${ids.join(', ')}`] : [];
|
|
1251
1346
|
})
|
|
1252
1347
|
.join('; ');
|
|
1253
1348
|
}
|
|
1254
1349
|
// ─── Tool Definition ─────────────────────────────────────────────────────────
|
|
1350
|
+
/**
|
|
1351
|
+
* Distinct identifiers one call fetches across `pmcids`, `pmids`, and `dois`,
|
|
1352
|
+
* in routing order; the rest are returned unfetched in `overLimit`. Bounds the
|
|
1353
|
+
* candidates every tier works per call. (#192, #222)
|
|
1354
|
+
*/
|
|
1355
|
+
const FETCH_LIMIT = 10;
|
|
1356
|
+
/** Distinct identifiers one call may name across the three fields. (#222) */
|
|
1357
|
+
const MAX_IDENTIFIERS = 50;
|
|
1255
1358
|
/**
|
|
1256
1359
|
* Compose the tool description for the fallback tiers enabled in this
|
|
1257
1360
|
* deployment. PMC EFetch is always present; Europe PMC (`EUROPEPMC_ENABLED`)
|
|
@@ -1260,7 +1363,7 @@ function deferredFieldLists(deferred) {
|
|
|
1260
1363
|
* recoveries that silently can't happen.
|
|
1261
1364
|
*/
|
|
1262
1365
|
export function buildFulltextDescription(tiers) {
|
|
1263
|
-
const base = 'Fetch full-text articles from PubMed Central
|
|
1366
|
+
const base = 'Fetch full-text articles from PubMed Central as structured sections — the body, then back matter such as acknowledgments, declarations, and appendices — with tables and references.';
|
|
1264
1367
|
const epmcClause = 'Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart)';
|
|
1265
1368
|
const unpaywallClause = 'Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text';
|
|
1266
1369
|
let fallback;
|
|
@@ -1284,13 +1387,11 @@ export function buildFulltextDescription(tiers) {
|
|
|
1284
1387
|
: tiers.unpaywall
|
|
1285
1388
|
? '; DOIs with no PMC copy recover via Unpaywall open access'
|
|
1286
1389
|
: '';
|
|
1287
|
-
const input = `Name articles by \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}), or any mix of the three — up to
|
|
1390
|
+
const input = `Name articles by \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}), or any mix of the three — up to ${MAX_IDENTIFIERS} distinct identifiers; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`; an article several of them name is fetched and returned once.`;
|
|
1288
1391
|
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.';
|
|
1289
1392
|
return `${base} ${fallback} ${input} ${budget}`;
|
|
1290
1393
|
}
|
|
1291
1394
|
const serverConfig = getServerConfig();
|
|
1292
|
-
/** Distinct identifiers one call may name across `pmcids`, `pmids`, and `dois`. (#192) */
|
|
1293
|
-
const MAX_IDENTIFIERS = 10;
|
|
1294
1395
|
export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
1295
1396
|
description: buildFulltextDescription({
|
|
1296
1397
|
europePmc: serverConfig.europepmcEnabled,
|
|
@@ -1314,26 +1415,30 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1314
1415
|
retryable: false,
|
|
1315
1416
|
},
|
|
1316
1417
|
],
|
|
1418
|
+
// Never advertised; rewritten to the canonical key before the schema parses.
|
|
1419
|
+
// Singular spellings sibling tools and this tool's own output use; a lone
|
|
1420
|
+
// string under one becomes a one-element array. (#221)
|
|
1421
|
+
inputAliases: { pmcid: 'pmcids', pmcId: 'pmcids', pmid: 'pmids', doi: 'dois' },
|
|
1317
1422
|
input: z
|
|
1318
1423
|
.object({
|
|
1319
1424
|
pmcids: z
|
|
1320
1425
|
.array(pmcidStringSchema)
|
|
1321
1426
|
.min(1)
|
|
1322
|
-
.max(
|
|
1427
|
+
.max(MAX_IDENTIFIERS)
|
|
1323
1428
|
.optional()
|
|
1324
|
-
.describe(
|
|
1429
|
+
.describe(`PMC IDs to fetch (e.g. ["PMC9575052"]). Combinable with \`pmids\` and \`dois\` — up to ${MAX_IDENTIFIERS} distinct identifiers across the three; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`. PMC IDs with no retrievable full text fall through to Europe PMC, then to Unpaywall on the DOI the chain resolves for them.`),
|
|
1325
1430
|
pmids: z
|
|
1326
1431
|
.array(pmidStringSchema)
|
|
1327
1432
|
.min(1)
|
|
1328
|
-
.max(
|
|
1433
|
+
.max(MAX_IDENTIFIERS)
|
|
1329
1434
|
.optional()
|
|
1330
|
-
.describe(
|
|
1435
|
+
.describe(`PubMed IDs. Combinable with \`pmcids\` and \`dois\` — up to ${MAX_IDENTIFIERS} distinct identifiers across the three; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`. Articles in PMC are returned as structured JATS; articles not in PMC fall through to Europe PMC (when EPMC has a \`fullTextXML\`), then to Unpaywall when \`UNPAYWALL_EMAIL\` is set and a DOI is available.`),
|
|
1331
1436
|
dois: z
|
|
1332
1437
|
.array(doiStringSchema)
|
|
1333
1438
|
.min(1)
|
|
1334
|
-
.max(
|
|
1439
|
+
.max(MAX_IDENTIFIERS)
|
|
1335
1440
|
.optional()
|
|
1336
|
-
.describe(
|
|
1441
|
+
.describe(`DOIs to resolve (e.g. ["10.21203/rs.3.rs-9010375/v1"]), one per element. Combinable with \`pmcids\` and \`pmids\` — up to ${MAX_IDENTIFIERS} distinct identifiers across the three; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`. Resolved to a PMCID via the PMC ID Converter and returned as structured JATS when the article is in PMC; DOIs with no PMC counterpart (preprints, EPMC-only OA) fall through to Europe PMC, then Unpaywall, when those layers are enabled.`),
|
|
1337
1442
|
includeReferences: z
|
|
1338
1443
|
.boolean()
|
|
1339
1444
|
.default(false)
|
|
@@ -1350,48 +1455,40 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1350
1455
|
.number()
|
|
1351
1456
|
.int()
|
|
1352
1457
|
.min(1)
|
|
1353
|
-
.max(50)
|
|
1354
1458
|
.optional()
|
|
1355
|
-
.describe('Maximum top-level body
|
|
1459
|
+
.describe('Maximum top-level sections. Back-matter sections count and follow the body, so a cap drops them first. Applies to `source=pmc` results only.'),
|
|
1356
1460
|
sections: z
|
|
1357
1461
|
.array(z.string())
|
|
1358
1462
|
.optional()
|
|
1359
|
-
.describe('Filter to specific sections by title (e.g. ["Introduction", "Methods", "Results", "Discussion"]). A term matches a section or subsection title at any nesting depth, case-insensitively, as a substring — "resul" matches "Results". A section whose own title matches is returned whole; one kept only because a nested subsection matched keeps its heading as a breadcrumb, with its own text cleared and only the matching branch beneath it. Tables and assets narrow with the filter: one whose section did not survive, or that names no section, is dropped. Empty strings are skipped, so `[""]` applies no filter; an element of only whitespace or invisible characters, such as a zero-width space, is rejected. Applies to `source=pmc` results only.'),
|
|
1463
|
+
.describe('Filter to specific sections by title (e.g. ["Introduction", "Methods", "Results", "Discussion"]). A term matches a section or subsection title at any nesting depth, case-insensitively, as a substring — "resul" matches "Results". Back-matter sections match the same way: by their own titles, or by their kind when they carry neither a title nor a label — "appendix" matches a section titled "Appendix A", "footnotes" an untitled footnote group. A label is never matched, so an appendix labelled "Appendix A" under a title of its own matches only on that title. A section whose own title matches is returned whole; one kept only because a nested subsection matched keeps its heading as a breadcrumb, with its own text cleared and only the matching branch beneath it. Tables and assets narrow with the filter: one whose section did not survive, or that names no section, is dropped. Empty strings are skipped, so `[""]` applies no filter; an element of only whitespace or invisible characters, such as a zero-width space, is rejected. Applies to `source=pmc` results only.'),
|
|
1360
1464
|
maxCharacters: z
|
|
1361
1465
|
.number()
|
|
1362
1466
|
.int()
|
|
1363
1467
|
.min(1)
|
|
1364
|
-
.max(1_000_000)
|
|
1365
1468
|
.optional()
|
|
1366
|
-
.describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text — which carries the inline blocks the parser renders in place, such as lists, definition lists, block quotes, boxed text, preformatted blocks and displayed formulae — plus table label, caption, cell and footnote text and asset label, caption and `href` text; or the `source=unpaywall` `content` body. Titles, abstracts, identifiers, and references are never counted or shortened. Shortened text ends at the last word boundary inside its allowance, so it can come back a few characters under it. The counted unit is that text alone — the Markdown grid `content[]` renders around the cells (pipes, padding, the divider row, headings) is scaffolding this budget does not measure, so a table renders longer than it costs here.
|
|
1469
|
+
.describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text — which carries the inline blocks the parser renders in place, such as lists, definition lists, block quotes, boxed text, preformatted blocks and displayed formulae — plus table label, caption, cell and footnote text and asset label, caption and `href` text; or the `source=unpaywall` `content` body. Titles, abstracts, identifiers, and references are never counted or shortened. Shortened text ends at the last word boundary inside its allowance, so it can come back a few characters under it. The counted unit is that text alone — the Markdown grid `content[]` renders around the cells (pipes, padding, the divider row, headings) is scaffolding this budget does not measure, so a table renders longer than it costs here. Body sections are served first, then tables, then assets, then back-matter sections, each spending what is left, in document order. For tables and assets, admission stops at the first entry that does not fit, and every entry from there on is dropped whole rather than cut mid-row or returned with a shortened caption, counted in `truncation.omittedTables` / `truncation.omittedAssets` and named in `truncation.articles[].omittedTableNames` / `omittedAssetNames`. Back matter takes only what remains and is shortened or dropped as body sections are; `sections` still returns it after the body. Applied after `sections`, `maxSections`, `includeReferences`, `includeTables`, and `includeAssets`, 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.'),
|
|
1367
1470
|
maxCharactersPerSection: z
|
|
1368
1471
|
.number()
|
|
1369
1472
|
.int()
|
|
1370
1473
|
.min(1)
|
|
1371
|
-
.max(1_000_000)
|
|
1372
1474
|
.optional()
|
|
1373
|
-
.describe('Budget for a single top-level
|
|
1475
|
+
.describe('Budget for a single top-level 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.'),
|
|
1374
1476
|
maxResponseCharacters: z
|
|
1375
1477
|
.number()
|
|
1376
1478
|
.int()
|
|
1377
1479
|
.min(1)
|
|
1378
|
-
.max(1_000_000)
|
|
1379
1480
|
.optional()
|
|
1380
|
-
.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,
|
|
1481
|
+
.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, 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 the order `articles` lists them 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.'),
|
|
1381
1482
|
overflowMode: z
|
|
1382
1483
|
.enum(['truncate', 'outline'])
|
|
1383
1484
|
.default('truncate')
|
|
1384
|
-
.describe('How to spend `maxCharacters` across an article that exceeds it. truncate: fill sections in document order, so early sections stay whole, the section the budget runs out in is cut, and every section or subsection past that point is dropped (counted in `truncation.omittedSections`). outline: split the budget evenly so every section and subsection keeps its heading, and an excerpt as far as the budget reaches — a heading the budget left empty is marked as such in the rendered text. 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.'),
|
|
1485
|
+
.describe('How to spend `maxCharacters` across an article that exceeds it. truncate: fill sections in document order, so early sections stay whole, the section the budget runs out in is cut, and every section or subsection past that point is dropped (counted in `truncation.omittedSections`). outline: split the budget evenly so every section and subsection keeps its heading, and an excerpt as far as the budget reaches — a heading the budget left empty is marked as such in the rendered text. In both modes the body sections spend the budget first and back-matter sections share what the body, tables, and assets leave. 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.'),
|
|
1385
1486
|
})
|
|
1386
1487
|
// Counted in each field's canonical form, the one its chain keys on, so the
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1488
|
+
// ceiling is settled before any upstream request; the handler then fetches
|
|
1489
|
+
// the first FETCH_LIMIT the same way. (#192, #222)
|
|
1389
1490
|
.superRefine((v, refinement) => {
|
|
1390
|
-
const distinct = new Set(
|
|
1391
|
-
...(v.pmcids ?? []).map((id) => `pmcid:${normalizePmcId(id)}`),
|
|
1392
|
-
...(v.pmids ?? []).map((id) => `pmid:${normalizePmid(id)}`),
|
|
1393
|
-
...(v.dois ?? []).map((id) => `doi:${id.toLowerCase()}`),
|
|
1394
|
-
]);
|
|
1491
|
+
const distinct = new Set(requestedIds(v).map((r) => r.key));
|
|
1395
1492
|
if (distinct.size === 0) {
|
|
1396
1493
|
refinement.addIssue({
|
|
1397
1494
|
code: 'custom',
|
|
@@ -1401,38 +1498,41 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1401
1498
|
else if (distinct.size > MAX_IDENTIFIERS) {
|
|
1402
1499
|
refinement.addIssue({
|
|
1403
1500
|
code: 'custom',
|
|
1404
|
-
message: `\`pmcids\`, \`pmids\`, and \`dois\` together name ${distinct.size} distinct identifiers; a call accepts at most ${MAX_IDENTIFIERS} — split them across calls.`,
|
|
1501
|
+
message: `\`pmcids\`, \`pmids\`, and \`dois\` together name ${distinct.size} distinct identifiers; a call accepts at most ${MAX_IDENTIFIERS} and fetches the first ${FETCH_LIMIT} — split them across calls.`,
|
|
1405
1502
|
});
|
|
1406
1503
|
}
|
|
1407
1504
|
}),
|
|
1408
1505
|
output: z.object({
|
|
1409
|
-
articles: z
|
|
1506
|
+
articles: z
|
|
1507
|
+
.array(ArticleSchema)
|
|
1508
|
+
.describe('Full-text articles in routing order — `pmcids`, then `pmids`, then `dois`, request order within each — each at the position of the id it would be deferred under, whichever tier served it. An article several ids name appears once.'),
|
|
1410
1509
|
totalReturned: z
|
|
1411
1510
|
.number()
|
|
1412
1511
|
.describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
|
|
1413
1512
|
unavailable: z
|
|
1414
1513
|
.array(UnavailableSchema)
|
|
1415
1514
|
.optional()
|
|
1416
|
-
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` names the field each id was sent in. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both.'),
|
|
1515
|
+
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` names the field each id was sent in. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both. Ids past the per-call limit are listed in `overLimit`, never here.'),
|
|
1417
1516
|
truncation: TruncationSchema.optional(),
|
|
1418
1517
|
deferred: DeferredSchema.optional(),
|
|
1518
|
+
overLimit: OverLimitSchema.optional(),
|
|
1419
1519
|
}),
|
|
1420
|
-
// Recovery guidance for
|
|
1421
|
-
//
|
|
1520
|
+
// Recovery guidance for six cases — a `sections` filter that removed every
|
|
1521
|
+
// section (#80), a record the chain could only retrieve as front matter
|
|
1422
1522
|
// (#86), a table returned with no cell values (#111), a body the per-article
|
|
1423
|
-
// character budget shortened (#81),
|
|
1424
|
-
// withheld (#100)
|
|
1425
|
-
// ctx.enrich.notice() to structuredContent
|
|
1426
|
-
// applies.
|
|
1523
|
+
// character budget shortened (#81), articles the whole-response budget
|
|
1524
|
+
// withheld (#100), and identifiers past the per-call fetch limit (#222).
|
|
1525
|
+
// Agent-facing context surfaced via ctx.enrich.notice() to structuredContent
|
|
1526
|
+
// and content[]; absent when none applies.
|
|
1427
1527
|
enrichment: {
|
|
1428
1528
|
notice: z
|
|
1429
1529
|
.string()
|
|
1430
1530
|
.optional()
|
|
1431
|
-
.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 table returned with no cell values names the affected table(s), the article each came from, and why the cells cannot be recovered. 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.'),
|
|
1531
|
+
.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 table returned with no cell values names the affected table(s), the article each came from, and why the cells cannot be recovered. 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. Identifiers past the per-call limit are named with the field to resend each under. Absent when none of those applies.'),
|
|
1432
1532
|
truncated: z
|
|
1433
1533
|
.boolean()
|
|
1434
1534
|
.optional()
|
|
1435
|
-
.describe('True when a character budget shortened at least one returned body
|
|
1535
|
+
.describe('True when a character budget shortened at least one returned body or withheld a whole article, or when the call named more distinct identifiers than one call fetches. Absent when every requested identifier was fetched and 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`; the unfetched ids are in `overLimit`.'),
|
|
1436
1536
|
},
|
|
1437
1537
|
async handler(input, ctx) {
|
|
1438
1538
|
const idCount = (input.pmcids?.length ?? 0) + (input.pmids?.length ?? 0) + (input.dois?.length ?? 0);
|
|
@@ -1450,6 +1550,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1450
1550
|
throw ctx.fail('blank_filter', `${sectionFilter.blank.map((path) => `\`${path}\``).join(', ')} ${one ? 'holds' : 'hold'} only whitespace or invisible characters, so ${one ? 'it names' : 'they name'} no section heading.`, { fields: sectionFilter.blank });
|
|
1451
1551
|
}
|
|
1452
1552
|
const filters = { ...input, sections: sectionFilter.terms };
|
|
1553
|
+
// Settled before any upstream request: every tier below works the fetched
|
|
1554
|
+
// ids alone, and the rest are handed back in `overLimit`. (#222)
|
|
1555
|
+
const { fetched, overLimit: overLimitIds } = splitAtFetchLimit(input);
|
|
1453
1556
|
// ── Chain tracking ──────────────────────────────────────────────────────
|
|
1454
1557
|
// Per-input-id tier history (the `triedTiers` array on unavailable entries).
|
|
1455
1558
|
// Keys: canonical PMID for a `pmids` entry, prefixed PMCID for a `pmcids`
|
|
@@ -1477,9 +1580,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1477
1580
|
// `dois` entry that named it — so a PMC miss can still reach Unpaywall
|
|
1478
1581
|
// without another lookup, whichever field owns the record.
|
|
1479
1582
|
const doiByPmcRecord = new Map();
|
|
1480
|
-
// Ids of returned articles whose `sections` filter removed every
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1583
|
+
// Ids of returned articles whose `sections` filter removed every section —
|
|
1584
|
+
// collected across the PMC and EPMC stages to drive one recovery notice via
|
|
1585
|
+
// ctx.enrich.notice (#80).
|
|
1483
1586
|
const sectionFilterMisses = [];
|
|
1484
1587
|
// Input ids whose PMC or EPMC record carried no body sections at all. Those
|
|
1485
1588
|
// records are not full-text hits, so the chain continues past them; ids still
|
|
@@ -1574,7 +1677,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1574
1677
|
};
|
|
1575
1678
|
// `PMC123`, `pmc123`, `123` and `PMC0123` name one record; it runs the
|
|
1576
1679
|
// chain once, keyed and reported in `PMC<digits>` form. (#170)
|
|
1577
|
-
for (const id of
|
|
1680
|
+
for (const id of fetched.pmcids) {
|
|
1578
1681
|
const normalized = normalizePmcId(id);
|
|
1579
1682
|
const key = withPmcPrefix(normalized);
|
|
1580
1683
|
if (!chainByInput.has(key))
|
|
@@ -1585,9 +1688,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1585
1688
|
// The ID Converter parses `00000001` as PMID 1 yet reports it "not found
|
|
1586
1689
|
// in PMC", and every later stage answers with NCBI's own PMID, so the chain
|
|
1587
1690
|
// runs once per distinct PMID in its canonical form. (#161)
|
|
1588
|
-
for (const id of
|
|
1691
|
+
for (const id of fetched.pmids)
|
|
1589
1692
|
addCallerId(normalizePmid(id), { id, idType: 'pmid' });
|
|
1590
|
-
const pmids = [...new Set(
|
|
1693
|
+
const pmids = [...new Set(fetched.pmids.map(normalizePmid))];
|
|
1591
1694
|
// DOIs resolve through the converter too, so PMC-indexed DOIs reach PMC
|
|
1592
1695
|
// EFetch instead of going straight to the EPMC/Unpaywall fallback (which
|
|
1593
1696
|
// misses articles whose only OA copy is the PMC JATS).
|
|
@@ -1597,7 +1700,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1597
1700
|
// DOI shares one chain, keyed by the first spelling submitted, and
|
|
1598
1701
|
// converter records are matched to it case-insensitively. (#166)
|
|
1599
1702
|
const chainKeyByDoi = new Map();
|
|
1600
|
-
for (const doi of
|
|
1703
|
+
for (const doi of fetched.dois) {
|
|
1601
1704
|
const key = chainKeyByDoi.get(doi.toLowerCase()) ?? doi;
|
|
1602
1705
|
chainKeyByDoi.set(doi.toLowerCase(), key);
|
|
1603
1706
|
addCallerId(key, { id: doi, idType: 'doi' });
|
|
@@ -1709,17 +1812,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1709
1812
|
const bodylessPmcIds = new Set();
|
|
1710
1813
|
const parsed = [];
|
|
1711
1814
|
for (const node of findAll(articleSet, 'article')) {
|
|
1712
|
-
const
|
|
1713
|
-
if (isBodylessArticle(
|
|
1714
|
-
if (
|
|
1715
|
-
bodylessPmcIds.add(
|
|
1815
|
+
const parsedArticle = parsePmcArticle(node);
|
|
1816
|
+
if (isBodylessArticle(parsedArticle)) {
|
|
1817
|
+
if (parsedArticle.pmcId)
|
|
1818
|
+
bodylessPmcIds.add(parsedArticle.pmcId);
|
|
1716
1819
|
continue;
|
|
1717
1820
|
}
|
|
1821
|
+
const before = withBackMatter(parsedArticle);
|
|
1718
1822
|
const after = applyPmcFilters(before, filters);
|
|
1719
1823
|
if (isSectionFilterMiss(before, after, filters.sections)) {
|
|
1720
1824
|
sectionFilterMisses.push(articleDisplayId(after));
|
|
1721
1825
|
}
|
|
1722
|
-
const budgeted = applyPmcBudget(after, budget);
|
|
1826
|
+
const budgeted = applyPmcBudget(after, budget, bodySectionCount(parsedArticle, filters));
|
|
1723
1827
|
omittedSections += budgeted.omittedSections;
|
|
1724
1828
|
if (budgeted.truncation) {
|
|
1725
1829
|
truncatedArticles.push({
|
|
@@ -1998,46 +2102,56 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1998
2102
|
});
|
|
1999
2103
|
}
|
|
2000
2104
|
}
|
|
2001
|
-
//
|
|
2105
|
+
// Each article is handed back under the first spelling of the id that owns
|
|
2106
|
+
// it, in the field that id was sent in. Every recovery site records the
|
|
2107
|
+
// owner; a PMC record that came back without a PMC ID has none, and is
|
|
2108
|
+
// reported under the id it carries — the total-function fallback, not an
|
|
2109
|
+
// expected path.
|
|
2110
|
+
const handBackId = (a) => {
|
|
2111
|
+
const key = inputIdByArticle.get(a);
|
|
2112
|
+
const owner = key === undefined ? undefined : callerIds.get(key)?.[0];
|
|
2113
|
+
return (owner ?? { id: articleDisplayId(a), idType: a.pmcId ? 'pmcid' : a.pmid ? 'pmid' : 'doi' });
|
|
2114
|
+
};
|
|
2115
|
+
// Articles run in routing order, each at the position of the id it is
|
|
2116
|
+
// handed back under: PMC EFetch answers in an order that is neither the
|
|
2117
|
+
// request's nor stable between calls, and the tiers finish in tier order,
|
|
2118
|
+
// so neither can decide what a budget keeps. An article with no owner sorts
|
|
2119
|
+
// last; the sort is stable. (#235)
|
|
2120
|
+
const routingPosition = new Map();
|
|
2121
|
+
for (const [index, { key }] of requestedIds(fetched).entries()) {
|
|
2122
|
+
if (!routingPosition.has(key))
|
|
2123
|
+
routingPosition.set(key, index);
|
|
2124
|
+
}
|
|
2125
|
+
const positionOf = (a) => {
|
|
2126
|
+
const { idType, id } = handBackId(a);
|
|
2127
|
+
return routingPosition.get(requestKey(idType, id)) ?? routingPosition.size;
|
|
2128
|
+
};
|
|
2129
|
+
const resolved = [...pmcArticles, ...fallbackArticles]
|
|
2130
|
+
.map((article) => ({ article, position: positionOf(article) }))
|
|
2131
|
+
.sort((a, b) => a.position - b.position)
|
|
2132
|
+
.map(({ article }) => article);
|
|
2133
|
+
// Whole-response budget: fill with complete records in routing order and
|
|
2002
2134
|
// hand the rest back as identifiers the caller can re-submit. One ledger for
|
|
2003
2135
|
// every tier — a PMC-served article and an Unpaywall-served one spend the
|
|
2004
|
-
// same characters. Without `maxResponseCharacters` nothing is measured
|
|
2005
|
-
//
|
|
2006
|
-
const resolved = [...pmcArticles, ...fallbackArticles];
|
|
2136
|
+
// same characters. Without `maxResponseCharacters` nothing is measured.
|
|
2137
|
+
// (#100)
|
|
2007
2138
|
const ceiling = input.maxResponseCharacters;
|
|
2008
2139
|
const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
|
|
2009
2140
|
const articles = fit?.kept ?? resolved;
|
|
2010
2141
|
const nextDeferredCharacters = fit?.nextDeferredCharacters;
|
|
2011
|
-
|
|
2012
|
-
// that owns it, in the field that id was sent in. Every recovery site
|
|
2013
|
-
// records the owner; a PMC record that came back without a PMC ID has none,
|
|
2014
|
-
// and is reported under the id it carries — the total-function fallback,
|
|
2015
|
-
// not an expected path.
|
|
2016
|
-
const deferredIds = (fit?.deferred ?? []).map((a) => {
|
|
2017
|
-
const key = inputIdByArticle.get(a);
|
|
2018
|
-
const owner = key === undefined ? undefined : callerIds.get(key)?.[0];
|
|
2019
|
-
return (owner ?? { id: articleDisplayId(a), idType: a.pmcId ? 'pmcid' : a.pmid ? 'pmid' : 'doi' });
|
|
2020
|
-
});
|
|
2021
|
-
const deferredFields = new Set(deferredIds.map((d) => d.idType));
|
|
2022
|
-
const [onlyField] = deferredFields;
|
|
2023
|
-
const deferredUnder = (idType) => deferredIds.filter((d) => d.idType === idType).map((d) => d.id);
|
|
2142
|
+
const deferredIds = (fit?.deferred ?? []).map(handBackId);
|
|
2024
2143
|
const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
|
|
2025
2144
|
? {
|
|
2026
2145
|
maxResponseCharacters: ceiling,
|
|
2027
2146
|
returnedCharacters: fit.keptCharacters,
|
|
2028
2147
|
deferredCount: fit.deferred.length,
|
|
2029
|
-
|
|
2030
|
-
// each field's list says where to resend its ids. (#192)
|
|
2031
|
-
...(deferredFields.size === 1 && onlyField && { idType: onlyField }),
|
|
2032
|
-
ids: deferredIds.map((d) => d.id),
|
|
2033
|
-
...(deferredFields.size > 1 && {
|
|
2034
|
-
...(deferredFields.has('pmcid') && { pmcids: deferredUnder('pmcid') }),
|
|
2035
|
-
...(deferredFields.has('pmid') && { pmids: deferredUnder('pmid') }),
|
|
2036
|
-
...(deferredFields.has('doi') && { dois: deferredUnder('doi') }),
|
|
2037
|
-
}),
|
|
2148
|
+
...groupIdsByField(deferredIds),
|
|
2038
2149
|
nextDeferredCharacters,
|
|
2039
2150
|
}
|
|
2040
2151
|
: undefined;
|
|
2152
|
+
const overLimit = overLimitIds.length > 0
|
|
2153
|
+
? { limit: FETCH_LIMIT, ...groupIdsByField(overLimitIds) }
|
|
2154
|
+
: undefined;
|
|
2041
2155
|
// A deferred article takes its body accounting out of the response with it —
|
|
2042
2156
|
// those counts describe text the caller never received.
|
|
2043
2157
|
if (fit) {
|
|
@@ -2052,6 +2166,12 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
2052
2166
|
}
|
|
2053
2167
|
}
|
|
2054
2168
|
}
|
|
2169
|
+
// The per-article ledgers fill as the tiers finish; listed in the returned
|
|
2170
|
+
// articles' routing order so identical calls report them identically. (#235)
|
|
2171
|
+
const articleRank = new Map(articles.map((a, i) => [articleDisplayId(a), i]));
|
|
2172
|
+
const rankOf = (id) => articleRank.get(id) ?? articleRank.size;
|
|
2173
|
+
truncatedArticles.sort((a, b) => rankOf(a.id) - rankOf(b.id));
|
|
2174
|
+
sectionFilterMisses.sort((a, b) => rankOf(a) - rankOf(b));
|
|
2055
2175
|
ctx.log.info('pubmed_fetch_fulltext completed', {
|
|
2056
2176
|
requested: idCount,
|
|
2057
2177
|
returned: articles.length,
|
|
@@ -2060,6 +2180,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
2060
2180
|
unpaywallHits: fallbackArticles.length,
|
|
2061
2181
|
unavailable: unavailable.length,
|
|
2062
2182
|
...(deferred && { deferred: deferred.deferredCount }),
|
|
2183
|
+
...(overLimit && { overLimit: overLimit.ids.length }),
|
|
2063
2184
|
});
|
|
2064
2185
|
// Summed off the per-article entries rather than carried through every
|
|
2065
2186
|
// stage, so a deferred article's dropped tables leave the roll-up together
|
|
@@ -2102,7 +2223,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
2102
2223
|
ctx.enrich({ truncated: true });
|
|
2103
2224
|
}
|
|
2104
2225
|
if (deferred) {
|
|
2105
|
-
notices.push(buildDeferralNotice(deferred
|
|
2226
|
+
notices.push(buildDeferralNotice(deferred, {
|
|
2227
|
+
noun: 'article',
|
|
2228
|
+
ids: deferred.ids.join(', '),
|
|
2229
|
+
recall: deferred.idType
|
|
2230
|
+
? `pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\``
|
|
2231
|
+
: `pubmed_fetch_fulltext with each id under its own field (${idFieldLists(deferred)})`,
|
|
2232
|
+
}));
|
|
2233
|
+
ctx.enrich({ truncated: true });
|
|
2234
|
+
}
|
|
2235
|
+
if (overLimit) {
|
|
2236
|
+
notices.push(buildOverLimitNotice(overLimit));
|
|
2106
2237
|
ctx.enrich({ truncated: true });
|
|
2107
2238
|
}
|
|
2108
2239
|
if (notices.length > 0)
|
|
@@ -2113,6 +2244,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
2113
2244
|
...(unavailable.length > 0 && { unavailable }),
|
|
2114
2245
|
...(truncation && { truncation }),
|
|
2115
2246
|
...(deferred && { deferred }),
|
|
2247
|
+
...(overLimit && { overLimit }),
|
|
2116
2248
|
};
|
|
2117
2249
|
},
|
|
2118
2250
|
format: (result) => {
|
|
@@ -2143,16 +2275,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
2143
2275
|
}
|
|
2144
2276
|
if (result.deferred) {
|
|
2145
2277
|
const d = result.deferred;
|
|
2146
|
-
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
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
}
|
|
2152
|
-
const fieldLists = deferredFieldLists(d);
|
|
2153
|
-
if (fieldLists) {
|
|
2154
|
-
lines.push(`Re-call \`pubmed_fetch_fulltext\` with these ids, each under its own field — ${fieldLists}`);
|
|
2155
|
-
}
|
|
2278
|
+
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`, ...resendLines(d));
|
|
2279
|
+
}
|
|
2280
|
+
if (result.overLimit) {
|
|
2281
|
+
const o = result.overLimit;
|
|
2282
|
+
lines.push(`\n**Over the per-call limit:** ${o.ids.length} identifier(s) not fetched — a call fetches the first ${o.limit} distinct identifiers`, ...resendLines(o));
|
|
2156
2283
|
}
|
|
2157
2284
|
// An empty response under a budget is a deferral, not an absence — the
|
|
2158
2285
|
// articles resolved and the ids above retrieve them.
|
|
@@ -2173,6 +2300,93 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
2173
2300
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
2174
2301
|
},
|
|
2175
2302
|
});
|
|
2303
|
+
/**
|
|
2304
|
+
* Every requested id in routing order — `pmcids`, then `pmids`, then `dois`,
|
|
2305
|
+
* request order within each — as sent. Every spelling one field accepts for an
|
|
2306
|
+
* identifier shares a key, and the same digits in two fields are two keys.
|
|
2307
|
+
* (#192, #222)
|
|
2308
|
+
*/
|
|
2309
|
+
function requestedIds(fields) {
|
|
2310
|
+
const inField = (idType, ids) => (ids ?? []).map((id) => ({ id, idType, key: requestKey(idType, id) }));
|
|
2311
|
+
return [
|
|
2312
|
+
...inField('pmcid', fields.pmcids),
|
|
2313
|
+
...inField('pmid', fields.pmids),
|
|
2314
|
+
...inField('doi', fields.dois),
|
|
2315
|
+
];
|
|
2316
|
+
}
|
|
2317
|
+
/** The key {@link requestedIds} gives an id sent in `idType`'s field. */
|
|
2318
|
+
function requestKey(idType, id) {
|
|
2319
|
+
switch (idType) {
|
|
2320
|
+
case 'pmcid':
|
|
2321
|
+
return `pmcid:${normalizePmcId(id)}`;
|
|
2322
|
+
case 'pmid':
|
|
2323
|
+
return `pmid:${normalizePmid(id)}`;
|
|
2324
|
+
case 'doi':
|
|
2325
|
+
return `doi:${id.toLowerCase()}`;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Split the requested ids at the per-call fetch limit, before any upstream
|
|
2330
|
+
* request. The first {@link FETCH_LIMIT} distinct identifiers in routing order
|
|
2331
|
+
* are fetched, each with every spelling that names it; each later identifier is
|
|
2332
|
+
* handed back once, under its first spelling — a PMC ID in the `PMC<digits>`
|
|
2333
|
+
* form every output reports it in. (#222)
|
|
2334
|
+
*/
|
|
2335
|
+
function splitAtFetchLimit(fields) {
|
|
2336
|
+
const fetched = { pmcids: [], pmids: [], dois: [] };
|
|
2337
|
+
const overLimit = [];
|
|
2338
|
+
const admitted = new Set();
|
|
2339
|
+
const listed = new Set();
|
|
2340
|
+
for (const { id, idType, key } of requestedIds(fields)) {
|
|
2341
|
+
if (admitted.has(key) || admitted.size < FETCH_LIMIT) {
|
|
2342
|
+
admitted.add(key);
|
|
2343
|
+
fetched[`${idType}s`].push(id);
|
|
2344
|
+
}
|
|
2345
|
+
else if (!listed.has(key)) {
|
|
2346
|
+
listed.add(key);
|
|
2347
|
+
overLimit.push({ id: idType === 'pmcid' ? withPmcPrefix(normalizePmcId(id)) : id, idType });
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
return { fetched, overLimit };
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Group ids handed back for a follow-up call: one field names them all —
|
|
2354
|
+
* always so for a single-field call — or each field's list says where to
|
|
2355
|
+
* resend its ids. (#192, #222)
|
|
2356
|
+
*/
|
|
2357
|
+
function groupIdsByField(callerIds) {
|
|
2358
|
+
const fields = new Set(callerIds.map((c) => c.idType));
|
|
2359
|
+
const [onlyField] = fields;
|
|
2360
|
+
const under = (idType) => callerIds.filter((c) => c.idType === idType).map((c) => c.id);
|
|
2361
|
+
return {
|
|
2362
|
+
...(fields.size === 1 && onlyField && { idType: onlyField }),
|
|
2363
|
+
ids: callerIds.map((c) => c.id),
|
|
2364
|
+
...(fields.size > 1 && {
|
|
2365
|
+
...(fields.has('pmcid') && { pmcids: under('pmcid') }),
|
|
2366
|
+
...(fields.has('pmid') && { pmids: under('pmid') }),
|
|
2367
|
+
...(fields.has('doi') && { dois: under('doi') }),
|
|
2368
|
+
}),
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* The `content[]` lines that resend a group of ids. Rendered on presence, each
|
|
2373
|
+
* independently: a group carries `idType` or the per-field lists, never both.
|
|
2374
|
+
*/
|
|
2375
|
+
function resendLines(group) {
|
|
2376
|
+
const fieldLists = idFieldLists(group);
|
|
2377
|
+
return [
|
|
2378
|
+
...(group.idType
|
|
2379
|
+
? [
|
|
2380
|
+
`Re-call \`pubmed_fetch_fulltext\` with these ${group.idType} ids as \`${group.idType}s\`: ${group.ids.join(', ')}`,
|
|
2381
|
+
]
|
|
2382
|
+
: []),
|
|
2383
|
+
...(fieldLists
|
|
2384
|
+
? [
|
|
2385
|
+
`Re-call \`pubmed_fetch_fulltext\` with these ids, each under its own field — ${fieldLists}`,
|
|
2386
|
+
]
|
|
2387
|
+
: []),
|
|
2388
|
+
];
|
|
2389
|
+
}
|
|
2176
2390
|
/**
|
|
2177
2391
|
* Merge what a Europe PMC hit carried onto a candidate bound for Unpaywall. A
|
|
2178
2392
|
* DOI the candidate already holds wins — for a `dois` entry it is the caller's
|
|
@@ -2373,16 +2587,17 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
|
|
|
2373
2587
|
if (!articleNode) {
|
|
2374
2588
|
return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
|
|
2375
2589
|
}
|
|
2376
|
-
const
|
|
2377
|
-
if (isBodylessArticle(
|
|
2590
|
+
const parsedArticle = parsePmcArticle(articleNode);
|
|
2591
|
+
if (isBodylessArticle(parsedArticle)) {
|
|
2378
2592
|
return {
|
|
2379
2593
|
kind: 'no-body',
|
|
2380
2594
|
detail: 'EPMC fullTextXML carried front matter and abstract only, with no body sections',
|
|
2381
2595
|
};
|
|
2382
2596
|
}
|
|
2597
|
+
const beforeFilter = withBackMatter(parsedArticle);
|
|
2383
2598
|
const parsed = applyPmcFilters(beforeFilter, args.input);
|
|
2384
2599
|
const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
|
|
2385
|
-
const budgeted = applyPmcBudget(parsed, args.budget);
|
|
2600
|
+
const budgeted = applyPmcBudget(parsed, args.budget, bodySectionCount(parsedArticle, args.input));
|
|
2386
2601
|
// `parsePmcArticle` always returns string fields (sometimes empty). Strip
|
|
2387
2602
|
// empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
|
|
2388
2603
|
// optional shape is respected — agents read `epmcId`/`epmcSource` for those.
|
|
@@ -2807,7 +3022,11 @@ function formatPmcArticle(a, lines, truncation, mode) {
|
|
|
2807
3022
|
lines.push(`**Type:** ${a.articleType}`);
|
|
2808
3023
|
if (a.publicationDate) {
|
|
2809
3024
|
const d = a.publicationDate;
|
|
2810
|
-
|
|
3025
|
+
// JATS deposits often write `<month>7</month>`; the rendered date pads a
|
|
3026
|
+
// one-digit numeric month or day so it reads as ISO, while the structured
|
|
3027
|
+
// parts stay as deposited. A named month (`Jul`) passes through. (#242)
|
|
3028
|
+
const pad = (part) => (part && /^\d$/.test(part) ? `0${part}` : part);
|
|
3029
|
+
const dateParts = [d.year, pad(d.month), pad(d.day)].filter(Boolean);
|
|
2811
3030
|
if (dateParts.length)
|
|
2812
3031
|
lines.push(`**Published:** ${dateParts.join('-')}`);
|
|
2813
3032
|
}
|
|
@@ -2913,7 +3132,7 @@ function formatAssets(assets, lines) {
|
|
|
2913
3132
|
lines.push(`\n#### Assets (${assets.length})`);
|
|
2914
3133
|
lines.push(`\n> \`file\` is the pointer exactly as deposited — a name inside the PMC deposit, not a fetchable URL. Open the article at the PMC link above to view it.`);
|
|
2915
3134
|
for (const asset of assets) {
|
|
2916
|
-
const heading = [asset
|
|
3135
|
+
const heading = [assetLabelName(asset), asset.caption].filter(Boolean).join(' — ');
|
|
2917
3136
|
lines.push(`\n##### ${escapeMarkdownInline(heading || 'Asset')}`);
|
|
2918
3137
|
const meta = [
|
|
2919
3138
|
asset.assetType,
|
|
@@ -3009,7 +3228,7 @@ function formatPmcAuthor(au) {
|
|
|
3009
3228
|
const parts = [];
|
|
3010
3229
|
if (au.collectiveName)
|
|
3011
3230
|
parts.push(`${au.collectiveName} (collective)`);
|
|
3012
|
-
const name = [au.givenNames, au.lastName].filter(Boolean).join(' ');
|
|
3231
|
+
const name = [au.givenNames, au.lastName, au.suffix].filter(Boolean).join(' ');
|
|
3013
3232
|
if (name)
|
|
3014
3233
|
parts.push(name);
|
|
3015
3234
|
return parts.join(' — ') || 'Unknown';
|
|
@@ -3033,7 +3252,7 @@ function sectionHeading(section) {
|
|
|
3033
3252
|
return section.label ? escapeMarkdownInline(section.label) : UNTITLED_SECTION_LABEL;
|
|
3034
3253
|
}
|
|
3035
3254
|
/**
|
|
3036
|
-
* Render one
|
|
3255
|
+
* Render one section and everything nested under it, one markdown heading
|
|
3037
3256
|
* level per nesting level. Walks the full depth the output schema carries, so
|
|
3038
3257
|
* `content[]` shows every section `structuredContent` does. Headings stop
|
|
3039
3258
|
* deepening at `######`, the deepest markdown supports. (#112)
|