@cyanheads/pubmed-mcp-server 2.10.7 → 2.10.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -5
- package/CLAUDE.md +9 -5
- package/README.md +4 -3
- package/dist/mcp-server/tools/definitions/_text.d.ts +23 -1
- package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_text.js +25 -1
- package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +18 -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 +501 -51
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +7 -11
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +3 -3
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +8 -16
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +5 -0
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +33 -10
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/ordered-xml-parser-options.d.ts +44 -0
- package/dist/services/ncbi/parsing/ordered-xml-parser-options.d.ts.map +1 -0
- package/dist/services/ncbi/parsing/ordered-xml-parser-options.js +41 -0
- package/dist/services/ncbi/parsing/ordered-xml-parser-options.js.map +1 -0
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +47 -7
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +382 -43
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +26 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +49 -3
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +3 -9
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +6 -29
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +45 -2
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
|
@@ -31,21 +31,144 @@ import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-serv
|
|
|
31
31
|
import { fitWholeItems } from './_budget.js';
|
|
32
32
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
|
|
33
33
|
import { pmidStringSchema } from './_schemas.js';
|
|
34
|
-
import { escapeMarkdownInline, sliceCodeUnits } from './_text.js';
|
|
34
|
+
import { escapeMarkdownInline, escapeMarkdownTableCell, sliceCodeUnits } from './_text.js';
|
|
35
35
|
function normalizePmcId(id) {
|
|
36
36
|
return id.replace(/^PMC/i, '');
|
|
37
37
|
}
|
|
38
38
|
function withPmcPrefix(id) {
|
|
39
39
|
return id.startsWith('PMC') ? id : `PMC${id}`;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/** Case-insensitive substring match of one section heading against the filter. */
|
|
42
|
+
function matchesSectionFilter(title, lowerFilter) {
|
|
43
|
+
const lowered = title?.toLowerCase();
|
|
44
|
+
return lowered !== undefined && lowerFilter.some((f) => lowered.includes(f));
|
|
44
45
|
}
|
|
46
|
+
function lowerCase(s) {
|
|
47
|
+
return s.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Prune a section tree to the branches a `sections` filter selects, matching
|
|
51
|
+
* titles at every nesting depth rather than the top level alone. (#126)
|
|
52
|
+
*
|
|
53
|
+
* A section whose own title matches is returned whole — object identity
|
|
54
|
+
* included, so an unfiltered subtree is never rebuilt. A section kept only
|
|
55
|
+
* because a descendant matched becomes a breadcrumb: its `title` and `label`
|
|
56
|
+
* survive so the match can be placed in the document, its own `text` is cleared
|
|
57
|
+
* because the caller filtered that prose away, and it carries just the matching
|
|
58
|
+
* branch of its subsections. Promoting the match to the top level instead would
|
|
59
|
+
* make `maxSections` — which caps genuine top-level sections — count nested
|
|
60
|
+
* content as top-level.
|
|
61
|
+
*/
|
|
62
|
+
function pruneSections(sections, lowerFilter) {
|
|
63
|
+
const kept = [];
|
|
64
|
+
for (const section of sections) {
|
|
65
|
+
if (matchesSectionFilter(section.title, lowerFilter)) {
|
|
66
|
+
kept.push(section);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const subsections = pruneSections(section.subsections ?? [], lowerFilter);
|
|
70
|
+
if (subsections.length > 0)
|
|
71
|
+
kept.push({ ...section, text: '', subsections });
|
|
72
|
+
}
|
|
73
|
+
return kept;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Titles of the sections a `sections` filter actually selected — a section that
|
|
77
|
+
* matched directly plus everything beneath it. Breadcrumb ancestors are left
|
|
78
|
+
* out: their own text was cleared because the caller did not ask for it, and a
|
|
79
|
+
* table sitting in one was not asked for either. Walks the *pruned* tree, so a
|
|
80
|
+
* top-level section the `maxSections` slice removed contributes nothing.
|
|
81
|
+
*/
|
|
82
|
+
function matchedSectionTitles(sections, lowerFilter) {
|
|
83
|
+
const titles = new Set();
|
|
84
|
+
const walk = (nodes, inherited) => {
|
|
85
|
+
for (const section of nodes) {
|
|
86
|
+
const matched = inherited || matchesSectionFilter(section.title, lowerFilter);
|
|
87
|
+
if (matched && section.title)
|
|
88
|
+
titles.add(section.title);
|
|
89
|
+
walk(section.subsections ?? [], matched);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
walk(sections, false);
|
|
93
|
+
return titles;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Render a section subtree as text blocks, in document order: each section's
|
|
97
|
+
* heading on its own line above its text. Used for the levels past
|
|
98
|
+
* {@link MAX_SECTION_DEPTH}, which have no node of their own to live in. (#112)
|
|
99
|
+
*/
|
|
100
|
+
function flattenSectionText(section) {
|
|
101
|
+
const heading = section.title ? formatHeading(section.label, section.title) : undefined;
|
|
102
|
+
const block = [heading, section.text].filter(Boolean).join('\n');
|
|
103
|
+
return [...(block ? [block] : []), ...(section.subsections ?? []).flatMap(flattenSectionText)];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Clamp a section tree to the depth the output schema declares. A section at the
|
|
107
|
+
* deepest level absorbs its descendants into its own text instead of carrying
|
|
108
|
+
* them as subsections the schema would strip on validation — silently, from both
|
|
109
|
+
* `structuredContent` and `content[]`. Shallower trees pass through untouched.
|
|
110
|
+
* (#112)
|
|
111
|
+
*/
|
|
112
|
+
function clampSectionDepth(sections, depth = 1) {
|
|
113
|
+
return sections.map((section) => {
|
|
114
|
+
const subsections = section.subsections;
|
|
115
|
+
if (!subsections?.length)
|
|
116
|
+
return section;
|
|
117
|
+
if (depth < MAX_SECTION_DEPTH) {
|
|
118
|
+
return { ...section, subsections: clampSectionDepth(subsections, depth + 1) };
|
|
119
|
+
}
|
|
120
|
+
const { subsections: _dropped, ...rest } = section;
|
|
121
|
+
const tail = subsections.flatMap(flattenSectionText);
|
|
122
|
+
return { ...rest, text: [section.text, ...tail].filter(Boolean).join('\n\n') };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Replace an article's table list, dropping the field entirely when nothing is
|
|
127
|
+
* left. An empty array would read as "this article has no tables", which is the
|
|
128
|
+
* one thing an absent field already says and a filtered-to-nothing list does
|
|
129
|
+
* not mean.
|
|
130
|
+
*/
|
|
131
|
+
function withTables(article, tables) {
|
|
132
|
+
const { tables: _replaced, ...rest } = article;
|
|
133
|
+
return (tables.length > 0 ? { ...rest, tables } : rest);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Narrow the table list to what the request asked for. `includeTables: false`
|
|
137
|
+
* is the wholesale off switch. An active `sections` filter narrows tables with
|
|
138
|
+
* it: a table names the section it sat in — body, back matter or appendix
|
|
139
|
+
* alike — so it survives when that section did, and a table that names no
|
|
140
|
+
* section, such as a `<floats-group>` deposit, is dropped because the caller
|
|
141
|
+
* asked for named headings and it belongs to none. With no `sections` filter
|
|
142
|
+
* every table is returned. (#111)
|
|
143
|
+
*/
|
|
144
|
+
function applyTableFilters(article, filters) {
|
|
145
|
+
if (!article.tables?.length)
|
|
146
|
+
return article;
|
|
147
|
+
if (!filters.includeTables)
|
|
148
|
+
return withTables(article, []);
|
|
149
|
+
if (!filters.sections?.length)
|
|
150
|
+
return article;
|
|
151
|
+
const surviving = matchedSectionTitles(article.sections, filters.sections.map(lowerCase));
|
|
152
|
+
return withTables(article, article.tables.filter((t) => t.sectionTitle !== undefined && surviving.has(t.sectionTitle)));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Apply the requested section/reference/table filters, then clamp the section
|
|
156
|
+
* tree to the depth the output schema carries. All of it runs here so every path
|
|
157
|
+
* producing a `pmc` article — PMC EFetch and the Europe PMC stage — shares one
|
|
158
|
+
* shape, and the budget helpers downstream count the text that will actually
|
|
159
|
+
* survive validation. (#112)
|
|
160
|
+
*
|
|
161
|
+
* Order is load-bearing. Tables are matched against the section tree *after*
|
|
162
|
+
* `filterSections` and the `maxSections` slice, so they narrow with exactly what
|
|
163
|
+
* the response returns, and *before* `clampSectionDepth`, which folds sections
|
|
164
|
+
* past {@link MAX_SECTION_DEPTH} into a parent's text — their titles vanish from
|
|
165
|
+
* the output while a table still names them, so a title set built after the
|
|
166
|
+
* clamp would drop tables that should have survived. (#111)
|
|
167
|
+
*/
|
|
45
168
|
function applyPmcFilters(article, filters) {
|
|
46
169
|
let out = article;
|
|
47
170
|
if (filters.sections?.length) {
|
|
48
|
-
out = { ...out, sections:
|
|
171
|
+
out = { ...out, sections: pruneSections(out.sections, filters.sections.map(lowerCase)) };
|
|
49
172
|
}
|
|
50
173
|
if (filters.maxSections !== undefined) {
|
|
51
174
|
out = { ...out, sections: out.sections.slice(0, filters.maxSections) };
|
|
@@ -54,7 +177,8 @@ function applyPmcFilters(article, filters) {
|
|
|
54
177
|
const { references: _, ...rest } = out;
|
|
55
178
|
out = rest;
|
|
56
179
|
}
|
|
57
|
-
|
|
180
|
+
out = applyTableFilters(out, filters);
|
|
181
|
+
return { ...out, sections: clampSectionDepth(out.sections) };
|
|
58
182
|
}
|
|
59
183
|
/**
|
|
60
184
|
* True when a `sections` filter removed every body section from an article that
|
|
@@ -88,11 +212,16 @@ function articleDisplayId(a) {
|
|
|
88
212
|
* Compose the single recovery notice for `sections`-filter misses. Names the
|
|
89
213
|
* requested terms and the affected article id(s) so the agent can distinguish a
|
|
90
214
|
* filtered-empty body from one absent upstream, and points at the recovery. (#80)
|
|
215
|
+
*
|
|
216
|
+
* States the scope the filter actually searches — section and subsection titles
|
|
217
|
+
* at every depth — so a caller who named a nested heading learns the term itself
|
|
218
|
+
* matched nothing, rather than being left to suspect the filter never looked
|
|
219
|
+
* that deep. (#126)
|
|
91
220
|
*/
|
|
92
221
|
function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
|
|
93
222
|
const terms = sectionFilter.join(', ');
|
|
94
223
|
const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
|
|
95
|
-
return `No
|
|
224
|
+
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 body section was filtered out. Retry without \`sections\`, or filter on broader headings such as Introduction, Methods, Results, or Discussion.`;
|
|
96
225
|
}
|
|
97
226
|
/**
|
|
98
227
|
* Compose the recovery notice for identifiers whose only retrievable record was
|
|
@@ -110,11 +239,30 @@ function buildBodylessNotice(affectedIds) {
|
|
|
110
239
|
return `No body text could be retrieved for ${subject} — the full-text source returned front matter and abstract only, and no later tier recovered a copy. See \`triedTiers\` on the \`unavailable\` entry for what each tier reported, and use \`pubmed_fetch_articles\` for the abstract and metadata.`;
|
|
111
240
|
}
|
|
112
241
|
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
|
242
|
+
/**
|
|
243
|
+
* How many `<sec>` levels the output schema carries as structured nodes. JATS
|
|
244
|
+
* nesting is unbounded and the parser recurses without a cap, so the schema is
|
|
245
|
+
* what decides how deep a section survives output validation — anything past it
|
|
246
|
+
* used to be dropped silently from both output surfaces (#112). Sections deeper
|
|
247
|
+
* than this are now flattened into the deepest surviving node's text instead, so
|
|
248
|
+
* no body content is lost at any depth.
|
|
249
|
+
*
|
|
250
|
+
* Two is the ceiling the tool's own contract can verify, not a guess at how deep
|
|
251
|
+
* real records nest: `format-parity`'s sentinel walker stops after 8 schema hops,
|
|
252
|
+
* and `articles[]` → the article union → `sections[]` → `subsections[]` already
|
|
253
|
+
* spends them all. A third `subsections` level puts its own elements out of the
|
|
254
|
+
* walker's reach, so `format()` parity for that subtree would ship unverified.
|
|
255
|
+
* Levels are inlined rather than expressed with `z.lazy()` regardless — a
|
|
256
|
+
* self-referential schema emits `$defs`/`$ref`, which Gemini rejects.
|
|
257
|
+
*/
|
|
258
|
+
const MAX_SECTION_DEPTH = 2;
|
|
113
259
|
const SubsectionSchema = z
|
|
114
260
|
.object({
|
|
115
261
|
title: z.string().optional().describe('Subsection heading'),
|
|
116
262
|
label: z.string().optional().describe('Subsection label'),
|
|
117
|
-
text: z
|
|
263
|
+
text: z
|
|
264
|
+
.string()
|
|
265
|
+
.describe('Subsection body text. Sections nested deeper than this level are folded in here in document order, each heading rendered on its own line above its text.'),
|
|
118
266
|
})
|
|
119
267
|
.describe('Article subsection');
|
|
120
268
|
const SectionSchema = z
|
|
@@ -148,6 +296,48 @@ const ReferenceSchema = z
|
|
|
148
296
|
label: z.string().optional().describe('Reference label'),
|
|
149
297
|
})
|
|
150
298
|
.describe('Reference entry');
|
|
299
|
+
/**
|
|
300
|
+
* One `<table-wrap>`, hung off the article rather than off a section.
|
|
301
|
+
*
|
|
302
|
+
* The article is the only level that can hold every table: roughly a quarter of
|
|
303
|
+
* real `<table-wrap>` elements sit in `<floats-group>`, `<back>`, a
|
|
304
|
+
* `<table-wrap-group>` or an appendix, with no `<sec>` to attach to. A table
|
|
305
|
+
* inside a section names it in {@link sectionTitle} instead of being placed by
|
|
306
|
+
* position.
|
|
307
|
+
*
|
|
308
|
+
* The shape also lands exactly on the `format-parity` sentinel walker's eight-hop
|
|
309
|
+
* budget — `articles[]` → the article union → `tables[]` → `rows[][]` — with no
|
|
310
|
+
* headroom, and a section-hung variant would spend a hop the existing
|
|
311
|
+
* `sections[]` → `subsections[]` chain already needs. Re-run
|
|
312
|
+
* `bun run lint:mcp` after any change here; a level added anywhere inside puts
|
|
313
|
+
* its own leaves out of the walker's reach and ships their `format()` parity
|
|
314
|
+
* unverified. (#111, #112)
|
|
315
|
+
*/
|
|
316
|
+
const TableSchema = z
|
|
317
|
+
.object({
|
|
318
|
+
label: z.string().optional().describe('Table label as printed, e.g. `TABLE 1`'),
|
|
319
|
+
caption: z.string().optional().describe('Caption text, with the label excluded'),
|
|
320
|
+
id: z
|
|
321
|
+
.string()
|
|
322
|
+
.optional()
|
|
323
|
+
.describe('JATS `id` attribute — the target body-text cross-references point at'),
|
|
324
|
+
sectionTitle: z
|
|
325
|
+
.string()
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Title of the innermost section enclosing the table, wherever that section sits — body, `<back>` matter, or an appendix all count, and in back matter the section name is the only positional cue there is. Absent only for a table inside no section at all, such as a `<floats-group>` deposit.'),
|
|
328
|
+
headerRowCount: z
|
|
329
|
+
.number()
|
|
330
|
+
.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.'),
|
|
331
|
+
rows: z
|
|
332
|
+
.array(z.array(z.string()).describe('One row, as cell text by grid column'))
|
|
333
|
+
.describe('Cell text by row, in document order, one entry per grid column. `colspan` and `rowspan` are expanded, so a cell covering several columns or rows repeats its text across each cell it covers and a well-formed table is rectangular — align on position from the left, and read a repeated value as one spanning cell rather than several measurements. Empty when `unextractableReason` is set.'),
|
|
334
|
+
footnotes: z.string().optional().describe('`<table-wrap-foot>` text, flattened to one string'),
|
|
335
|
+
unextractableReason: z
|
|
336
|
+
.enum(['cals-tgroup', 'graphic-only', 'no-rows'])
|
|
337
|
+
.optional()
|
|
338
|
+
.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
|
+
})
|
|
340
|
+
.describe('One table from the article, with its cells, caption, and owning section');
|
|
151
341
|
const PublicationDateSchema = z
|
|
152
342
|
.object({
|
|
153
343
|
year: z.string().optional().describe('Publication year'),
|
|
@@ -183,6 +373,10 @@ const PmcArticleSchema = z
|
|
|
183
373
|
articleType: z.string().optional().describe('Article type'),
|
|
184
374
|
publicationDate: PublicationDateSchema.optional(),
|
|
185
375
|
sections: z.array(SectionSchema).describe('Article body sections'),
|
|
376
|
+
tables: z
|
|
377
|
+
.array(TableSchema)
|
|
378
|
+
.optional()
|
|
379
|
+
.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.'),
|
|
186
380
|
references: z.array(ReferenceSchema).optional().describe('Reference list'),
|
|
187
381
|
epmcId: z
|
|
188
382
|
.string()
|
|
@@ -323,6 +517,14 @@ const TruncatedArticleSchema = z
|
|
|
323
517
|
.array(TruncatedSectionSchema)
|
|
324
518
|
.optional()
|
|
325
519
|
.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.'),
|
|
520
|
+
omittedTables: z
|
|
521
|
+
.number()
|
|
522
|
+
.optional()
|
|
523
|
+
.describe('Tables this article dropped whole because the budget left no room for them. A table is never cut mid-row, so it is either returned complete or counted here. Absent when none were dropped.'),
|
|
524
|
+
omittedTableNames: z
|
|
525
|
+
.array(z.string())
|
|
526
|
+
.optional()
|
|
527
|
+
.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."),
|
|
326
528
|
})
|
|
327
529
|
.describe('Character accounting for one article the budget shortened');
|
|
328
530
|
const TruncationSchema = z
|
|
@@ -344,6 +546,10 @@ const TruncationSchema = z
|
|
|
344
546
|
omittedSections: z
|
|
345
547
|
.number()
|
|
346
548
|
.describe('Body sections dropped entirely because an article budget was exhausted before reaching them. Always 0 in `outline` mode, which keeps every heading.'),
|
|
549
|
+
omittedTables: z
|
|
550
|
+
.number()
|
|
551
|
+
.optional()
|
|
552
|
+
.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.'),
|
|
347
553
|
articles: z
|
|
348
554
|
.array(TruncatedArticleSchema)
|
|
349
555
|
.describe('Per-article accounting, covering only the articles the budget shortened'),
|
|
@@ -376,9 +582,71 @@ const DeferredSchema = z
|
|
|
376
582
|
function budgetRequested(budget) {
|
|
377
583
|
return budget.maxCharacters !== undefined || budget.maxCharactersPerSection !== undefined;
|
|
378
584
|
}
|
|
379
|
-
/**
|
|
585
|
+
/** Every text field in a section subtree, in document order, own text first. */
|
|
586
|
+
function sectionTextFields(section) {
|
|
587
|
+
return [section.text, ...(section.subsections ?? []).flatMap(sectionTextFields)];
|
|
588
|
+
}
|
|
589
|
+
/** Combined length of the strings given, skipping the absent ones. */
|
|
590
|
+
function totalLength(parts) {
|
|
591
|
+
return parts.reduce((n, part) => n + (part?.length ?? 0), 0);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Body characters a section carries — its own text plus every nested
|
|
595
|
+
* subsection's. Measured off {@link sectionTextFields} rather than its own walk,
|
|
596
|
+
* so the count the budget reports as `originalCharacters` is always taken over
|
|
597
|
+
* exactly the fields {@link fitFields} shortens.
|
|
598
|
+
*/
|
|
380
599
|
function sectionCharacters(section) {
|
|
381
|
-
return (
|
|
600
|
+
return totalLength(sectionTextFields(section));
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Characters a table costs the budget: everything it renders — label, caption,
|
|
604
|
+
* every cell, footnotes. The whole figure is what admitting the table spends,
|
|
605
|
+
* and a table is admitted or dropped whole, so there is no partial measure to
|
|
606
|
+
* take. (#111)
|
|
607
|
+
*/
|
|
608
|
+
function tableCharacters(table) {
|
|
609
|
+
return totalLength([table.label, table.caption, table.footnotes, ...table.rows.flat()]);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Admit tables in document order until the allowance is spent, then drop the
|
|
613
|
+
* rest whole and name them.
|
|
614
|
+
*
|
|
615
|
+
* Admission stops at the first table that does not fit rather than skipping past
|
|
616
|
+
* it to a smaller one further down: the returned set stays a document-order
|
|
617
|
+
* prefix, so a caller reading it knows where the response stopped instead of
|
|
618
|
+
* receiving a late table with nothing saying the earlier ones exist. A table
|
|
619
|
+
* that does not fit is never cut either — half a grid reads as a complete one
|
|
620
|
+
* carrying values that were never deposited, the defect the table extraction
|
|
621
|
+
* exists to fix. Both mirror how `maxResponseCharacters` defers a whole article
|
|
622
|
+
* rather than half-populating it. (#111)
|
|
623
|
+
*/
|
|
624
|
+
function fitTables(tables, allowance) {
|
|
625
|
+
const kept = [];
|
|
626
|
+
let spent = 0;
|
|
627
|
+
for (const [index, table] of tables.entries()) {
|
|
628
|
+
const size = tableCharacters(table);
|
|
629
|
+
if (spent + size > allowance) {
|
|
630
|
+
return {
|
|
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 };
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Rebuild a section subtree from `fitted`, consuming one entry per node in the
|
|
643
|
+
* same document order {@link sectionTextFields} produced them. `cursor` walks
|
|
644
|
+
* the flat list across the whole subtree.
|
|
645
|
+
*/
|
|
646
|
+
function withFittedTexts(section, fitted, cursor) {
|
|
647
|
+
const text = fitted[cursor.i++] ?? '';
|
|
648
|
+
const subsections = section.subsections?.map((sub) => withFittedTexts(sub, fitted, cursor));
|
|
649
|
+
return { ...section, text, ...(subsections && { subsections }) };
|
|
382
650
|
}
|
|
383
651
|
/**
|
|
384
652
|
* Shorten an ordered list of text fields so their combined length fits
|
|
@@ -455,9 +723,16 @@ function allotSectionBudgets(sizes, budget) {
|
|
|
455
723
|
/**
|
|
456
724
|
* Apply the character budget to a JATS article's body. Runs as a pure
|
|
457
725
|
* post-processing pass after `applyPmcFilters`, so `sections` / `maxSections` /
|
|
458
|
-
* `includeReferences` and the empty-body signals they feed are
|
|
459
|
-
* Titles, abstracts, identifiers, and references are never counted
|
|
460
|
-
* the budget
|
|
726
|
+
* `includeReferences` / `includeTables` and the empty-body signals they feed are
|
|
727
|
+
* unaffected. Titles, abstracts, identifiers, and references are never counted
|
|
728
|
+
* or cut — the budget spends on body text and table content, keeping every
|
|
729
|
+
* article citable.
|
|
730
|
+
*
|
|
731
|
+
* Body sections are served first and tables spend what `maxCharacters` leaves,
|
|
732
|
+
* in document order: a table that does not fit is dropped whole and counted,
|
|
733
|
+
* never truncated into a partial grid. With no `maxCharacters` — a bare
|
|
734
|
+
* `maxCharactersPerSection` request — nothing bounds the tables and every one is
|
|
735
|
+
* kept. (#111)
|
|
461
736
|
*
|
|
462
737
|
* Returns the article untouched (same object identity) when no budget was
|
|
463
738
|
* requested or nothing exceeded it. A section left with zero characters is
|
|
@@ -466,11 +741,13 @@ function allotSectionBudgets(sizes, budget) {
|
|
|
466
741
|
* caller can see which headings exist. (#81)
|
|
467
742
|
*/
|
|
468
743
|
function applyPmcBudget(article, budget) {
|
|
469
|
-
|
|
744
|
+
const tables = article.tables ?? [];
|
|
745
|
+
if (!budgetRequested(budget) || (article.sections.length === 0 && tables.length === 0)) {
|
|
470
746
|
return { article, omittedSections: 0 };
|
|
471
747
|
}
|
|
472
748
|
const sizes = article.sections.map(sectionCharacters);
|
|
473
|
-
const
|
|
749
|
+
const tablesOriginal = tables.reduce((sum, table) => sum + tableCharacters(table), 0);
|
|
750
|
+
const originalCharacters = sizes.reduce((sum, size) => sum + size, 0) + tablesOriginal;
|
|
474
751
|
const allowances = allotSectionBudgets(sizes, budget);
|
|
475
752
|
const kept = [];
|
|
476
753
|
const sectionReports = [];
|
|
@@ -478,8 +755,8 @@ function applyPmcBudget(article, budget) {
|
|
|
478
755
|
let returnedCharacters = 0;
|
|
479
756
|
article.sections.forEach((section, i) => {
|
|
480
757
|
const original = sizes[i] ?? 0;
|
|
481
|
-
const fitted = fitFields(
|
|
482
|
-
const returned = fitted
|
|
758
|
+
const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
|
|
759
|
+
const returned = totalLength(fitted);
|
|
483
760
|
returnedCharacters += returned;
|
|
484
761
|
sectionReports.push({
|
|
485
762
|
...(section.title !== undefined && { title: section.title }),
|
|
@@ -491,21 +768,31 @@ function applyPmcBudget(article, budget) {
|
|
|
491
768
|
omittedSections += 1;
|
|
492
769
|
return;
|
|
493
770
|
}
|
|
494
|
-
kept.push({
|
|
495
|
-
...section,
|
|
496
|
-
text: fitted[0] ?? '',
|
|
497
|
-
...(section.subsections && {
|
|
498
|
-
subsections: section.subsections.map((sub, j) => ({ ...sub, text: fitted[j + 1] ?? '' })),
|
|
499
|
-
}),
|
|
500
|
-
});
|
|
771
|
+
kept.push(withFittedTexts(section, fitted, { i: 0 }));
|
|
501
772
|
});
|
|
502
|
-
|
|
773
|
+
// Sections are served first; the tables spend whatever `maxCharacters` has
|
|
774
|
+
// left. A bare per-section budget sets no total, so nothing bounds them.
|
|
775
|
+
const tableAllowance = budget.maxCharacters === undefined
|
|
776
|
+
? Number.POSITIVE_INFINITY
|
|
777
|
+
: Math.max(budget.maxCharacters - returnedCharacters, 0);
|
|
778
|
+
const fittedTables = fitTables(tables, tableAllowance);
|
|
779
|
+
returnedCharacters += fittedTables.spent;
|
|
780
|
+
const omittedTables = fittedTables.omittedNames.length;
|
|
781
|
+
if (returnedCharacters === originalCharacters && omittedSections === 0 && omittedTables === 0) {
|
|
503
782
|
return { article, omittedSections: 0 };
|
|
504
783
|
}
|
|
505
784
|
return {
|
|
506
|
-
article: { ...article, sections: kept },
|
|
785
|
+
article: withTables({ ...article, sections: kept }, fittedTables.kept),
|
|
507
786
|
omittedSections,
|
|
508
|
-
truncation: {
|
|
787
|
+
truncation: {
|
|
788
|
+
originalCharacters,
|
|
789
|
+
returnedCharacters,
|
|
790
|
+
sections: sectionReports,
|
|
791
|
+
...(omittedTables > 0 && {
|
|
792
|
+
omittedTables,
|
|
793
|
+
omittedTableNames: fittedTables.omittedNames,
|
|
794
|
+
}),
|
|
795
|
+
},
|
|
509
796
|
};
|
|
510
797
|
}
|
|
511
798
|
/**
|
|
@@ -524,6 +811,62 @@ function applyContentBudget(content, budget) {
|
|
|
524
811
|
truncation: { originalCharacters: content.length, returnedCharacters: kept.length },
|
|
525
812
|
};
|
|
526
813
|
}
|
|
814
|
+
/** Why a table arrived with no rows, in the reader's terms. */
|
|
815
|
+
const UNEXTRACTABLE_TABLE_EXPLANATIONS = {
|
|
816
|
+
'cals-tgroup': 'it uses the CALS `<tgroup>` model, which this server does not extract',
|
|
817
|
+
'graphic-only': 'it was deposited as an image, with no underlying markup to read',
|
|
818
|
+
'no-rows': 'its markup carried no rows',
|
|
819
|
+
};
|
|
820
|
+
/** How a table is named in a notice: its label, else its id, else its position. */
|
|
821
|
+
function tableDisplayName(table, index) {
|
|
822
|
+
return table.label ?? table.id ?? `table ${index + 1}`;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Collect the returned tables that carry no cell values. Read off the articles
|
|
826
|
+
* the response actually ships — after every filter and both budgets — so a table
|
|
827
|
+
* the `sections` filter removed, or one belonging to a deferred article, is
|
|
828
|
+
* never named as though the caller received it. (#111)
|
|
829
|
+
*/
|
|
830
|
+
function collectUnextractableTables(articles) {
|
|
831
|
+
const entries = [];
|
|
832
|
+
for (const article of articles) {
|
|
833
|
+
if (article.source !== 'pmc')
|
|
834
|
+
continue;
|
|
835
|
+
for (const [index, table] of (article.tables ?? []).entries()) {
|
|
836
|
+
const reason = table.unextractableReason;
|
|
837
|
+
if (!reason)
|
|
838
|
+
continue;
|
|
839
|
+
entries.push({
|
|
840
|
+
articleId: articleDisplayId(article),
|
|
841
|
+
name: tableDisplayName(table, index),
|
|
842
|
+
reason,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return entries;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Compose the recovery notice for tables returned with a label and caption but
|
|
850
|
+
* no cells. Without it a table-bearing response carries no response-level signal
|
|
851
|
+
* that some of the numbers the caller asked for are absent — the per-table
|
|
852
|
+
* `unextractableReason` only helps a reader who already went looking at that
|
|
853
|
+
* table. One notice covers the whole response, aggregated across articles, the
|
|
854
|
+
* way the `sections`-filter miss notice does. (#111)
|
|
855
|
+
*
|
|
856
|
+
* The wording turns on recoverability: nothing the caller changes produces these
|
|
857
|
+
* cells, because the markup does not exist upstream. Tables the character budget
|
|
858
|
+
* dropped are the opposite case — recoverable by raising `maxCharacters` — so
|
|
859
|
+
* they stay with the rest of the budget accounting in {@link
|
|
860
|
+
* buildTruncationNotice} rather than being mixed in here.
|
|
861
|
+
*/
|
|
862
|
+
function buildUnextractableTablesNotice(entries) {
|
|
863
|
+
const subject = entries.length === 1 ? '1 table was' : `${entries.length} tables were`;
|
|
864
|
+
const named = entries.map((e) => `${e.name} (${e.articleId}, ${e.reason})`).join(', ');
|
|
865
|
+
const reasons = [...new Set(entries.map((e) => e.reason))]
|
|
866
|
+
.map((reason) => `${reason} — ${UNEXTRACTABLE_TABLE_EXPLANATIONS[reason]}`)
|
|
867
|
+
.join('; ');
|
|
868
|
+
return `${subject} returned with a label and caption but no cell values: ${named}. Re-calling will not recover the cells (${reasons}); see \`unextractableReason\` on each table.`;
|
|
869
|
+
}
|
|
527
870
|
/**
|
|
528
871
|
* Compose the recovery notice for a budgeted response. Names what was spent and
|
|
529
872
|
* where the detail lives so an agent reading only `content[]` knows the body it
|
|
@@ -534,13 +877,20 @@ function buildTruncationNotice(truncation) {
|
|
|
534
877
|
const omitted = truncation.omittedSections > 0
|
|
535
878
|
? ` ${truncation.omittedSections} section(s) were dropped once the budget ran out.`
|
|
536
879
|
: '';
|
|
880
|
+
// Tables are admitted after sections and only whole, so a dropped one is
|
|
881
|
+
// absent rather than partial — say so, and name them: a bare count leaves the
|
|
882
|
+
// reader unable to tell which numbers are missing from what they received.
|
|
883
|
+
const droppedNames = truncation.articles.flatMap((a) => a.omittedTableNames ?? []);
|
|
884
|
+
const omittedTables = truncation.omittedTables
|
|
885
|
+
? ` ${truncation.omittedTables} table(s) were dropped whole rather than cut mid-row: ${droppedNames.join(', ')}.`
|
|
886
|
+
: '';
|
|
537
887
|
// Name only the budgets the request actually set — pointing at `maxCharacters`
|
|
538
888
|
// when the caller only capped per-section sends them to a knob that is unset.
|
|
539
889
|
const knobs = [
|
|
540
890
|
truncation.maxCharacters !== undefined ? '`maxCharacters`' : undefined,
|
|
541
891
|
truncation.maxCharactersPerSection !== undefined ? '`maxCharactersPerSection`' : undefined,
|
|
542
892
|
].filter((k) => k !== undefined);
|
|
543
|
-
return `Full text was shortened to fit the requested character budget: ${truncation.returnedCharacters} of ${truncation.originalCharacters} body characters returned across ${subject} in ${truncation.mode} mode.${omitted} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
|
|
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.`;
|
|
544
894
|
}
|
|
545
895
|
/**
|
|
546
896
|
* Compose the recovery notice for articles the whole-response budget withheld.
|
|
@@ -574,7 +924,7 @@ function countOmittedSections(entry, mode) {
|
|
|
574
924
|
* recoveries that silently can't happen.
|
|
575
925
|
*/
|
|
576
926
|
export function buildFulltextDescription(tiers) {
|
|
577
|
-
const base = 'Fetch full-text articles from PubMed Central with structured sections and references.';
|
|
927
|
+
const base = 'Fetch full-text articles from PubMed Central with structured sections, tables, and references.';
|
|
578
928
|
const epmcClause = 'Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart)';
|
|
579
929
|
const unpaywallClause = 'Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text';
|
|
580
930
|
let fallback;
|
|
@@ -642,6 +992,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
642
992
|
.boolean()
|
|
643
993
|
.default(false)
|
|
644
994
|
.describe('Include reference list. Applies to `source=pmc` results only.'),
|
|
995
|
+
includeTables: z
|
|
996
|
+
.boolean()
|
|
997
|
+
.default(true)
|
|
998
|
+
.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."),
|
|
645
999
|
maxSections: z
|
|
646
1000
|
.number()
|
|
647
1001
|
.int()
|
|
@@ -652,14 +1006,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
652
1006
|
sections: z
|
|
653
1007
|
.array(z.string())
|
|
654
1008
|
.optional()
|
|
655
|
-
.describe('Filter to specific sections by title
|
|
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.'),
|
|
656
1010
|
maxCharacters: z
|
|
657
1011
|
.number()
|
|
658
1012
|
.int()
|
|
659
1013
|
.min(1)
|
|
660
1014
|
.max(1_000_000)
|
|
661
1015
|
.optional()
|
|
662
|
-
.describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `
|
|
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; 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 and tables spend what is left, in document order — admission stops at the first table that does not fit, and every table from there on is dropped whole rather than cut mid-row, counted in `truncation.omittedTables` and named in `truncation.articles[].omittedTableNames`. Applied after `sections`, `maxSections`, `includeReferences`, and `includeTables`, 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.'),
|
|
663
1017
|
maxCharactersPerSection: z
|
|
664
1018
|
.number()
|
|
665
1019
|
.int()
|
|
@@ -694,17 +1048,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
694
1048
|
truncation: TruncationSchema.optional(),
|
|
695
1049
|
deferred: DeferredSchema.optional(),
|
|
696
1050
|
}),
|
|
697
|
-
// Recovery guidance for
|
|
1051
|
+
// Recovery guidance for five cases — a `sections` filter that removed every
|
|
698
1052
|
// body section (#80), a record the chain could only retrieve as front matter
|
|
699
|
-
// (#86), a
|
|
700
|
-
//
|
|
1053
|
+
// (#86), a table returned with no cell values (#111), a body the per-article
|
|
1054
|
+
// character budget shortened (#81), and articles the whole-response budget
|
|
1055
|
+
// withheld (#100). Agent-facing context surfaced via
|
|
701
1056
|
// ctx.enrich.notice() to structuredContent and content[]; absent when none
|
|
702
1057
|
// applies.
|
|
703
1058
|
enrichment: {
|
|
704
1059
|
notice: z
|
|
705
1060
|
.string()
|
|
706
1061
|
.optional()
|
|
707
|
-
.describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. A response-wide budget that deferred articles names the ids to re-request. Absent when none of those applies.'),
|
|
1062
|
+
.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.'),
|
|
708
1063
|
truncated: z
|
|
709
1064
|
.boolean()
|
|
710
1065
|
.optional()
|
|
@@ -1292,6 +1647,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1292
1647
|
unavailable: unavailable.length,
|
|
1293
1648
|
...(deferred && { deferred: deferred.deferredCount }),
|
|
1294
1649
|
});
|
|
1650
|
+
// Summed off the per-article entries rather than carried through every
|
|
1651
|
+
// stage, so a deferred article's dropped tables leave the roll-up together
|
|
1652
|
+
// with its entry when the splice above removes it. (#111)
|
|
1653
|
+
const omittedTables = truncatedArticles.reduce((n, a) => n + (a.omittedTables ?? 0), 0);
|
|
1295
1654
|
// Rolled up only when the budget actually removed characters, so an
|
|
1296
1655
|
// under-budget request returns exactly what it did before the budget
|
|
1297
1656
|
// controls existed. (#81)
|
|
@@ -1305,6 +1664,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1305
1664
|
originalCharacters: truncatedArticles.reduce((n, a) => n + a.originalCharacters, 0),
|
|
1306
1665
|
returnedCharacters: truncatedArticles.reduce((n, a) => n + a.returnedCharacters, 0),
|
|
1307
1666
|
omittedSections,
|
|
1667
|
+
...(omittedTables > 0 && { omittedTables }),
|
|
1308
1668
|
articles: truncatedArticles,
|
|
1309
1669
|
}
|
|
1310
1670
|
: undefined;
|
|
@@ -1317,6 +1677,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
|
|
|
1317
1677
|
const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
|
|
1318
1678
|
if (unrecoveredBodyless.length > 0)
|
|
1319
1679
|
notices.push(buildBodylessNotice(unrecoveredBodyless));
|
|
1680
|
+
const unextractableTables = collectUnextractableTables(articles);
|
|
1681
|
+
if (unextractableTables.length > 0) {
|
|
1682
|
+
notices.push(buildUnextractableTablesNotice(unextractableTables));
|
|
1683
|
+
}
|
|
1320
1684
|
if (truncation) {
|
|
1321
1685
|
notices.push(buildTruncationNotice(truncation));
|
|
1322
1686
|
ctx.enrich({ truncated: true });
|
|
@@ -1873,7 +2237,8 @@ function formatUnqueriedTiers(tiers, chain) {
|
|
|
1873
2237
|
* separators — so the numbers stay greppable. (#81)
|
|
1874
2238
|
*/
|
|
1875
2239
|
function formatTruncation(t, lines) {
|
|
1876
|
-
|
|
2240
|
+
const tablesOmitted = t.omittedTables === undefined ? '' : `; ${t.omittedTables} table(s) omitted whole`;
|
|
2241
|
+
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}`);
|
|
1877
2242
|
const budgets = [
|
|
1878
2243
|
t.maxCharacters === undefined ? undefined : `maxCharacters ${t.maxCharacters}`,
|
|
1879
2244
|
t.maxCharactersPerSection === undefined
|
|
@@ -1883,7 +2248,10 @@ function formatTruncation(t, lines) {
|
|
|
1883
2248
|
if (budgets.length)
|
|
1884
2249
|
lines.push(`Budget applied: ${budgets.join(', ')}`);
|
|
1885
2250
|
for (const a of t.articles) {
|
|
1886
|
-
|
|
2251
|
+
const tablesDropped = a.omittedTables === undefined
|
|
2252
|
+
? ''
|
|
2253
|
+
: `, ${a.omittedTables} table(s) dropped whole: ${(a.omittedTableNames ?? []).join(', ')}`;
|
|
2254
|
+
lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters${tablesDropped}`);
|
|
1887
2255
|
for (const s of a.sections ?? []) {
|
|
1888
2256
|
lines.push(` - ${s.title ?? 'untitled section'} — ${s.returnedCharacters} of ${s.originalCharacters} characters (truncated: ${s.truncated})`);
|
|
1889
2257
|
}
|
|
@@ -1950,20 +2318,10 @@ function formatPmcArticle(a, lines, truncation) {
|
|
|
1950
2318
|
lines.push(truncationNote(truncation));
|
|
1951
2319
|
if (a.abstract)
|
|
1952
2320
|
lines.push(`\n#### Abstract\n${a.abstract}`);
|
|
1953
|
-
for (const sec of a.sections)
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
lines.push(sec.text);
|
|
1958
|
-
if (sec.subsections?.length) {
|
|
1959
|
-
for (const sub of sec.subsections) {
|
|
1960
|
-
if (sub.title)
|
|
1961
|
-
lines.push(`\n##### ${formatHeading(sub.label, sub.title)}`);
|
|
1962
|
-
if (sub.text)
|
|
1963
|
-
lines.push(sub.text);
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
2321
|
+
for (const sec of a.sections)
|
|
2322
|
+
formatSection(sec, lines, 4);
|
|
2323
|
+
if (a.tables?.length)
|
|
2324
|
+
formatTables(a.tables, lines);
|
|
1967
2325
|
if (a.references?.length) {
|
|
1968
2326
|
lines.push(`\n#### References (${a.references.length})`);
|
|
1969
2327
|
for (const ref of a.references) {
|
|
@@ -1972,6 +2330,83 @@ function formatPmcArticle(a, lines, truncation) {
|
|
|
1972
2330
|
}
|
|
1973
2331
|
}
|
|
1974
2332
|
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Render every table as a Markdown grid, so a `content[]` reader gets the same
|
|
2335
|
+
* cells `structuredContent` carries rather than a note that tables exist. (#111)
|
|
2336
|
+
*
|
|
2337
|
+
* Cell text goes through {@link escapeMarkdownTableCell} — the inline escape
|
|
2338
|
+
* plus the `|` a cell cannot carry raw. The parser expands `colspan` and
|
|
2339
|
+
* `rowspan`, so a well-formed table arrives rectangular and every value renders
|
|
2340
|
+
* under the header it belongs to; a row still short of the widest is padded on
|
|
2341
|
+
* the right with empty cells only, never a neighbour's value.
|
|
2342
|
+
*/
|
|
2343
|
+
function formatTables(tables, lines) {
|
|
2344
|
+
lines.push(`\n#### Tables (${tables.length})`);
|
|
2345
|
+
for (const table of tables) {
|
|
2346
|
+
const heading = [table.label, table.caption].filter(Boolean).join(' — ');
|
|
2347
|
+
lines.push(`\n##### ${escapeMarkdownInline(heading || 'Table')}`);
|
|
2348
|
+
const meta = [
|
|
2349
|
+
table.sectionTitle ? `Section: ${escapeMarkdownInline(table.sectionTitle)}` : undefined,
|
|
2350
|
+
table.id ? `id: ${escapeMarkdownInline(table.id)}` : undefined,
|
|
2351
|
+
describeHeaderRows(table),
|
|
2352
|
+
].filter((part) => part !== undefined);
|
|
2353
|
+
if (meta.length)
|
|
2354
|
+
lines.push(`*${meta.join(' · ')}*`);
|
|
2355
|
+
if (table.unextractableReason) {
|
|
2356
|
+
lines.push(`\n> Table body could not be read (${table.unextractableReason}) — ${UNEXTRACTABLE_TABLE_EXPLANATIONS[table.unextractableReason]}. The label and caption above are all this deposit carries; no cell values exist to return.`);
|
|
2357
|
+
}
|
|
2358
|
+
if (table.rows.length > 0)
|
|
2359
|
+
lines.push(...renderTableGrid(table.rows, table.headerRowCount));
|
|
2360
|
+
if (table.footnotes)
|
|
2361
|
+
lines.push(`\nFootnotes: ${escapeMarkdownInline(table.footnotes)}`);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* The meta-line clause describing a table's header rows.
|
|
2366
|
+
*
|
|
2367
|
+
* A Markdown grid carries exactly one header row, so a table declaring several
|
|
2368
|
+
* has them folded into it — say how many were folded, or the grid understates
|
|
2369
|
+
* what the deposit declared. A table declaring none still needs the empty header
|
|
2370
|
+
* row Markdown requires above the divider; naming that keeps a reader from
|
|
2371
|
+
* taking the blank row for a header the publisher deposited and left empty.
|
|
2372
|
+
*/
|
|
2373
|
+
function describeHeaderRows(table) {
|
|
2374
|
+
if (table.rows.length === 0)
|
|
2375
|
+
return;
|
|
2376
|
+
if (table.headerRowCount === 0)
|
|
2377
|
+
return 'header rows: none declared — every row below is data';
|
|
2378
|
+
if (table.headerRowCount === 1)
|
|
2379
|
+
return 'header rows: 1';
|
|
2380
|
+
return `header rows: ${table.headerRowCount} (folded into one)`;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Join one grid column's header cells into the single header path Markdown can
|
|
2384
|
+
* carry. Consecutive repeats — what expanding a `colspan` produces — collapse to
|
|
2385
|
+
* one, so a group header spanning three columns reads once per column rather
|
|
2386
|
+
* than three times in each.
|
|
2387
|
+
*/
|
|
2388
|
+
function foldHeaderColumn(headerRows, column) {
|
|
2389
|
+
const path = [];
|
|
2390
|
+
for (const row of headerRows) {
|
|
2391
|
+
const cell = row[column] ?? '';
|
|
2392
|
+
if (cell && cell !== path.at(-1))
|
|
2393
|
+
path.push(cell);
|
|
2394
|
+
}
|
|
2395
|
+
return path.join(' · ');
|
|
2396
|
+
}
|
|
2397
|
+
/** One table's rows as Markdown grid lines, preceded by a blank line. */
|
|
2398
|
+
function renderTableGrid(rows, headerRowCount) {
|
|
2399
|
+
const columns = rows.reduce((widest, row) => Math.max(widest, row.length), 0);
|
|
2400
|
+
const renderRow = (cells) => `| ${Array.from({ length: columns }, (_, i) => escapeMarkdownTableCell(cells[i] ?? '')).join(' | ')} |`;
|
|
2401
|
+
const headerRows = rows.slice(0, headerRowCount);
|
|
2402
|
+
const header = Array.from({ length: columns }, (_, i) => foldHeaderColumn(headerRows, i));
|
|
2403
|
+
return [
|
|
2404
|
+
'',
|
|
2405
|
+
renderRow(header),
|
|
2406
|
+
`| ${Array.from({ length: columns }, () => '---').join(' | ')} |`,
|
|
2407
|
+
...rows.slice(headerRowCount).map(renderRow),
|
|
2408
|
+
];
|
|
2409
|
+
}
|
|
1975
2410
|
function formatUnpaywallArticle(a, lines, truncation) {
|
|
1976
2411
|
const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
|
|
1977
2412
|
const heading = a.title ?? requestedId;
|
|
@@ -2015,6 +2450,21 @@ function formatPmcAuthor(au) {
|
|
|
2015
2450
|
function formatHeading(label, title) {
|
|
2016
2451
|
return label ? `${label} ${title}` : title;
|
|
2017
2452
|
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Render one body section and everything nested under it, one markdown heading
|
|
2455
|
+
* level per nesting level. Walks the full depth the output schema carries, so
|
|
2456
|
+
* `content[]` shows every section `structuredContent` does. Headings stop
|
|
2457
|
+
* deepening at `######`, the deepest markdown supports. (#112)
|
|
2458
|
+
*/
|
|
2459
|
+
function formatSection(section, lines, depth) {
|
|
2460
|
+
if (section.title) {
|
|
2461
|
+
lines.push(`\n${'#'.repeat(Math.min(depth, 6))} ${formatHeading(section.label, section.title)}`);
|
|
2462
|
+
}
|
|
2463
|
+
if (section.text)
|
|
2464
|
+
lines.push(section.text);
|
|
2465
|
+
for (const sub of section.subsections ?? [])
|
|
2466
|
+
formatSection(sub, lines, depth + 1);
|
|
2467
|
+
}
|
|
2018
2468
|
/**
|
|
2019
2469
|
* Strip absolute URLs from chain detail strings. Upstream errors (e.g.
|
|
2020
2470
|
* `Fetch failed for <eutils URL>. Status: 400`) leak endpoint paths and query
|