@cyanheads/pubmed-mcp-server 2.9.10 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +19 -4
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_text.d.ts +22 -0
- package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_text.js +32 -0
- package/dist/mcp-server/tools/definitions/_text.js.map +1 -0
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +32 -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 +440 -79
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +87 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +195 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +28 -7
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +21 -7
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +39 -6
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +9 -0
- package/dist/services/europe-pmc/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -30,6 +30,7 @@ import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
|
|
|
30
30
|
import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
|
|
31
31
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
|
|
32
32
|
import { pmidStringSchema } from './_schemas.js';
|
|
33
|
+
import { sliceCodeUnits } from './_text.js';
|
|
33
34
|
function normalizePmcId(id) {
|
|
34
35
|
return id.replace(/^PMC/i, '');
|
|
35
36
|
}
|
|
@@ -76,10 +77,10 @@ function isSectionFilterMiss(before, after, sectionFilter) {
|
|
|
76
77
|
function isBodylessArticle(before) {
|
|
77
78
|
return before.sections.length === 0;
|
|
78
79
|
}
|
|
79
|
-
/** Pick the best human-readable identifier for an article
|
|
80
|
-
*
|
|
80
|
+
/** Pick the best human-readable identifier for an article, for recovery notices
|
|
81
|
+
* and character-budget accounting. Treats empty strings as absent — EPMC-only
|
|
81
82
|
* records carry an empty `pmcId`. */
|
|
82
|
-
function
|
|
83
|
+
function articleDisplayId(a) {
|
|
83
84
|
return [a.pmcId, a.pmid, a.doi, a.epmcId].find((v) => v && v.length > 0) ?? 'article';
|
|
84
85
|
}
|
|
85
86
|
/**
|
|
@@ -280,6 +281,234 @@ const UnavailableSchema = z
|
|
|
280
281
|
.describe('Per-tier outcomes the chain produced for this id, in execution order. Covers `pmc`, `europepmc`, and `unpaywall` — the same tiers the tool description references. Tiers that the chain skipped appear as `outcome: not-attempted` with a `detail` explaining why.'),
|
|
281
282
|
})
|
|
282
283
|
.describe('One identifier that could not be returned, with the full chain it traversed');
|
|
284
|
+
// ─── Character-budget schemas ────────────────────────────────────────────────
|
|
285
|
+
const TruncatedSectionSchema = z
|
|
286
|
+
.object({
|
|
287
|
+
title: z.string().optional().describe('Section heading, when the section carries one'),
|
|
288
|
+
originalCharacters: z
|
|
289
|
+
.number()
|
|
290
|
+
.describe('Body characters this section carried before the budget pass'),
|
|
291
|
+
returnedCharacters: z
|
|
292
|
+
.number()
|
|
293
|
+
.describe('Body characters this section carries in the response. Zero means the section was dropped in `truncate` mode, or kept as a heading-only entry in `outline` mode.'),
|
|
294
|
+
truncated: z
|
|
295
|
+
.boolean()
|
|
296
|
+
.describe('True when the section returned fewer characters than it originally carried'),
|
|
297
|
+
})
|
|
298
|
+
.describe('Character accounting for one body section of a budgeted article');
|
|
299
|
+
const TruncatedArticleSchema = z
|
|
300
|
+
.object({
|
|
301
|
+
id: z
|
|
302
|
+
.string()
|
|
303
|
+
.describe('Identifier for the article — PMCID, PMID, DOI, or Europe PMC id, whichever the article carries first'),
|
|
304
|
+
source: z
|
|
305
|
+
.enum(['pmc', 'unpaywall'])
|
|
306
|
+
.describe('Which output shape was budgeted: `pmc` budgets body sections and subsections, `unpaywall` budgets the single `content` body'),
|
|
307
|
+
originalCharacters: z
|
|
308
|
+
.number()
|
|
309
|
+
.describe('Body characters this article carried before the budget pass'),
|
|
310
|
+
returnedCharacters: z.number().describe('Body characters this article carries in the response'),
|
|
311
|
+
sections: z
|
|
312
|
+
.array(TruncatedSectionSchema)
|
|
313
|
+
.optional()
|
|
314
|
+
.describe('Per-section accounting for `source: pmc` articles, in document order, including sections dropped for budget. Absent for `source: unpaywall`, whose body has no section structure.'),
|
|
315
|
+
})
|
|
316
|
+
.describe('Character accounting for one article the budget shortened');
|
|
317
|
+
const TruncationSchema = z
|
|
318
|
+
.object({
|
|
319
|
+
mode: z
|
|
320
|
+
.enum(['truncate', 'outline'])
|
|
321
|
+
.describe('The `overflowMode` that produced these results'),
|
|
322
|
+
maxCharacters: z.number().optional().describe('The `maxCharacters` budget applied, when set'),
|
|
323
|
+
maxCharactersPerSection: z
|
|
324
|
+
.number()
|
|
325
|
+
.optional()
|
|
326
|
+
.describe('The `maxCharactersPerSection` budget applied, when set'),
|
|
327
|
+
originalCharacters: z
|
|
328
|
+
.number()
|
|
329
|
+
.describe('Body characters the shortened articles carried before the budget pass'),
|
|
330
|
+
returnedCharacters: z
|
|
331
|
+
.number()
|
|
332
|
+
.describe('Body characters the shortened articles carry in this response'),
|
|
333
|
+
omittedSections: z
|
|
334
|
+
.number()
|
|
335
|
+
.describe('Body sections dropped entirely because an article budget was exhausted before reaching them. Always 0 in `outline` mode, which keeps every heading.'),
|
|
336
|
+
articles: z
|
|
337
|
+
.array(TruncatedArticleSchema)
|
|
338
|
+
.describe('Per-article accounting, covering only the articles the budget shortened'),
|
|
339
|
+
})
|
|
340
|
+
.describe('Character accounting for full text the budget shortened. Present only when a budget actually removed characters — its absence means every returned article carries its full post-filter body.');
|
|
341
|
+
/** True when the request asked for any budget at all. Without one, every budget
|
|
342
|
+
* helper returns its input untouched so the response is byte-identical. */
|
|
343
|
+
function budgetRequested(budget) {
|
|
344
|
+
return budget.maxCharacters !== undefined || budget.maxCharactersPerSection !== undefined;
|
|
345
|
+
}
|
|
346
|
+
/** Body characters a top-level section carries — its own text plus its subsections'. */
|
|
347
|
+
function sectionCharacters(section) {
|
|
348
|
+
return (section.text.length + (section.subsections?.reduce((n, sub) => n + sub.text.length, 0) ?? 0));
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Shorten an ordered list of text fields so their combined length fits
|
|
352
|
+
* `allowance`. Fields are filled in order, so earlier fields survive whole and
|
|
353
|
+
* later ones absorb the shortfall — the section's own text before its
|
|
354
|
+
* subsections. Cuts at the character boundary with no appended marker so the
|
|
355
|
+
* reported `returnedCharacters` is exact; `format()` carries the human-visible
|
|
356
|
+
* note. A cut that would split a surrogate pair backs off a code unit, so a
|
|
357
|
+
* field can return one character under its share — counts are measured off the
|
|
358
|
+
* returned text, never off the allowance. (#93)
|
|
359
|
+
*/
|
|
360
|
+
function fitFields(fields, allowance) {
|
|
361
|
+
let remaining = Math.max(allowance, 0);
|
|
362
|
+
return fields.map((text) => {
|
|
363
|
+
const kept = sliceCodeUnits(text, remaining);
|
|
364
|
+
remaining -= kept.length;
|
|
365
|
+
return kept;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Split `total` evenly across sections, then hand the leftover from sections
|
|
370
|
+
* that need less than their share back to the ones still capped, until the
|
|
371
|
+
* budget is spent or every section holds all it can. Equal shares alone would
|
|
372
|
+
* strand budget on short sections — a ten-section article with two one-line
|
|
373
|
+
* sections would return well under what the caller asked for.
|
|
374
|
+
*/
|
|
375
|
+
function evenShares(caps, total) {
|
|
376
|
+
const allowances = caps.map(() => 0);
|
|
377
|
+
let remaining = total;
|
|
378
|
+
while (remaining > 0) {
|
|
379
|
+
const hungry = caps.reduce((acc, cap, i) => {
|
|
380
|
+
if ((allowances[i] ?? 0) < cap)
|
|
381
|
+
acc.push(i);
|
|
382
|
+
return acc;
|
|
383
|
+
}, []);
|
|
384
|
+
if (hungry.length === 0)
|
|
385
|
+
break;
|
|
386
|
+
const share = Math.floor(remaining / hungry.length);
|
|
387
|
+
// Fewer characters left than sections still wanting them: hand out the
|
|
388
|
+
// remainder one character at a time so the budget is fully spent.
|
|
389
|
+
for (const i of hungry) {
|
|
390
|
+
const want = (caps[i] ?? 0) - (allowances[i] ?? 0);
|
|
391
|
+
const give = Math.min(share === 0 ? 1 : share, want, remaining);
|
|
392
|
+
allowances[i] = (allowances[i] ?? 0) + give;
|
|
393
|
+
remaining -= give;
|
|
394
|
+
if (remaining === 0)
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return allowances;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Decide how many characters each top-level section may keep.
|
|
402
|
+
*
|
|
403
|
+
* `truncate` fills sections greedily in document order: early sections keep
|
|
404
|
+
* their full text and sections reached after the budget is spent get nothing.
|
|
405
|
+
* `outline` spreads `maxCharacters` across every section instead, so each
|
|
406
|
+
* heading survives with an excerpt rather than the budget being consumed by the
|
|
407
|
+
* first sections. `maxCharactersPerSection` caps each section under either mode.
|
|
408
|
+
*/
|
|
409
|
+
function allotSectionBudgets(sizes, budget) {
|
|
410
|
+
const perSection = budget.maxCharactersPerSection;
|
|
411
|
+
const total = budget.maxCharacters;
|
|
412
|
+
if (budget.overflowMode === 'outline' && total !== undefined) {
|
|
413
|
+
return evenShares(sizes.map((size) => Math.min(perSection ?? size, size)), total);
|
|
414
|
+
}
|
|
415
|
+
let remaining = total ?? sizes.reduce((sum, size) => sum + size, 0);
|
|
416
|
+
return sizes.map((size) => {
|
|
417
|
+
const allowance = Math.min(perSection ?? size, size, remaining);
|
|
418
|
+
remaining -= allowance;
|
|
419
|
+
return allowance;
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Apply the character budget to a JATS article's body. Runs as a pure
|
|
424
|
+
* post-processing pass after `applyPmcFilters`, so `sections` / `maxSections` /
|
|
425
|
+
* `includeReferences` and the empty-body signals they feed are unaffected.
|
|
426
|
+
* Titles, abstracts, identifiers, and references are never counted or cut —
|
|
427
|
+
* the budget only spends on body text, keeping every article citable.
|
|
428
|
+
*
|
|
429
|
+
* Returns the article untouched (same object identity) when no budget was
|
|
430
|
+
* requested or nothing exceeded it. A section left with zero characters is
|
|
431
|
+
* dropped in `truncate` mode and counted as omitted; `outline` keeps it as a
|
|
432
|
+
* heading-only entry. Dropped sections still appear in the accounting so the
|
|
433
|
+
* caller can see which headings exist. (#81)
|
|
434
|
+
*/
|
|
435
|
+
function applyPmcBudget(article, budget) {
|
|
436
|
+
if (!budgetRequested(budget) || article.sections.length === 0) {
|
|
437
|
+
return { article, omittedSections: 0 };
|
|
438
|
+
}
|
|
439
|
+
const sizes = article.sections.map(sectionCharacters);
|
|
440
|
+
const originalCharacters = sizes.reduce((sum, size) => sum + size, 0);
|
|
441
|
+
const allowances = allotSectionBudgets(sizes, budget);
|
|
442
|
+
const kept = [];
|
|
443
|
+
const sectionReports = [];
|
|
444
|
+
let omittedSections = 0;
|
|
445
|
+
let returnedCharacters = 0;
|
|
446
|
+
article.sections.forEach((section, i) => {
|
|
447
|
+
const original = sizes[i] ?? 0;
|
|
448
|
+
const fitted = fitFields([section.text, ...(section.subsections?.map((sub) => sub.text) ?? [])], allowances[i] ?? 0);
|
|
449
|
+
const returned = fitted.reduce((sum, text) => sum + text.length, 0);
|
|
450
|
+
returnedCharacters += returned;
|
|
451
|
+
sectionReports.push({
|
|
452
|
+
...(section.title !== undefined && { title: section.title }),
|
|
453
|
+
originalCharacters: original,
|
|
454
|
+
returnedCharacters: returned,
|
|
455
|
+
truncated: returned < original,
|
|
456
|
+
});
|
|
457
|
+
if (returned === 0 && original > 0 && budget.overflowMode === 'truncate') {
|
|
458
|
+
omittedSections += 1;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
kept.push({
|
|
462
|
+
...section,
|
|
463
|
+
text: fitted[0] ?? '',
|
|
464
|
+
...(section.subsections && {
|
|
465
|
+
subsections: section.subsections.map((sub, j) => ({ ...sub, text: fitted[j + 1] ?? '' })),
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
if (returnedCharacters === originalCharacters && omittedSections === 0) {
|
|
470
|
+
return { article, omittedSections: 0 };
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
article: { ...article, sections: kept },
|
|
474
|
+
omittedSections,
|
|
475
|
+
truncation: { originalCharacters, returnedCharacters, sections: sectionReports },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Apply the character budget to an Unpaywall body. That body is one
|
|
480
|
+
* unstructured blob — HTML-as-Markdown or PDF-as-text — so only `maxCharacters`
|
|
481
|
+
* applies, and `outline` mode has no headings to preserve and behaves like
|
|
482
|
+
* `truncate`. (#81)
|
|
483
|
+
*/
|
|
484
|
+
function applyContentBudget(content, budget) {
|
|
485
|
+
const cap = budget.maxCharacters;
|
|
486
|
+
if (cap === undefined || content.length <= cap)
|
|
487
|
+
return { content };
|
|
488
|
+
const kept = sliceCodeUnits(content, cap);
|
|
489
|
+
return {
|
|
490
|
+
content: kept,
|
|
491
|
+
truncation: { originalCharacters: content.length, returnedCharacters: kept.length },
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Compose the recovery notice for a budgeted response. Names what was spent and
|
|
496
|
+
* where the detail lives so an agent reading only `content[]` knows the body it
|
|
497
|
+
* received is partial. (#81)
|
|
498
|
+
*/
|
|
499
|
+
function buildTruncationNotice(truncation) {
|
|
500
|
+
const subject = truncation.articles.length === 1 ? '1 article' : `${truncation.articles.length} articles`;
|
|
501
|
+
const omitted = truncation.omittedSections > 0
|
|
502
|
+
? ` ${truncation.omittedSections} section(s) were dropped once the budget ran out.`
|
|
503
|
+
: '';
|
|
504
|
+
// Name only the budgets the request actually set — pointing at `maxCharacters`
|
|
505
|
+
// when the caller only capped per-section sends them to a knob that is unset.
|
|
506
|
+
const knobs = [
|
|
507
|
+
truncation.maxCharacters !== undefined ? '`maxCharacters`' : undefined,
|
|
508
|
+
truncation.maxCharactersPerSection !== undefined ? '`maxCharactersPerSection`' : undefined,
|
|
509
|
+
].filter((k) => k !== undefined);
|
|
510
|
+
return `Full text was shortened to fit the requested character budget: ${truncation.returnedCharacters} of ${truncation.originalCharacters} body characters returned across ${subject} in ${truncation.mode} mode.${omitted} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
|
|
511
|
+
}
|
|
283
512
|
// ─── Tool Definition ─────────────────────────────────────────────────────────
|
|
284
513
|
/**
|
|
285
514
|
* Compose the tool description for the fallback tiers enabled in this
|
|
@@ -367,6 +596,24 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
367
596
|
.array(z.string())
|
|
368
597
|
.optional()
|
|
369
598
|
.describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
|
|
599
|
+
maxCharacters: z
|
|
600
|
+
.number()
|
|
601
|
+
.int()
|
|
602
|
+
.min(1)
|
|
603
|
+
.max(1_000_000)
|
|
604
|
+
.optional()
|
|
605
|
+
.describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, so semantic filtering is unaffected. The response-wide ceiling is this value times the number of articles returned. Omit for the full body.'),
|
|
606
|
+
maxCharactersPerSection: z
|
|
607
|
+
.number()
|
|
608
|
+
.int()
|
|
609
|
+
.min(1)
|
|
610
|
+
.max(1_000_000)
|
|
611
|
+
.optional()
|
|
612
|
+
.describe('Budget for a single top-level body section, in characters, counting the section text plus its subsections. Combine with `maxCharacters` to cap both one section and the article; the tighter of the two wins. Applies to `source=pmc` results only.'),
|
|
613
|
+
overflowMode: z
|
|
614
|
+
.enum(['truncate', 'outline'])
|
|
615
|
+
.default('truncate')
|
|
616
|
+
.describe('How to spend `maxCharacters` across an article that exceeds it. truncate: fill sections in document order, so early sections stay whole and sections past the budget are dropped (counted in `truncation.omittedSections`). outline: split the budget evenly so every section keeps its heading, and an excerpt as far as the budget reaches — use it to survey what an article contains before requesting specific `sections`. Ignored when no budget is set, and identical for `source=unpaywall` bodies, which have no headings to preserve.'),
|
|
370
617
|
})
|
|
371
618
|
.refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
|
|
372
619
|
message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
|
|
@@ -378,16 +625,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
378
625
|
.array(UnavailableSchema)
|
|
379
626
|
.optional()
|
|
380
627
|
.describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
|
|
628
|
+
truncation: TruncationSchema.optional(),
|
|
381
629
|
}),
|
|
382
|
-
// Recovery guidance for
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// structuredContent and content[]; absent
|
|
630
|
+
// Recovery guidance for three cases — a `sections` filter that removed every
|
|
631
|
+
// body section (#80), a record the chain could only retrieve as front matter
|
|
632
|
+
// (#86), and a body the character budget shortened (#81). Agent-facing context
|
|
633
|
+
// surfaced via ctx.enrich.notice() to structuredContent and content[]; absent
|
|
634
|
+
// when none applies.
|
|
386
635
|
enrichment: {
|
|
387
636
|
notice: z
|
|
388
637
|
.string()
|
|
389
638
|
.optional()
|
|
390
|
-
.describe('Optional guidance for empty
|
|
639
|
+
.describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. Absent when none of those applies.'),
|
|
391
640
|
},
|
|
392
641
|
async handler(input, ctx) {
|
|
393
642
|
ctx.log.info('Executing pubmed_fetch_fulltext', {
|
|
@@ -418,6 +667,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
418
667
|
// records are not full-text hits, so the chain continues past them; ids still
|
|
419
668
|
// unrecovered at the end drive the metadata-only recovery notice (#86).
|
|
420
669
|
const bodylessInputIds = new Set();
|
|
670
|
+
// Per-article character accounting collected across all three stages, plus
|
|
671
|
+
// the running count of sections the budget dropped. Empty when no budget was
|
|
672
|
+
// requested or nothing exceeded it (#81).
|
|
673
|
+
const truncatedArticles = [];
|
|
674
|
+
let omittedSections = 0;
|
|
675
|
+
const budget = {
|
|
676
|
+
overflowMode: input.overflowMode,
|
|
677
|
+
...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
|
|
678
|
+
...(input.maxCharactersPerSection !== undefined && {
|
|
679
|
+
maxCharactersPerSection: input.maxCharactersPerSection,
|
|
680
|
+
}),
|
|
681
|
+
};
|
|
421
682
|
const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
|
|
422
683
|
// ── Branch routing → produce buckets the staged chain consumes ──────────
|
|
423
684
|
let pmcIds = [];
|
|
@@ -564,9 +825,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
564
825
|
}
|
|
565
826
|
const after = applyPmcFilters(before, input);
|
|
566
827
|
if (isSectionFilterMiss(before, after, input.sections)) {
|
|
567
|
-
sectionFilterMisses.push(
|
|
828
|
+
sectionFilterMisses.push(articleDisplayId(after));
|
|
829
|
+
}
|
|
830
|
+
const budgeted = applyPmcBudget(after, budget);
|
|
831
|
+
omittedSections += budgeted.omittedSections;
|
|
832
|
+
if (budgeted.truncation) {
|
|
833
|
+
truncatedArticles.push({
|
|
834
|
+
id: articleDisplayId(after),
|
|
835
|
+
source: 'pmc',
|
|
836
|
+
...budgeted.truncation,
|
|
837
|
+
});
|
|
568
838
|
}
|
|
569
|
-
parsed.push({ source: 'pmc', viaSource: 'pmc', ...
|
|
839
|
+
parsed.push({ source: 'pmc', viaSource: 'pmc', ...budgeted.article });
|
|
570
840
|
}
|
|
571
841
|
pmcArticles = parsed;
|
|
572
842
|
const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
|
|
@@ -614,6 +884,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
614
884
|
pmcidFallbackCandidates,
|
|
615
885
|
doiCandidates,
|
|
616
886
|
input,
|
|
887
|
+
budget,
|
|
617
888
|
ctx,
|
|
618
889
|
})
|
|
619
890
|
: {
|
|
@@ -625,8 +896,12 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
625
896
|
pmcidOutcomes: new Map(),
|
|
626
897
|
doiOutcomes: new Map(),
|
|
627
898
|
sectionFilterMisses: [],
|
|
899
|
+
truncatedArticles: [],
|
|
900
|
+
omittedSections: 0,
|
|
628
901
|
};
|
|
629
902
|
pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
|
|
903
|
+
truncatedArticles.push(...epmcOutcomes.truncatedArticles);
|
|
904
|
+
omittedSections += epmcOutcomes.omittedSections;
|
|
630
905
|
// Fold EPMC outcomes into each id's chain. EPMC-served articles count as
|
|
631
906
|
// recovered, so their ids are added to `recoveredIds` here.
|
|
632
907
|
if (!epmc) {
|
|
@@ -719,7 +994,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
719
994
|
return {
|
|
720
995
|
pmcId,
|
|
721
996
|
result: candidate.doi
|
|
722
|
-
? await resolveUnpaywall({ pmcId, doi: candidate.doi }, unpaywall, ctx)
|
|
997
|
+
? await resolveUnpaywall({ pmcId, doi: candidate.doi, budget }, unpaywall, ctx)
|
|
723
998
|
: { unavailable: { reason: 'no-doi' } },
|
|
724
999
|
};
|
|
725
1000
|
}));
|
|
@@ -727,6 +1002,8 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
727
1002
|
const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
|
|
728
1003
|
if ('article' in result) {
|
|
729
1004
|
fallbackArticles.push(result.article);
|
|
1005
|
+
if (result.truncation)
|
|
1006
|
+
truncatedArticles.push(result.truncation);
|
|
730
1007
|
recoveredIds.add(inputId);
|
|
731
1008
|
}
|
|
732
1009
|
else {
|
|
@@ -775,12 +1052,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
775
1052
|
const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
|
|
776
1053
|
candidate,
|
|
777
1054
|
result: candidate.doi
|
|
778
|
-
? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi }, unpaywall, ctx)
|
|
1055
|
+
? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi, budget }, unpaywall, ctx)
|
|
779
1056
|
: { unavailable: { reason: 'no-doi' } },
|
|
780
1057
|
})));
|
|
781
1058
|
for (const { candidate, result } of outcomes) {
|
|
782
1059
|
if ('article' in result) {
|
|
783
1060
|
fallbackArticles.push(result.article);
|
|
1061
|
+
if (result.truncation)
|
|
1062
|
+
truncatedArticles.push(result.truncation);
|
|
784
1063
|
recoveredIds.add(candidate.pmid);
|
|
785
1064
|
}
|
|
786
1065
|
else {
|
|
@@ -809,11 +1088,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
809
1088
|
// doesn't reject under normal operation.
|
|
810
1089
|
const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
|
|
811
1090
|
doi: c.doi,
|
|
812
|
-
result: await resolveUnpaywall({ doi: c.doi }, unpaywall, ctx),
|
|
1091
|
+
result: await resolveUnpaywall({ doi: c.doi, budget }, unpaywall, ctx),
|
|
813
1092
|
})));
|
|
814
1093
|
for (const { doi, result } of outcomes) {
|
|
815
1094
|
if ('article' in result) {
|
|
816
1095
|
fallbackArticles.push(result.article);
|
|
1096
|
+
if (result.truncation)
|
|
1097
|
+
truncatedArticles.push(result.truncation);
|
|
817
1098
|
recoveredIds.add(doi);
|
|
818
1099
|
}
|
|
819
1100
|
else {
|
|
@@ -848,6 +1129,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
848
1129
|
unpaywallHits: fallbackArticles.length,
|
|
849
1130
|
unavailable: unavailable.length,
|
|
850
1131
|
});
|
|
1132
|
+
// Rolled up only when the budget actually removed characters, so an
|
|
1133
|
+
// under-budget request returns exactly what it did before the budget
|
|
1134
|
+
// controls existed. (#81)
|
|
1135
|
+
const truncation = truncatedArticles.length > 0
|
|
1136
|
+
? {
|
|
1137
|
+
mode: input.overflowMode,
|
|
1138
|
+
...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
|
|
1139
|
+
...(input.maxCharactersPerSection !== undefined && {
|
|
1140
|
+
maxCharactersPerSection: input.maxCharactersPerSection,
|
|
1141
|
+
}),
|
|
1142
|
+
originalCharacters: truncatedArticles.reduce((n, a) => n + a.originalCharacters, 0),
|
|
1143
|
+
returnedCharacters: truncatedArticles.reduce((n, a) => n + a.returnedCharacters, 0),
|
|
1144
|
+
omittedSections,
|
|
1145
|
+
articles: truncatedArticles,
|
|
1146
|
+
}
|
|
1147
|
+
: undefined;
|
|
851
1148
|
// Only the last ctx.enrich.notice survives, so the applicable fragments are
|
|
852
1149
|
// collected and emitted once.
|
|
853
1150
|
const notices = [];
|
|
@@ -857,12 +1154,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
857
1154
|
const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
|
|
858
1155
|
if (unrecoveredBodyless.length > 0)
|
|
859
1156
|
notices.push(buildBodylessNotice(unrecoveredBodyless));
|
|
1157
|
+
if (truncation)
|
|
1158
|
+
notices.push(buildTruncationNotice(truncation));
|
|
860
1159
|
if (notices.length > 0)
|
|
861
1160
|
ctx.enrich.notice(notices.join(' '));
|
|
862
1161
|
return {
|
|
863
1162
|
articles,
|
|
864
1163
|
totalReturned: articles.length,
|
|
865
1164
|
...(unavailable.length > 0 && { unavailable }),
|
|
1165
|
+
...(truncation && { truncation }),
|
|
866
1166
|
};
|
|
867
1167
|
},
|
|
868
1168
|
format: (result) => {
|
|
@@ -884,12 +1184,16 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
884
1184
|
if (result.totalReturned === 0) {
|
|
885
1185
|
lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC, Europe PMC, or recoverable via Unpaywall to retrieve full text. For metadata and abstracts only, use \`pubmed_fetch_articles\`.`);
|
|
886
1186
|
}
|
|
1187
|
+
if (result.truncation)
|
|
1188
|
+
formatTruncation(result.truncation, lines);
|
|
1189
|
+
const truncationById = new Map(result.truncation?.articles.map((t) => [t.id, t]) ?? []);
|
|
887
1190
|
for (const a of result.articles) {
|
|
888
1191
|
lines.push('');
|
|
1192
|
+
const t = truncationById.get(articleDisplayId(a));
|
|
889
1193
|
if (a.source === 'pmc')
|
|
890
|
-
formatPmcArticle(a, lines);
|
|
1194
|
+
formatPmcArticle(a, lines, t);
|
|
891
1195
|
else
|
|
892
|
-
formatUnpaywallArticle(a, lines);
|
|
1196
|
+
formatUnpaywallArticle(a, lines, t);
|
|
893
1197
|
}
|
|
894
1198
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
895
1199
|
},
|
|
@@ -934,6 +1238,8 @@ async function runEpmcStage(epmc, args) {
|
|
|
934
1238
|
outcome: { kind: 'hit' },
|
|
935
1239
|
article: fetched.article,
|
|
936
1240
|
sectionFilterMiss: fetched.sectionFilterMiss,
|
|
1241
|
+
omittedSections: fetched.omittedSections,
|
|
1242
|
+
...(fetched.truncation && { truncation: fetched.truncation }),
|
|
937
1243
|
};
|
|
938
1244
|
};
|
|
939
1245
|
/**
|
|
@@ -964,35 +1270,36 @@ async function runEpmcStage(epmc, args) {
|
|
|
964
1270
|
const pmcidOutcomes = new Map();
|
|
965
1271
|
const doiOutcomes = new Map();
|
|
966
1272
|
const sectionFilterMisses = [];
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1273
|
+
const truncatedArticles = [];
|
|
1274
|
+
let omittedSections = 0;
|
|
1275
|
+
const collectHit = (run) => {
|
|
1276
|
+
articles.push(run.article);
|
|
1277
|
+
if (run.sectionFilterMiss)
|
|
1278
|
+
sectionFilterMisses.push(articleDisplayId(run.article));
|
|
1279
|
+
if (run.truncation)
|
|
1280
|
+
truncatedArticles.push(run.truncation);
|
|
1281
|
+
omittedSections += run.omittedSections ?? 0;
|
|
1282
|
+
};
|
|
1283
|
+
for (const run of pmidResults) {
|
|
1284
|
+
pmidOutcomes.set(run.c.pmid, run.outcome);
|
|
1285
|
+
if (run.article)
|
|
1286
|
+
collectHit({ ...run, article: run.article });
|
|
974
1287
|
else
|
|
975
|
-
remainingPmid.push(c);
|
|
1288
|
+
remainingPmid.push(run.c);
|
|
976
1289
|
}
|
|
977
|
-
for (const
|
|
978
|
-
pmcidOutcomes.set(
|
|
979
|
-
if (article)
|
|
980
|
-
|
|
981
|
-
if (sectionFilterMiss)
|
|
982
|
-
sectionFilterMisses.push(articleSectionMissId(article));
|
|
983
|
-
}
|
|
1290
|
+
for (const run of pmcidResults) {
|
|
1291
|
+
pmcidOutcomes.set(run.c.normalized, run.outcome);
|
|
1292
|
+
if (run.article)
|
|
1293
|
+
collectHit({ ...run, article: run.article });
|
|
984
1294
|
else
|
|
985
|
-
remainingPmcid.push(doi && !
|
|
1295
|
+
remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
|
|
986
1296
|
}
|
|
987
|
-
for (const
|
|
988
|
-
doiOutcomes.set(c.doi, outcome);
|
|
989
|
-
if (article)
|
|
990
|
-
|
|
991
|
-
if (sectionFilterMiss)
|
|
992
|
-
sectionFilterMisses.push(articleSectionMissId(article));
|
|
993
|
-
}
|
|
1297
|
+
for (const run of doiResults) {
|
|
1298
|
+
doiOutcomes.set(run.c.doi, run.outcome);
|
|
1299
|
+
if (run.article)
|
|
1300
|
+
collectHit({ ...run, article: run.article });
|
|
994
1301
|
else
|
|
995
|
-
remainingDoi.push(c);
|
|
1302
|
+
remainingDoi.push(run.c);
|
|
996
1303
|
}
|
|
997
1304
|
return {
|
|
998
1305
|
articles,
|
|
@@ -1003,6 +1310,8 @@ async function runEpmcStage(epmc, args) {
|
|
|
1003
1310
|
pmcidOutcomes,
|
|
1004
1311
|
doiOutcomes,
|
|
1005
1312
|
sectionFilterMisses,
|
|
1313
|
+
truncatedArticles,
|
|
1314
|
+
omittedSections,
|
|
1006
1315
|
};
|
|
1007
1316
|
}
|
|
1008
1317
|
/**
|
|
@@ -1064,28 +1373,38 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
|
|
|
1064
1373
|
}
|
|
1065
1374
|
const parsed = applyPmcFilters(beforeFilter, args.input);
|
|
1066
1375
|
const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
|
|
1376
|
+
const budgeted = applyPmcBudget(parsed, args.budget);
|
|
1067
1377
|
// `parsePmcArticle` always returns string fields (sometimes empty). Strip
|
|
1068
1378
|
// empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
|
|
1069
1379
|
// optional shape is respected — agents read `epmcId`/`epmcSource` for those.
|
|
1070
|
-
const { pmcId, pmcUrl, ...rest } =
|
|
1380
|
+
const { pmcId, pmcUrl, ...rest } = budgeted.article;
|
|
1071
1381
|
const pmid = rest.pmid ?? hit.pmid ?? contextPmid;
|
|
1072
1382
|
const doi = rest.doi ?? hit.doi;
|
|
1383
|
+
const article = {
|
|
1384
|
+
source: 'pmc',
|
|
1385
|
+
viaSource: 'europepmc',
|
|
1386
|
+
...rest,
|
|
1387
|
+
...(pmcId && { pmcId, pmcUrl }),
|
|
1388
|
+
...(pmid && {
|
|
1389
|
+
pmid,
|
|
1390
|
+
pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
|
|
1391
|
+
}),
|
|
1392
|
+
...(doi && { doi }),
|
|
1393
|
+
epmcId: hit.id,
|
|
1394
|
+
epmcSource: hit.source,
|
|
1395
|
+
};
|
|
1073
1396
|
return {
|
|
1074
1397
|
kind: 'article',
|
|
1075
1398
|
sectionFilterMiss,
|
|
1076
|
-
article
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
...(doi && { doi }),
|
|
1086
|
-
epmcId: hit.id,
|
|
1087
|
-
epmcSource: hit.source,
|
|
1088
|
-
},
|
|
1399
|
+
article,
|
|
1400
|
+
omittedSections: budgeted.omittedSections,
|
|
1401
|
+
...(budgeted.truncation && {
|
|
1402
|
+
truncation: {
|
|
1403
|
+
id: articleDisplayId(article),
|
|
1404
|
+
source: 'pmc',
|
|
1405
|
+
...budgeted.truncation,
|
|
1406
|
+
},
|
|
1407
|
+
}),
|
|
1089
1408
|
};
|
|
1090
1409
|
}
|
|
1091
1410
|
catch (error) {
|
|
@@ -1131,8 +1450,23 @@ async function fetchPubmedDois(pmids, signal) {
|
|
|
1131
1450
|
* it carries its identifier through — Unpaywall itself only knows the DOI.
|
|
1132
1451
|
*/
|
|
1133
1452
|
async function resolveUnpaywall(args, service, ctx) {
|
|
1134
|
-
const { pmcId, pmid, doi } = args;
|
|
1453
|
+
const { pmcId, pmid, doi, budget } = args;
|
|
1135
1454
|
const requestedIds = { ...(pmcId && { pmcId }), ...(pmid && { pmid }) };
|
|
1455
|
+
/** Budget the extracted body, then pair the article with its accounting. */
|
|
1456
|
+
const budgeted = (build, content) => {
|
|
1457
|
+
const capped = applyContentBudget(content, budget);
|
|
1458
|
+
const article = build(capped.content);
|
|
1459
|
+
return {
|
|
1460
|
+
article,
|
|
1461
|
+
...(capped.truncation && {
|
|
1462
|
+
truncation: {
|
|
1463
|
+
id: articleDisplayId(article),
|
|
1464
|
+
source: 'unpaywall',
|
|
1465
|
+
...capped.truncation,
|
|
1466
|
+
},
|
|
1467
|
+
}),
|
|
1468
|
+
};
|
|
1469
|
+
};
|
|
1136
1470
|
let resolution;
|
|
1137
1471
|
try {
|
|
1138
1472
|
resolution = await service.resolve(doi, ctx.signal);
|
|
@@ -1169,18 +1503,16 @@ async function resolveUnpaywall(args, service, ctx) {
|
|
|
1169
1503
|
},
|
|
1170
1504
|
};
|
|
1171
1505
|
}
|
|
1172
|
-
return {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
}),
|
|
1183
|
-
};
|
|
1506
|
+
return budgeted((text) => buildUnpaywallArticle({
|
|
1507
|
+
...requestedIds,
|
|
1508
|
+
doi,
|
|
1509
|
+
sourceUrl: content.fetchedUrl,
|
|
1510
|
+
location: resolution.location,
|
|
1511
|
+
contentFormat: 'html-markdown',
|
|
1512
|
+
content: text,
|
|
1513
|
+
title: extracted.title,
|
|
1514
|
+
wordCount: extracted.wordCount,
|
|
1515
|
+
}), body);
|
|
1184
1516
|
}
|
|
1185
1517
|
const extracted = await pdfParser.extractText(content.body, { mergePages: true });
|
|
1186
1518
|
const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
|
|
@@ -1189,17 +1521,15 @@ async function resolveUnpaywall(args, service, ctx) {
|
|
|
1189
1521
|
unavailable: { reason: 'parse-failed', detail: 'PDF extraction produced empty text' },
|
|
1190
1522
|
};
|
|
1191
1523
|
}
|
|
1192
|
-
return {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
}),
|
|
1202
|
-
};
|
|
1524
|
+
return budgeted((body) => buildUnpaywallArticle({
|
|
1525
|
+
...requestedIds,
|
|
1526
|
+
doi,
|
|
1527
|
+
sourceUrl: content.fetchedUrl,
|
|
1528
|
+
location: resolution.location,
|
|
1529
|
+
contentFormat: 'pdf-text',
|
|
1530
|
+
content: body,
|
|
1531
|
+
totalPages: extracted.totalPages,
|
|
1532
|
+
}), text);
|
|
1203
1533
|
}
|
|
1204
1534
|
catch (error) {
|
|
1205
1535
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -1316,7 +1646,34 @@ function reasonFromChain(chain) {
|
|
|
1316
1646
|
}
|
|
1317
1647
|
}
|
|
1318
1648
|
// ─── format() helpers ────────────────────────────────────────────────────────
|
|
1319
|
-
|
|
1649
|
+
/**
|
|
1650
|
+
* Render the response-level character accounting. Every field is rendered
|
|
1651
|
+
* unconditionally so `content[]` readers see the same budget detail
|
|
1652
|
+
* `structuredContent` readers get. Counts are printed raw — no thousands
|
|
1653
|
+
* separators — so the numbers stay greppable. (#81)
|
|
1654
|
+
*/
|
|
1655
|
+
function formatTruncation(t, lines) {
|
|
1656
|
+
lines.push(`\n**Truncated (${t.mode} mode):** ${t.returnedCharacters} of ${t.originalCharacters} body characters returned across ${t.articles.length} article(s); ${t.omittedSections} section(s) omitted`);
|
|
1657
|
+
const budgets = [
|
|
1658
|
+
t.maxCharacters === undefined ? undefined : `maxCharacters ${t.maxCharacters}`,
|
|
1659
|
+
t.maxCharactersPerSection === undefined
|
|
1660
|
+
? undefined
|
|
1661
|
+
: `maxCharactersPerSection ${t.maxCharactersPerSection}`,
|
|
1662
|
+
].filter((b) => b !== undefined);
|
|
1663
|
+
if (budgets.length)
|
|
1664
|
+
lines.push(`Budget applied: ${budgets.join(', ')}`);
|
|
1665
|
+
for (const a of t.articles) {
|
|
1666
|
+
lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters`);
|
|
1667
|
+
for (const s of a.sections ?? []) {
|
|
1668
|
+
lines.push(` - ${s.title ?? 'untitled section'} — ${s.returnedCharacters} of ${s.originalCharacters} characters (truncated: ${s.truncated})`);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/** Per-article inline marker so a reader of one article's body knows it is partial. */
|
|
1673
|
+
function truncationNote(t) {
|
|
1674
|
+
return `\n> Body shortened to fit the requested character budget — ${t.returnedCharacters} of ${t.originalCharacters} characters returned. See \`truncation\` for per-section counts.`;
|
|
1675
|
+
}
|
|
1676
|
+
function formatPmcArticle(a, lines, truncation) {
|
|
1320
1677
|
lines.push(`### ${a.title ?? a.pmcId}`);
|
|
1321
1678
|
const sourceLabel = a.viaSource === 'europepmc'
|
|
1322
1679
|
? `Europe PMC (structured JATS${a.epmcSource ? `, source: ${a.epmcSource}` : ''})`
|
|
@@ -1367,6 +1724,8 @@ function formatPmcArticle(a, lines) {
|
|
|
1367
1724
|
lines.push(`**PubMed:** ${a.pubmedUrl}`);
|
|
1368
1725
|
if (a.keywords?.length)
|
|
1369
1726
|
lines.push(`**Keywords:** ${a.keywords.join(', ')}`);
|
|
1727
|
+
if (truncation)
|
|
1728
|
+
lines.push(truncationNote(truncation));
|
|
1370
1729
|
if (a.abstract)
|
|
1371
1730
|
lines.push(`\n#### Abstract\n${a.abstract}`);
|
|
1372
1731
|
for (const sec of a.sections) {
|
|
@@ -1391,7 +1750,7 @@ function formatPmcArticle(a, lines) {
|
|
|
1391
1750
|
}
|
|
1392
1751
|
}
|
|
1393
1752
|
}
|
|
1394
|
-
function formatUnpaywallArticle(a, lines) {
|
|
1753
|
+
function formatUnpaywallArticle(a, lines, truncation) {
|
|
1395
1754
|
const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
|
|
1396
1755
|
const heading = a.title ?? requestedId;
|
|
1397
1756
|
const formatLabel = a.contentFormat === 'html-markdown'
|
|
@@ -1418,6 +1777,8 @@ function formatUnpaywallArticle(a, lines) {
|
|
|
1418
1777
|
if (a.totalPages !== undefined)
|
|
1419
1778
|
lines.push(`**Pages:** ${a.totalPages}`);
|
|
1420
1779
|
lines.push(`\n> Section structure is not guaranteed for this source. Treat the content as best-effort raw text. OA location metadata courtesy of Unpaywall (https://unpaywall.org).`);
|
|
1780
|
+
if (truncation)
|
|
1781
|
+
lines.push(truncationNote(truncation));
|
|
1421
1782
|
lines.push(`\n#### Full Text\n${a.content}`);
|
|
1422
1783
|
}
|
|
1423
1784
|
function formatPmcAuthor(au) {
|