@cyanheads/pubmed-mcp-server 2.10.9 → 2.10.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +4 -3
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +17 -0
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +297 -62
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +8 -0
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +131 -23
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +14 -3
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +15 -3
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +15 -3
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +22 -2
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +24 -2
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +24 -4
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +498 -56
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +43 -0
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts +13 -4
- package/dist/services/openalex/api-client.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +19 -8
- package/dist/services/openalex/api-client.js.map +1 -1
- package/dist/services/openalex/openalex-service.d.ts +34 -17
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +119 -35
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/types.d.ts +34 -1
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +20 -0
- package/dist/services/openalex/types.js.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -152,18 +152,99 @@ function applyTableFilters(article, filters) {
|
|
|
152
152
|
return withTables(article, article.tables.filter((t) => t.sectionTitle !== undefined && surviving.has(t.sectionTitle)));
|
|
153
153
|
}
|
|
154
154
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
155
|
+
* Replace an article's asset list, dropping the field entirely when nothing is
|
|
156
|
+
* left — the same rule {@link withTables} follows, and for the same reason: an
|
|
157
|
+
* empty array claims the article deposits no figures, which a filtered-to-nothing
|
|
158
|
+
* list does not mean.
|
|
159
|
+
*/
|
|
160
|
+
function withAssets(article, assets) {
|
|
161
|
+
const { assets: _replaced, ...rest } = article;
|
|
162
|
+
return (assets.length > 0 ? { ...rest, assets } : rest);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The positional marker the parser leaves in section text where an asset was
|
|
166
|
+
* lifted out — `[Figure: Fig. 1]`, `[Supplementary: Table S3]`, or the bare
|
|
167
|
+
* `[Figure]` / `[Supplementary]` when the deposit carries no label.
|
|
168
|
+
*
|
|
169
|
+
* Mirrors `assetMarker` in `pmc-article-parser.ts`, which is the only producer.
|
|
170
|
+
* The two must stay byte-identical: this is what {@link stripAssetMarkers}
|
|
171
|
+
* removes, and a marker built any other way would leave the real one in place.
|
|
172
|
+
* Derived per asset rather than matched as a pattern, so prose that happens to
|
|
173
|
+
* carry a bracketed word is never touched. (#130)
|
|
174
|
+
*/
|
|
175
|
+
function assetMarkerText(asset) {
|
|
176
|
+
const kind = asset.assetType === 'figure' ? 'Figure' : 'Supplementary';
|
|
177
|
+
return asset.label ? `[${kind}: ${asset.label}]` : `[${kind}]`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Remove every marker in `markers` from one text field and close the gap it
|
|
181
|
+
* leaves: trailing whitespace on a line the marker ended, and the blank line a
|
|
182
|
+
* marker that stood alone as its own block leaves behind.
|
|
183
|
+
*/
|
|
184
|
+
function stripAssetMarkers(text, markers) {
|
|
185
|
+
if (!text)
|
|
186
|
+
return text;
|
|
187
|
+
let out = text;
|
|
188
|
+
for (const marker of markers)
|
|
189
|
+
out = out.split(marker).join('');
|
|
190
|
+
if (out === text)
|
|
191
|
+
return text;
|
|
192
|
+
return out
|
|
193
|
+
.split('\n')
|
|
194
|
+
.map((line) => line.replace(/[ \t]+$/, ''))
|
|
195
|
+
.join('\n')
|
|
196
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
197
|
+
.trim();
|
|
198
|
+
}
|
|
199
|
+
/** Strip asset markers from a section and every subsection beneath it. */
|
|
200
|
+
function sectionWithoutMarkers(section, markers) {
|
|
201
|
+
const text = stripAssetMarkers(section.text, markers);
|
|
202
|
+
const subsections = section.subsections?.map((sub) => sectionWithoutMarkers(sub, markers));
|
|
203
|
+
if (text === section.text && subsections === undefined)
|
|
204
|
+
return section;
|
|
205
|
+
return { ...section, text, ...(subsections && { subsections }) };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Narrow the asset list to what the request asked for, mirroring {@link
|
|
209
|
+
* applyTableFilters}: `includeAssets: false` is the wholesale off switch, and an
|
|
210
|
+
* active `sections` filter keeps an asset whose named section survived while
|
|
211
|
+
* dropping one that names no section at all — a `<floats-group>` deposit — since
|
|
212
|
+
* the caller asked for named headings and it belongs to none.
|
|
213
|
+
*
|
|
214
|
+
* Where it goes beyond the table counterpart: turning assets off also removes the
|
|
215
|
+
* positional markers the parser left in the section text. The marker is the
|
|
216
|
+
* lift's anchor, and without the array to resolve it against it points at
|
|
217
|
+
* nothing. Prose-shaped blocks — lists, quotes, formulae — are section text
|
|
218
|
+
* rather than assets and this switch never touches them. (#130)
|
|
219
|
+
*/
|
|
220
|
+
function applyAssetFilters(article, filters) {
|
|
221
|
+
const assets = article.assets;
|
|
222
|
+
if (!assets?.length)
|
|
223
|
+
return article;
|
|
224
|
+
if (!filters.includeAssets) {
|
|
225
|
+
const markers = assets.map(assetMarkerText);
|
|
226
|
+
return withAssets({ ...article, sections: article.sections.map((s) => sectionWithoutMarkers(s, markers)) }, []);
|
|
227
|
+
}
|
|
228
|
+
if (!filters.sections?.length)
|
|
229
|
+
return article;
|
|
230
|
+
const surviving = matchedSectionTitles(article.sections, filters.sections.map(lowerCase));
|
|
231
|
+
return withAssets(article, assets.filter((a) => a.sectionTitle !== undefined && surviving.has(a.sectionTitle)));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Apply the requested section/reference/table/asset filters, then clamp the
|
|
235
|
+
* section tree to the depth the output schema carries. All of it runs here so every path
|
|
157
236
|
* producing a `pmc` article — PMC EFetch and the Europe PMC stage — shares one
|
|
158
237
|
* shape, and the budget helpers downstream count the text that will actually
|
|
159
238
|
* survive validation. (#112)
|
|
160
239
|
*
|
|
161
|
-
* Order is load-bearing. Tables are matched against the section tree
|
|
162
|
-
* `filterSections` and the `maxSections` slice, so they narrow with
|
|
163
|
-
* the response returns, and *before* `clampSectionDepth`, which
|
|
164
|
-
* past {@link MAX_SECTION_DEPTH} into a parent's text — their
|
|
165
|
-
* the output while a table still names them, so a
|
|
166
|
-
* clamp would drop
|
|
240
|
+
* Order is load-bearing. Tables and assets are matched against the section tree
|
|
241
|
+
* *after* `filterSections` and the `maxSections` slice, so they narrow with
|
|
242
|
+
* exactly what the response returns, and *before* `clampSectionDepth`, which
|
|
243
|
+
* folds sections past {@link MAX_SECTION_DEPTH} into a parent's text — their
|
|
244
|
+
* titles vanish from the output while a table or figure still names them, so a
|
|
245
|
+
* title set built after the clamp would drop entries that should have survived.
|
|
246
|
+
* Stripping the asset markers also has to precede the clamp, or a marker in a
|
|
247
|
+
* folded-away section survives in the text it was folded into. (#111, #130)
|
|
167
248
|
*/
|
|
168
249
|
function applyPmcFilters(article, filters) {
|
|
169
250
|
let out = article;
|
|
@@ -178,6 +259,7 @@ function applyPmcFilters(article, filters) {
|
|
|
178
259
|
out = rest;
|
|
179
260
|
}
|
|
180
261
|
out = applyTableFilters(out, filters);
|
|
262
|
+
out = applyAssetFilters(out, filters);
|
|
181
263
|
return { ...out, sections: clampSectionDepth(out.sections) };
|
|
182
264
|
}
|
|
183
265
|
/**
|
|
@@ -338,6 +420,44 @@ const TableSchema = z
|
|
|
338
420
|
.describe('Why `rows` is empty — set only then. graphic-only: the table was deposited as an image with no underlying markup. cals-tgroup: the table uses the CALS `<tgroup>` model, which this server does not extract (0 of 283 tables in an open-access survey used it). no-rows: the markup carried no rows. The label and caption are still returned, so a table that could not be read is visible rather than silently missing.'),
|
|
339
421
|
})
|
|
340
422
|
.describe('One table from the article, with its cells, caption, and owning section');
|
|
423
|
+
/**
|
|
424
|
+
* One `<fig>` or `<supplementary-material>`, hung off the article beside
|
|
425
|
+
* {@link TableSchema} and for the same reason: a sixth of them sit in
|
|
426
|
+
* `<floats-group>`, `<back>` or an appendix with no `<sec>` to attach to, so an
|
|
427
|
+
* asset inside a section names it in {@link sectionTitle} rather than being
|
|
428
|
+
* placed by position.
|
|
429
|
+
*
|
|
430
|
+
* Scalar leaves only. `articles[]` → the article union → `assets[]` → a leaf is
|
|
431
|
+
* six of the eight hops the `format-parity` sentinel walker allows; a nested
|
|
432
|
+
* object here (a `files[]` list, say) spends the remaining two and leaves nothing
|
|
433
|
+
* for a later field. Re-run `bun run lint:mcp` after any change to this shape.
|
|
434
|
+
*
|
|
435
|
+
* There is no media-type field and no per-asset unextractable reason: `mimetype`
|
|
436
|
+
* appears on no observed deposit, and an uncaptioned supplement is still fully
|
|
437
|
+
* described by its `id` and `href`, unlike a table that promises cells and
|
|
438
|
+
* carries none. (#130)
|
|
439
|
+
*/
|
|
440
|
+
const AssetSchema = z
|
|
441
|
+
.object({
|
|
442
|
+
assetType: z
|
|
443
|
+
.enum(['figure', 'supplementary-material'])
|
|
444
|
+
.describe('Which captioned element this came from — `figure` for a `<fig>`, `supplementary-material` for a `<supplementary-material>` deposit'),
|
|
445
|
+
label: z.string().optional().describe('Display label as printed, e.g. `Fig. 1`'),
|
|
446
|
+
caption: z.string().optional().describe('Caption text, with the label excluded'),
|
|
447
|
+
id: z
|
|
448
|
+
.string()
|
|
449
|
+
.optional()
|
|
450
|
+
.describe('JATS `id` attribute — the target body-text cross-references point at'),
|
|
451
|
+
sectionTitle: z
|
|
452
|
+
.string()
|
|
453
|
+
.optional()
|
|
454
|
+
.describe('Title of the innermost section enclosing the asset, wherever that section sits — body, `<back>` matter, or an appendix all count. Absent for an asset inside no section at all, such as a `<floats-group>` deposit.'),
|
|
455
|
+
href: z
|
|
456
|
+
.string()
|
|
457
|
+
.optional()
|
|
458
|
+
.describe('The `<graphic>`/`<media>` `@xlink:href` exactly as deposited — a pointer into the PMC deposit (`MOL2-20-1253-g001.jpg`), not a fetchable URL. No absolute form of it resolves; read the rendered article at `pmcUrl` instead. Absent when the deposit names no file.'),
|
|
459
|
+
})
|
|
460
|
+
.describe('One figure or supplementary-material item, with its caption, pointer, and section');
|
|
341
461
|
const PublicationDateSchema = z
|
|
342
462
|
.object({
|
|
343
463
|
year: z.string().optional().describe('Publication year'),
|
|
@@ -377,6 +497,10 @@ const PmcArticleSchema = z
|
|
|
377
497
|
.array(TableSchema)
|
|
378
498
|
.optional()
|
|
379
499
|
.describe('Every `<table-wrap>` the article carries, in document order — from the body and from `<floats-group>`, `<back>` and appendices alike. Absent when the article deposits none, when `includeTables` is false, or when a `sections` filter left none standing.'),
|
|
500
|
+
assets: z
|
|
501
|
+
.array(AssetSchema)
|
|
502
|
+
.optional()
|
|
503
|
+
.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 the body 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.'),
|
|
380
504
|
references: z.array(ReferenceSchema).optional().describe('Reference list'),
|
|
381
505
|
epmcId: z
|
|
382
506
|
.string()
|
|
@@ -441,12 +565,13 @@ const UnavailableReasonSchema = z
|
|
|
441
565
|
'no-epmc-fulltext',
|
|
442
566
|
'no-body',
|
|
443
567
|
'no-doi',
|
|
568
|
+
'doi-lookup-failed',
|
|
444
569
|
'no-oa',
|
|
445
570
|
'fetch-failed',
|
|
446
571
|
'parse-failed',
|
|
447
572
|
'service-error',
|
|
448
573
|
])
|
|
449
|
-
.describe('Why no full text was returned — the most specific signal any tier that answered reported. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi:
|
|
574
|
+
.describe('Why no full text was returned — the most specific signal any tier that answered reported. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi: the DOI lookup ran and this record has none, so Unpaywall could not be queried. doi-lookup-failed: the DOI lookup itself errored, so whether a DOI exists is unknown and Unpaywall was never reached — retry the request; unlike no-doi this is a transient failure, not a settled answer. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data). A reason never means the chain ran to completion — read `unqueriedTiers` for that.');
|
|
450
575
|
const UnqueriedTierSchema = z
|
|
451
576
|
.enum(['europepmc', 'unpaywall'])
|
|
452
577
|
.describe('A fallback tier this deployment has not configured');
|
|
@@ -457,12 +582,13 @@ const TierOutcomeSchema = z
|
|
|
457
582
|
'no-fulltext',
|
|
458
583
|
'no-body',
|
|
459
584
|
'no-doi',
|
|
585
|
+
'doi-lookup-failed',
|
|
460
586
|
'no-oa',
|
|
461
587
|
'fetch-failed',
|
|
462
588
|
'parse-failed',
|
|
463
589
|
'service-error',
|
|
464
590
|
])
|
|
465
|
-
.describe('Per-tier outcome. not-attempted: tier was skipped. miss: tier returned no record. no-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the tier returned a record with front matter and abstract but no body sections, so the chain continued. no-doi:
|
|
591
|
+
.describe('Per-tier outcome. not-attempted: tier was skipped. miss: tier returned no record. no-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the tier returned a record with front matter and abstract but no body sections, so the chain continued. no-doi: the DOI lookup ran and this record has none, so Unpaywall could not be queried. doi-lookup-failed: the DOI lookup itself errored, so whether a DOI exists is unknown and Unpaywall was never reached — retry the request. no-oa: Unpaywall reports no open-access copy. fetch-failed: OA copy download failed. parse-failed: extraction produced empty content. service-error: tier service threw.');
|
|
466
592
|
const TriedTierSchema = z
|
|
467
593
|
.object({
|
|
468
594
|
tier: z.enum(['pmc', 'europepmc', 'unpaywall']).describe('Which tier in the resolution chain'),
|
|
@@ -525,6 +651,14 @@ const TruncatedArticleSchema = z
|
|
|
525
651
|
.array(z.string())
|
|
526
652
|
.optional()
|
|
527
653
|
.describe("The dropped tables by name, in document order — each table's label, else its `id`, else `table <n>` for its position in the article. Names the tables a bare count only hints at, the way `deferred.ids` names deferred articles. Every table from the first that did not fit onward is here: admission stops at that table rather than skipping ahead to a smaller one, so these are contiguous. Absent when none were dropped."),
|
|
654
|
+
omittedAssets: z
|
|
655
|
+
.number()
|
|
656
|
+
.optional()
|
|
657
|
+
.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.'),
|
|
658
|
+
omittedAssetNames: z
|
|
659
|
+
.array(z.string())
|
|
660
|
+
.optional()
|
|
661
|
+
.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."),
|
|
528
662
|
})
|
|
529
663
|
.describe('Character accounting for one article the budget shortened');
|
|
530
664
|
const TruncationSchema = z
|
|
@@ -550,6 +684,10 @@ const TruncationSchema = z
|
|
|
550
684
|
.number()
|
|
551
685
|
.optional()
|
|
552
686
|
.describe('Tables dropped whole across every budgeted article, because the budget left no room once body sections were served. Absent when none were dropped. Re-request the affected articles with a higher `maxCharacters`, or with `sections` narrowed, to receive them.'),
|
|
687
|
+
omittedAssets: z
|
|
688
|
+
.number()
|
|
689
|
+
.optional()
|
|
690
|
+
.describe('Figures and supplementary items dropped whole across every budgeted article, because the budget left no room once body sections and tables were served. Absent when none were dropped. Re-request the affected articles with a higher `maxCharacters`, or with `sections` narrowed, to receive them.'),
|
|
553
691
|
articles: z
|
|
554
692
|
.array(TruncatedArticleSchema)
|
|
555
693
|
.describe('Per-article accounting, covering only the articles the budget shortened'),
|
|
@@ -609,34 +747,37 @@ function tableCharacters(table) {
|
|
|
609
747
|
return totalLength([table.label, table.caption, table.footnotes, ...table.rows.flat()]);
|
|
610
748
|
}
|
|
611
749
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
750
|
+
* Characters an asset costs the budget: the text it carries — label, caption,
|
|
751
|
+
* and the `href` pointer. Positional metadata is excluded, exactly as {@link
|
|
752
|
+
* tableCharacters} excludes `sectionTitle` and `id`: it places the asset rather
|
|
753
|
+
* than being content the caller asked for. An asset is admitted or dropped
|
|
754
|
+
* whole — a caption cut in half is a caption that says something else — so there
|
|
755
|
+
* is no partial measure to take. (#130)
|
|
756
|
+
*/
|
|
757
|
+
function assetCharacters(asset) {
|
|
758
|
+
return totalLength([asset.label, asset.caption, asset.href]);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Admit tables, then assets, in document order until the allowance is spent,
|
|
762
|
+
* then drop the rest whole and name them. The split is {@link fitWholeItems}'s
|
|
763
|
+
* prefix cut, the same one `maxResponseCharacters` applies to whole articles.
|
|
614
764
|
*
|
|
615
|
-
* Admission stops at the first
|
|
765
|
+
* Admission stops at the first entry that does not fit rather than skipping past
|
|
616
766
|
* it to a smaller one further down: the returned set stays a document-order
|
|
617
767
|
* prefix, so a caller reading it knows where the response stopped instead of
|
|
618
|
-
* receiving a late
|
|
619
|
-
* that does not fit is never cut either — half a grid reads as a complete
|
|
768
|
+
* receiving a late entry with nothing saying the earlier ones exist. An entry
|
|
769
|
+
* that does not fit is never cut either — half a grid reads as a complete table
|
|
620
770
|
* carrying values that were never deposited, the defect the table extraction
|
|
621
|
-
* exists to fix
|
|
622
|
-
*
|
|
771
|
+
* exists to fix, and half a caption says something the deposit does not.
|
|
772
|
+
* (#111, #130)
|
|
623
773
|
*/
|
|
624
|
-
function
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
kept,
|
|
632
|
-
omittedNames: tables.slice(index).map((t, i) => tableDisplayName(t, index + i)),
|
|
633
|
-
spent,
|
|
634
|
-
};
|
|
635
|
-
}
|
|
636
|
-
kept.push(table);
|
|
637
|
-
spent += size;
|
|
638
|
-
}
|
|
639
|
-
return { kept, omittedNames: [], spent };
|
|
774
|
+
function fitWholeNamed(items, allowance, measure, name) {
|
|
775
|
+
const fit = fitWholeItems(items, allowance, measure);
|
|
776
|
+
return {
|
|
777
|
+
kept: fit.kept,
|
|
778
|
+
omittedNames: fit.deferred.map((item, i) => name(item, fit.kept.length + i)),
|
|
779
|
+
spent: fit.keptCharacters,
|
|
780
|
+
};
|
|
640
781
|
}
|
|
641
782
|
/**
|
|
642
783
|
* Rebuild a section subtree from `fitted`, consuming one entry per node in the
|
|
@@ -728,11 +869,11 @@ function allotSectionBudgets(sizes, budget) {
|
|
|
728
869
|
* or cut — the budget spends on body text and table content, keeping every
|
|
729
870
|
* article citable.
|
|
730
871
|
*
|
|
731
|
-
* Body sections are served first
|
|
732
|
-
* in document order:
|
|
733
|
-
* never truncated into a partial grid
|
|
734
|
-
* `
|
|
735
|
-
* kept. (#111)
|
|
872
|
+
* Body sections are served first, then tables, then assets, each spending what
|
|
873
|
+
* `maxCharacters` has left, in document order: an item that does not fit is
|
|
874
|
+
* dropped whole and counted, never truncated into a partial grid or a caption cut
|
|
875
|
+
* short. With no `maxCharacters` — a bare `maxCharactersPerSection` request —
|
|
876
|
+
* nothing bounds either list and every entry is kept. (#111, #130)
|
|
736
877
|
*
|
|
737
878
|
* Returns the article untouched (same object identity) when no budget was
|
|
738
879
|
* requested or nothing exceeded it. A section left with zero characters is
|
|
@@ -742,12 +883,15 @@ function allotSectionBudgets(sizes, budget) {
|
|
|
742
883
|
*/
|
|
743
884
|
function applyPmcBudget(article, budget) {
|
|
744
885
|
const tables = article.tables ?? [];
|
|
745
|
-
|
|
886
|
+
const assets = article.assets ?? [];
|
|
887
|
+
if (!budgetRequested(budget) ||
|
|
888
|
+
(article.sections.length === 0 && tables.length === 0 && assets.length === 0)) {
|
|
746
889
|
return { article, omittedSections: 0 };
|
|
747
890
|
}
|
|
748
891
|
const sizes = article.sections.map(sectionCharacters);
|
|
749
892
|
const tablesOriginal = tables.reduce((sum, table) => sum + tableCharacters(table), 0);
|
|
750
|
-
const
|
|
893
|
+
const assetsOriginal = assets.reduce((sum, asset) => sum + assetCharacters(asset), 0);
|
|
894
|
+
const originalCharacters = sizes.reduce((sum, size) => sum + size, 0) + tablesOriginal + assetsOriginal;
|
|
751
895
|
const allowances = allotSectionBudgets(sizes, budget);
|
|
752
896
|
const kept = [];
|
|
753
897
|
const sectionReports = [];
|
|
@@ -770,19 +914,26 @@ function applyPmcBudget(article, budget) {
|
|
|
770
914
|
}
|
|
771
915
|
kept.push(withFittedTexts(section, fitted, { i: 0 }));
|
|
772
916
|
});
|
|
773
|
-
// Sections are served first
|
|
774
|
-
// left. A bare per-section budget sets no total, so nothing
|
|
775
|
-
|
|
917
|
+
// Sections are served first, then tables, then assets — each spending whatever
|
|
918
|
+
// `maxCharacters` has left. A bare per-section budget sets no total, so nothing
|
|
919
|
+
// bounds either list.
|
|
920
|
+
const remainingAllowance = () => budget.maxCharacters === undefined
|
|
776
921
|
? Number.POSITIVE_INFINITY
|
|
777
922
|
: Math.max(budget.maxCharacters - returnedCharacters, 0);
|
|
778
|
-
const fittedTables =
|
|
923
|
+
const fittedTables = fitWholeNamed(tables, remainingAllowance(), tableCharacters, tableDisplayName);
|
|
779
924
|
returnedCharacters += fittedTables.spent;
|
|
780
925
|
const omittedTables = fittedTables.omittedNames.length;
|
|
781
|
-
|
|
926
|
+
const fittedAssets = fitWholeNamed(assets, remainingAllowance(), assetCharacters, assetDisplayName);
|
|
927
|
+
returnedCharacters += fittedAssets.spent;
|
|
928
|
+
const omittedAssets = fittedAssets.omittedNames.length;
|
|
929
|
+
if (returnedCharacters === originalCharacters &&
|
|
930
|
+
omittedSections === 0 &&
|
|
931
|
+
omittedTables === 0 &&
|
|
932
|
+
omittedAssets === 0) {
|
|
782
933
|
return { article, omittedSections: 0 };
|
|
783
934
|
}
|
|
784
935
|
return {
|
|
785
|
-
article: withTables({ ...article, sections: kept }, fittedTables.kept),
|
|
936
|
+
article: withAssets(withTables({ ...article, sections: kept }, fittedTables.kept), fittedAssets.kept),
|
|
786
937
|
omittedSections,
|
|
787
938
|
truncation: {
|
|
788
939
|
originalCharacters,
|
|
@@ -792,6 +943,10 @@ function applyPmcBudget(article, budget) {
|
|
|
792
943
|
omittedTables,
|
|
793
944
|
omittedTableNames: fittedTables.omittedNames,
|
|
794
945
|
}),
|
|
946
|
+
...(omittedAssets > 0 && {
|
|
947
|
+
omittedAssets,
|
|
948
|
+
omittedAssetNames: fittedAssets.omittedNames,
|
|
949
|
+
}),
|
|
795
950
|
},
|
|
796
951
|
};
|
|
797
952
|
}
|
|
@@ -821,6 +976,10 @@ const UNEXTRACTABLE_TABLE_EXPLANATIONS = {
|
|
|
821
976
|
function tableDisplayName(table, index) {
|
|
822
977
|
return table.label ?? table.id ?? `table ${index + 1}`;
|
|
823
978
|
}
|
|
979
|
+
/** How an asset is named in a notice: its label, else its id, else its position. */
|
|
980
|
+
function assetDisplayName(asset, index) {
|
|
981
|
+
return asset.label ?? asset.id ?? `asset ${index + 1}`;
|
|
982
|
+
}
|
|
824
983
|
/**
|
|
825
984
|
* Collect the returned tables that carry no cell values. Read off the articles
|
|
826
985
|
* the response actually ships — after every filter and both budgets — so a table
|
|
@@ -884,13 +1043,19 @@ function buildTruncationNotice(truncation) {
|
|
|
884
1043
|
const omittedTables = truncation.omittedTables
|
|
885
1044
|
? ` ${truncation.omittedTables} table(s) were dropped whole rather than cut mid-row: ${droppedNames.join(', ')}.`
|
|
886
1045
|
: '';
|
|
1046
|
+
// Assets are admitted last and only whole — a caption cut in half says
|
|
1047
|
+
// something the deposit does not — so name them on the same terms.
|
|
1048
|
+
const droppedAssetNames = truncation.articles.flatMap((a) => a.omittedAssetNames ?? []);
|
|
1049
|
+
const omittedAssets = truncation.omittedAssets
|
|
1050
|
+
? ` ${truncation.omittedAssets} figure/supplementary item(s) were dropped whole rather than returned with a shortened caption: ${droppedAssetNames.join(', ')}.`
|
|
1051
|
+
: '';
|
|
887
1052
|
// Name only the budgets the request actually set — pointing at `maxCharacters`
|
|
888
1053
|
// when the caller only capped per-section sends them to a knob that is unset.
|
|
889
1054
|
const knobs = [
|
|
890
1055
|
truncation.maxCharacters !== undefined ? '`maxCharacters`' : undefined,
|
|
891
1056
|
truncation.maxCharactersPerSection !== undefined ? '`maxCharactersPerSection`' : undefined,
|
|
892
1057
|
].filter((k) => k !== undefined);
|
|
893
|
-
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} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
|
|
1058
|
+
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.`;
|
|
894
1059
|
}
|
|
895
1060
|
/**
|
|
896
1061
|
* Compose the recovery notice for articles the whole-response budget withheld.
|
|
@@ -996,6 +1161,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
996
1161
|
.boolean()
|
|
997
1162
|
.default(true)
|
|
998
1163
|
.describe("Include the article's tables — cells, captions, labels and footnotes. On by default because a dropped table takes its numbers with it. Table-dense articles pay for it: rendered tables typically add 12–17% to an article record and can more than double it. Set false to omit them, or cap the cost with `maxCharacters`, which drops tables it cannot fit whole. Applies to `source=pmc` results only."),
|
|
1164
|
+
includeAssets: z
|
|
1165
|
+
.boolean()
|
|
1166
|
+
.default(true)
|
|
1167
|
+
.describe("Include the article's figures and supplementary material — `assets[]`, each with its label, caption, enclosing section and deposit pointer. On by default because it is cheaper than tables: a median asset-bearing article grows about 10%, and the body prose already refers to these by label. Set false to omit them, which also removes the `[Figure: …]` / `[Supplementary: …]` markers from the section text, since without the array they point at nothing. Prose-shaped blocks — lists, definition lists, block quotes, boxed text, preformatted blocks, displayed formulae — are section text rather than assets and this switch never affects them. Applies to `source=pmc` results only."),
|
|
999
1168
|
maxSections: z
|
|
1000
1169
|
.number()
|
|
1001
1170
|
.int()
|
|
@@ -1006,14 +1175,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1006
1175
|
sections: z
|
|
1007
1176
|
.array(z.string())
|
|
1008
1177
|
.optional()
|
|
1009
|
-
.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 narrow with the filter: one whose section did not survive, or that names no section, is dropped. Applies to `source=pmc` results only.'),
|
|
1178
|
+
.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. Applies to `source=pmc` results only.'),
|
|
1010
1179
|
maxCharacters: z
|
|
1011
1180
|
.number()
|
|
1012
1181
|
.int()
|
|
1013
1182
|
.min(1)
|
|
1014
1183
|
.max(1_000_000)
|
|
1015
1184
|
.optional()
|
|
1016
|
-
.describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text plus table label, caption, cell and footnote text, or the `source=unpaywall` `content` body
|
|
1185
|
+
.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. 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. Sections are served first, then tables, then assets, each spending what is left, in document order — 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`. 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.'),
|
|
1017
1186
|
maxCharactersPerSection: z
|
|
1018
1187
|
.number()
|
|
1019
1188
|
.int()
|
|
@@ -1405,6 +1574,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1405
1574
|
// ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
|
|
1406
1575
|
const unpaywall = getUnpaywallService();
|
|
1407
1576
|
const fallbackArticles = [];
|
|
1577
|
+
// Detail of a DOI-backfill lookup that threw, per branch. A candidate still
|
|
1578
|
+
// DOI-less after one of these is an unknown, not a settled absence: it
|
|
1579
|
+
// reports `doi-lookup-failed` rather than `no-doi`. (#119)
|
|
1580
|
+
let pmcidDoiLookupFailure;
|
|
1581
|
+
let pmidDoiLookupFailure;
|
|
1582
|
+
/** The Unpaywall tier entry for a candidate that reached the stage with no DOI. */
|
|
1583
|
+
const doilessTierEntry = (failure) => ({
|
|
1584
|
+
tier: 'unpaywall',
|
|
1585
|
+
outcome: failure ? 'doi-lookup-failed' : 'no-doi',
|
|
1586
|
+
...(failure && { detail: failure }),
|
|
1587
|
+
});
|
|
1408
1588
|
// `pmcids` input reaches Unpaywall on the DOI the chain already holds: the
|
|
1409
1589
|
// EPMC stage searches by PMCID and its hit carries one, captured on non-hit
|
|
1410
1590
|
// outcomes too. PMCIDs EPMC never resolved fall back to the PMC ID
|
|
@@ -1447,8 +1627,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1447
1627
|
});
|
|
1448
1628
|
}
|
|
1449
1629
|
catch (error) {
|
|
1630
|
+
pmcidDoiLookupFailure = error instanceof Error ? error.message : String(error);
|
|
1450
1631
|
ctx.log.warning('Failed to resolve PMCID → DOI for the Unpaywall fallback', {
|
|
1451
|
-
error:
|
|
1632
|
+
error: pmcidDoiLookupFailure,
|
|
1452
1633
|
pmcidCount: needDoi.length,
|
|
1453
1634
|
});
|
|
1454
1635
|
}
|
|
@@ -1462,12 +1643,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1462
1643
|
pmcId,
|
|
1463
1644
|
result: candidate.doi
|
|
1464
1645
|
? await resolveUnpaywall({ pmcId, doi: candidate.doi, budget }, unpaywall, ctx)
|
|
1465
|
-
:
|
|
1646
|
+
: undefined,
|
|
1466
1647
|
};
|
|
1467
1648
|
}));
|
|
1468
1649
|
for (const { pmcId, result } of outcomes) {
|
|
1469
1650
|
const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
|
|
1470
|
-
if (
|
|
1651
|
+
if (result === undefined) {
|
|
1652
|
+
chainByInput.get(inputId)?.push(doilessTierEntry(pmcidDoiLookupFailure));
|
|
1653
|
+
}
|
|
1654
|
+
else if ('article' in result) {
|
|
1471
1655
|
fallbackArticles.push(result.article);
|
|
1472
1656
|
inputIdByArticle.set(result.article, inputId);
|
|
1473
1657
|
if (result.truncation)
|
|
@@ -1501,19 +1685,21 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1501
1685
|
});
|
|
1502
1686
|
}
|
|
1503
1687
|
catch (error) {
|
|
1688
|
+
pmidDoiLookupFailure = error instanceof Error ? error.message : String(error);
|
|
1504
1689
|
ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
|
|
1505
|
-
error:
|
|
1690
|
+
error: pmidDoiLookupFailure,
|
|
1506
1691
|
pmidCount: needDoi.length,
|
|
1507
1692
|
});
|
|
1508
1693
|
}
|
|
1509
1694
|
}
|
|
1510
1695
|
if (!unpaywall) {
|
|
1511
|
-
// `fetchPubmedDois` has already run, so
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1696
|
+
// `fetchPubmedDois` has already run, so a candidate with no DOI could
|
|
1697
|
+
// not have reached Unpaywall configured or not — that is `no-doi`, a
|
|
1698
|
+
// real answer, not an incomplete search. Unless the lookup itself
|
|
1699
|
+
// threw, in which case the DOI state is unknown rather than absent.
|
|
1514
1700
|
for (const c of pmidFallbackCandidates) {
|
|
1515
1701
|
if (!c.doi) {
|
|
1516
|
-
chainByInput.get(c.pmid)?.push(
|
|
1702
|
+
chainByInput.get(c.pmid)?.push(doilessTierEntry(pmidDoiLookupFailure));
|
|
1517
1703
|
continue;
|
|
1518
1704
|
}
|
|
1519
1705
|
chainByInput.get(c.pmid)?.push({
|
|
@@ -1529,10 +1715,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1529
1715
|
candidate,
|
|
1530
1716
|
result: candidate.doi
|
|
1531
1717
|
? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi, budget }, unpaywall, ctx)
|
|
1532
|
-
:
|
|
1718
|
+
: undefined,
|
|
1533
1719
|
})));
|
|
1534
1720
|
for (const { candidate, result } of outcomes) {
|
|
1535
|
-
if (
|
|
1721
|
+
if (result === undefined) {
|
|
1722
|
+
chainByInput.get(candidate.pmid)?.push(doilessTierEntry(pmidDoiLookupFailure));
|
|
1723
|
+
}
|
|
1724
|
+
else if ('article' in result) {
|
|
1536
1725
|
fallbackArticles.push(result.article);
|
|
1537
1726
|
inputIdByArticle.set(result.article, candidate.pmid);
|
|
1538
1727
|
if (result.truncation)
|
|
@@ -1651,6 +1840,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1651
1840
|
// stage, so a deferred article's dropped tables leave the roll-up together
|
|
1652
1841
|
// with its entry when the splice above removes it. (#111)
|
|
1653
1842
|
const omittedTables = truncatedArticles.reduce((n, a) => n + (a.omittedTables ?? 0), 0);
|
|
1843
|
+
const omittedAssets = truncatedArticles.reduce((n, a) => n + (a.omittedAssets ?? 0), 0);
|
|
1654
1844
|
// Rolled up only when the budget actually removed characters, so an
|
|
1655
1845
|
// under-budget request returns exactly what it did before the budget
|
|
1656
1846
|
// controls existed. (#81)
|
|
@@ -1665,6 +1855,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1665
1855
|
returnedCharacters: truncatedArticles.reduce((n, a) => n + a.returnedCharacters, 0),
|
|
1666
1856
|
omittedSections,
|
|
1667
1857
|
...(omittedTables > 0 && { omittedTables }),
|
|
1858
|
+
...(omittedAssets > 0 && { omittedAssets }),
|
|
1668
1859
|
articles: truncatedArticles,
|
|
1669
1860
|
}
|
|
1670
1861
|
: undefined;
|
|
@@ -1836,8 +2027,11 @@ async function runEpmcStage(epmc, args) {
|
|
|
1836
2027
|
pmidOutcomes.set(run.c.pmid, run.outcome);
|
|
1837
2028
|
if (run.article)
|
|
1838
2029
|
collectHit(run.c.pmid, { ...run, article: run.article });
|
|
2030
|
+
// A DOI the EPMC hit carried is evidence the next stage needs, whatever the
|
|
2031
|
+
// fetch outcome was — merge it in like the pmcid branch below, so Unpaywall
|
|
2032
|
+
// gets it without a redundant PubMed metadata round-trip. (#119)
|
|
1839
2033
|
else
|
|
1840
|
-
remainingPmid.push(run.c);
|
|
2034
|
+
remainingPmid.push(run.doi && !run.c.doi ? { ...run.c, doi: run.doi } : run.c);
|
|
1841
2035
|
}
|
|
1842
2036
|
for (const run of pmcidResults) {
|
|
1843
2037
|
pmcidOutcomes.set(run.c.normalized, run.outcome);
|
|
@@ -2144,6 +2338,7 @@ function unpaywallReasonToTierOutcome(reason) {
|
|
|
2144
2338
|
switch (reason) {
|
|
2145
2339
|
case 'no-body':
|
|
2146
2340
|
case 'no-doi':
|
|
2341
|
+
case 'doi-lookup-failed':
|
|
2147
2342
|
case 'no-oa':
|
|
2148
2343
|
case 'fetch-failed':
|
|
2149
2344
|
case 'parse-failed':
|
|
@@ -2195,6 +2390,11 @@ function reasonFromChain(chain) {
|
|
|
2195
2390
|
return 'no-body';
|
|
2196
2391
|
case 'unpaywall:no-doi':
|
|
2197
2392
|
return 'no-doi';
|
|
2393
|
+
// Its own case, never folded into the `unpaywall:no-doi` → `not-found`
|
|
2394
|
+
// collapse above: that collapse says record absence is the specific signal,
|
|
2395
|
+
// and a lookup that never answered has established no absence at all. (#119)
|
|
2396
|
+
case 'unpaywall:doi-lookup-failed':
|
|
2397
|
+
return 'doi-lookup-failed';
|
|
2198
2398
|
case 'unpaywall:no-oa':
|
|
2199
2399
|
return 'no-oa';
|
|
2200
2400
|
case 'unpaywall:fetch-failed':
|
|
@@ -2238,7 +2438,8 @@ function formatUnqueriedTiers(tiers, chain) {
|
|
|
2238
2438
|
*/
|
|
2239
2439
|
function formatTruncation(t, lines) {
|
|
2240
2440
|
const tablesOmitted = t.omittedTables === undefined ? '' : `; ${t.omittedTables} table(s) omitted whole`;
|
|
2241
|
-
|
|
2441
|
+
const assetsOmitted = t.omittedAssets === undefined ? '' : `; ${t.omittedAssets} asset(s) omitted whole`;
|
|
2442
|
+
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${tablesOmitted}${assetsOmitted}`);
|
|
2242
2443
|
const budgets = [
|
|
2243
2444
|
t.maxCharacters === undefined ? undefined : `maxCharacters ${t.maxCharacters}`,
|
|
2244
2445
|
t.maxCharactersPerSection === undefined
|
|
@@ -2251,7 +2452,10 @@ function formatTruncation(t, lines) {
|
|
|
2251
2452
|
const tablesDropped = a.omittedTables === undefined
|
|
2252
2453
|
? ''
|
|
2253
2454
|
: `, ${a.omittedTables} table(s) dropped whole: ${(a.omittedTableNames ?? []).join(', ')}`;
|
|
2254
|
-
|
|
2455
|
+
const assetsDropped = a.omittedAssets === undefined
|
|
2456
|
+
? ''
|
|
2457
|
+
: `, ${a.omittedAssets} asset(s) dropped whole: ${(a.omittedAssetNames ?? []).join(', ')}`;
|
|
2458
|
+
lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters${tablesDropped}${assetsDropped}`);
|
|
2255
2459
|
for (const s of a.sections ?? []) {
|
|
2256
2460
|
lines.push(` - ${s.title ?? 'untitled section'} — ${s.returnedCharacters} of ${s.originalCharacters} characters (truncated: ${s.truncated})`);
|
|
2257
2461
|
}
|
|
@@ -2322,6 +2526,8 @@ function formatPmcArticle(a, lines, truncation) {
|
|
|
2322
2526
|
formatSection(sec, lines, 4);
|
|
2323
2527
|
if (a.tables?.length)
|
|
2324
2528
|
formatTables(a.tables, lines);
|
|
2529
|
+
if (a.assets?.length)
|
|
2530
|
+
formatAssets(a.assets, lines);
|
|
2325
2531
|
if (a.references?.length) {
|
|
2326
2532
|
lines.push(`\n#### References (${a.references.length})`);
|
|
2327
2533
|
for (const ref of a.references) {
|
|
@@ -2361,6 +2567,35 @@ function formatTables(tables, lines) {
|
|
|
2361
2567
|
lines.push(`\nFootnotes: ${escapeMarkdownInline(table.footnotes)}`);
|
|
2362
2568
|
}
|
|
2363
2569
|
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Render every figure and supplementary item, so a `content[]` reader gets the
|
|
2572
|
+
* same caption, pointer and placement `structuredContent` carries rather than a
|
|
2573
|
+
* count saying they exist. Each field of `AssetSchema` appears here — that is
|
|
2574
|
+
* what `format-parity` verifies. (#130)
|
|
2575
|
+
*
|
|
2576
|
+
* Label, caption and `href` go through {@link escapeMarkdownInline}: the parser
|
|
2577
|
+
* stores the raw upstream text, and a caption carrying `[`, `*` or a tag-shaped
|
|
2578
|
+
* `<` would otherwise form a link, emphasis or raw HTML at the render boundary.
|
|
2579
|
+
*
|
|
2580
|
+
* The note about `href` is worth its line: the value is a filename inside the PMC
|
|
2581
|
+
* deposit, and an agent that reads it as a URL will spend a request on a 404 for
|
|
2582
|
+
* every figure in the article.
|
|
2583
|
+
*/
|
|
2584
|
+
function formatAssets(assets, lines) {
|
|
2585
|
+
lines.push(`\n#### Assets (${assets.length})`);
|
|
2586
|
+
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.`);
|
|
2587
|
+
for (const asset of assets) {
|
|
2588
|
+
const heading = [asset.label, asset.caption].filter(Boolean).join(' — ');
|
|
2589
|
+
lines.push(`\n##### ${escapeMarkdownInline(heading || 'Asset')}`);
|
|
2590
|
+
const meta = [
|
|
2591
|
+
asset.assetType,
|
|
2592
|
+
asset.sectionTitle ? `Section: ${escapeMarkdownInline(asset.sectionTitle)}` : undefined,
|
|
2593
|
+
asset.id ? `id: ${escapeMarkdownInline(asset.id)}` : undefined,
|
|
2594
|
+
asset.href ? `file: ${escapeMarkdownInline(asset.href)}` : undefined,
|
|
2595
|
+
].filter((part) => part !== undefined);
|
|
2596
|
+
lines.push(`*${meta.join(' · ')}*`);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2364
2599
|
/**
|
|
2365
2600
|
* The meta-line clause describing a table's header rows.
|
|
2366
2601
|
*
|