@cyanheads/ris-austria-mcp-server 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +5 -5
- package/changelog/0.4.x/0.4.0.md +17 -0
- package/changelog/0.4.x/0.4.1.md +15 -0
- package/dist/mcp-server/resources/definitions/ris-document.resource.d.ts +4 -3
- package/dist/mcp-server/resources/definitions/ris-document.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/ris-document.resource.js +11 -6
- package/dist/mcp-server/resources/definitions/ris-document.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts +100 -38
- package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-get-document.tool.js +271 -75
- package/dist/mcp-server/tools/definitions/ris-get-document.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-drafts.tool.d.ts +4 -8
- package/dist/mcp-server/tools/definitions/ris-search-drafts.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-drafts.tool.js +24 -16
- package/dist/mcp-server/tools/definitions/ris-search-drafts.tool.js.map +1 -1
- package/dist/services/ris/ris-service.d.ts +4 -3
- package/dist/services/ris/ris-service.d.ts.map +1 -1
- package/dist/services/ris/ris-service.js +4 -3
- package/dist/services/ris/ris-service.js.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
* or its rendition URLs, with explicit binding status and the amtssigniert Authentisch PDF
|
|
4
4
|
* surfaced wherever it exists. Two addressing modes route to the same fetch path:
|
|
5
5
|
* `document_number` + `application`, or a `document_url` from a result's content_urls — the
|
|
6
|
-
* latter also carrying the one route to a draft's companion documents
|
|
7
|
-
*
|
|
6
|
+
* latter also carrying the one route to a draft's companion documents, whose per-record
|
|
7
|
+
* opaque filenames no document number reaches (see {@link COMPANION_STEMS} for the shapes
|
|
8
|
+
* RIS files them under).
|
|
8
9
|
* Format availability varies by application (full text · authentic-PDF-only · PDF-only ·
|
|
9
10
|
* metadata-only) — a text-format request against a non-text application degrades to a
|
|
10
11
|
* `format_unavailable` notice on a success result, never an error. Markdown conversion drops
|
|
11
12
|
* the `.sr-only` screen-reader twin RIS ships beside every abbreviated citation, keeping the
|
|
12
|
-
* visible form; `html` and `xml` pass through untouched. Oversized markdown
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* visible form; `html` and `xml` pass through untouched. Oversized markdown overflows to a
|
|
14
|
+
* retrievable outline rather than a silent truncation — its §/Artikel/Anlage sections, or
|
|
15
|
+
* `Part n of N` byte windows where it carries no such headings; a follow-up call with
|
|
16
|
+
* `sections:[…]` returns just the chosen entries, and a selector matching nothing gets the
|
|
17
|
+
* outline back with a notice rather than the whole document. The shared
|
|
17
18
|
* {@link renderDocument} helper backs both this tool and the `ris://document/…` resource.
|
|
18
19
|
* @module mcp-server/tools/definitions/ris-get-document
|
|
19
20
|
*/
|
|
@@ -35,18 +36,20 @@ const BINDING_STATUSES = [
|
|
|
35
36
|
'translation',
|
|
36
37
|
];
|
|
37
38
|
/**
|
|
38
|
-
* Serialized-length budget for inlining document text
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
39
|
+
* Serialized-length budget for inlining document text, and the size of one addressable
|
|
40
|
+
* window. Above it, markdown overflows to an outline instead of returning whole — its
|
|
41
|
+
* §/Artikel/Anlage sections where it carries at least two such headings, else the byte
|
|
42
|
+
* windows {@link windowDocument} cuts, so a rendition the segmenter cannot split is
|
|
43
|
+
* retrievable rather than returned whole at any size (court decisions, gazette and
|
|
44
|
+
* announcement bodies, and consolidated `§ 0` promulgation records all carry no headings,
|
|
45
|
+
* measured live to 399,972 bytes on a Bvwg decision).
|
|
44
46
|
*
|
|
45
47
|
* Sized against what one tool result can carry into a working context (≈10K tokens of legal
|
|
46
48
|
* prose), not against what counts as a pathological document: across live samples the
|
|
47
49
|
* documents callers routinely read land well under it — consolidated-law sections at 2–3 KB,
|
|
48
50
|
* drafts and bills at 6–36 KB — while the multi-act bills and long translations that motivated
|
|
49
|
-
* the outline (71 KB–199 KB) route through it.
|
|
51
|
+
* the outline (71 KB–199 KB) route through it. One number serves both roles because a window
|
|
52
|
+
* is by definition one result's worth, which is what the budget already means.
|
|
50
53
|
*
|
|
51
54
|
* Handed to `outlineOnOverflow`, which measures `JSON.stringify` length (≈ UTF-8 bytes for
|
|
52
55
|
* the mostly-Latin legal text); the exact `byte_size` is reported separately.
|
|
@@ -64,15 +67,37 @@ const APPLICATION_BY_SEGMENT = new Map(RIS_APPLICATIONS.flatMap((app) => app.con
|
|
|
64
67
|
*/
|
|
65
68
|
const RENDITION_EXTENSIONS = new Set([...RIS_CONTENT_FORMATS, 'pdfsig']);
|
|
66
69
|
/**
|
|
67
|
-
* Filename
|
|
70
|
+
* Filename stems of a companion document — the `Erläuterungen`, `Textgegenüberstellung`,
|
|
68
71
|
* `Vorblatt und WFA`, cover letter, and annex texts a `Begut` draft or `RegV` bill ships
|
|
69
|
-
* beside its main text, surfaced on a `ris_search_drafts` record's `materials`.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* companion
|
|
72
|
+
* beside its main text, surfaced on a `ris_search_drafts` record's `materials`. The stem is
|
|
73
|
+
* opaque and per-record — the URL is the only handle, so nothing about a companion is
|
|
74
|
+
* reconstructible from a document number, and this list is what decides whether a passed
|
|
75
|
+
* `document_url` may address a file other than the main rendition. Three shapes, every one
|
|
76
|
+
* of the 23,483 companion URLs across the full live Begut + RegV corpus (7,185 records,
|
|
77
|
+
* every page of both applications, drafts from 2003 through 2026):
|
|
78
|
+
*
|
|
79
|
+
* - `{Materialien|Schreiben|Anlagen}_{NNNN}_{UUID}` — 4,763, all on records from 2026.
|
|
80
|
+
* - `COO_{n}_{n}_{n}_{n}` — 18,719, the Fabasoft object address, on every record before
|
|
81
|
+
* 2026. Not a date: the leading group is a constant store id, so nothing here is a year.
|
|
82
|
+
* Refusing it refused four companion URLs in five (#35).
|
|
83
|
+
* - `{NNNN}_{UUID}` — 1, the ordinal + UUID tail with no prefix, on one RegV record.
|
|
84
|
+
*
|
|
85
|
+
* Each alternative is anchored end to end over hex digits, `_`, and its own literal prefix,
|
|
86
|
+
* so an accepted stem can carry no `/`, `.`, `%`, backslash, or null byte. Widening happens
|
|
87
|
+
* by adding a whole alternative, never by relaxing one into a prefix or substring test — RIS
|
|
88
|
+
* names the per-record inline formula images `Material-COO_…` and `Anlage-COO_…`, ~55 on
|
|
89
|
+
* every draft record, and a passed filename matching none of these shapes is content this
|
|
90
|
+
* tool would otherwise fetch blind inside the document's folder.
|
|
74
91
|
*/
|
|
75
|
-
const
|
|
92
|
+
const COMPANION_STEMS = [
|
|
93
|
+
/^(?:Materialien|Schreiben|Anlagen)_\d{4}_[0-9A-F]{8}(?:_[0-9A-F]{4}){3}_[0-9A-F]{12}$/iu,
|
|
94
|
+
/^COO(?:_\d{1,10}){4}$/u,
|
|
95
|
+
/^\d{4}_[0-9A-F]{8}(?:_[0-9A-F]{4}){3}_[0-9A-F]{12}$/iu,
|
|
96
|
+
];
|
|
97
|
+
/** Whether a `document_url`'s filename stem addresses a companion document. */
|
|
98
|
+
function isCompanionStem(stem) {
|
|
99
|
+
return COMPANION_STEMS.some((pattern) => pattern.test(stem));
|
|
100
|
+
}
|
|
76
101
|
/**
|
|
77
102
|
* Drop RIS's screen-reader twin, pass every other span through untouched. RIS renditions
|
|
78
103
|
* carry each abbreviated legal reference twice — the visible citation in
|
|
@@ -159,25 +184,34 @@ export function parseDocumentUrl(url, contentBaseUrl) {
|
|
|
159
184
|
}
|
|
160
185
|
// A folder URL (…/{segment}/{documentNumber}[/]) carries no trailing filename and resolves
|
|
161
186
|
// fine. A trailing filename addresses one of exactly two things: the main-document
|
|
162
|
-
// rendition, named {documentNumber}.{ext}, or a companion document, named
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
187
|
+
// rendition, named {documentNumber}.{ext}, or a companion document, named in one of the
|
|
188
|
+
// {@link COMPANION_STEMS} shapes. Anything else — an unknown stem, an unknown extension,
|
|
189
|
+
// deeper nesting — is content this tool would otherwise fetch blind or silently swap for
|
|
190
|
+
// the main document, so it is rejected here. Each of the three causes reports itself: they
|
|
191
|
+
// ask different things of the caller, and a caller who passed a URL a search result handed
|
|
192
|
+
// them learns nothing from being told to pass a URL a search result handed them.
|
|
166
193
|
if (filename !== undefined) {
|
|
167
194
|
const decodedFilename = decodePathSegment(filename);
|
|
168
195
|
if (decodedFilename === undefined) {
|
|
169
196
|
return { error: `"${filename}" is not a decodable filename (malformed % escape)` };
|
|
170
197
|
}
|
|
198
|
+
if (rest.length > 0) {
|
|
199
|
+
return {
|
|
200
|
+
error: `"${decodedFilename}/${rest.join('/')}" nests below the document folder — RIS files every rendition directly in /Dokumente/${segment}/${documentNumber}/, so drop the trailing path`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
171
203
|
const dot = decodedFilename.lastIndexOf('.');
|
|
172
204
|
const stem = dot === -1 ? decodedFilename : decodedFilename.slice(0, dot);
|
|
173
205
|
const extension = dot === -1 ? '' : decodedFilename.slice(dot + 1).toLowerCase();
|
|
206
|
+
if (!RENDITION_EXTENSIONS.has(extension)) {
|
|
207
|
+
return {
|
|
208
|
+
error: `"${decodedFilename}" does not end in a RIS rendition extension (${[...RENDITION_EXTENSIONS].join(', ')}) — the extension is only checked and then discarded, so pass any rendition URL of the document you want and select the rendition with format`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
174
211
|
const isMainDocument = stem === documentNumber;
|
|
175
|
-
|
|
176
|
-
RENDITION_EXTENSIONS.has(extension) &&
|
|
177
|
-
(isMainDocument || COMPANION_STEM.test(stem));
|
|
178
|
-
if (!addressable) {
|
|
212
|
+
if (!isMainDocument && !isCompanionStem(stem)) {
|
|
179
213
|
return {
|
|
180
|
-
error: `"${decodedFilename}" is neither
|
|
214
|
+
error: `"${decodedFilename}" is neither ${documentNumber}'s own rendition (${documentNumber}.${extension}) nor one of its companion documents — RIS assigns companion filenames opaquely, so one cannot be composed by hand. Copy a materials[].url from a ris_search_drafts record verbatim, or drop the filename to read ${documentNumber} itself`,
|
|
181
215
|
};
|
|
182
216
|
}
|
|
183
217
|
if (!isMainDocument) {
|
|
@@ -194,10 +228,13 @@ function authenticPdfFrom(pdfUrl) {
|
|
|
194
228
|
* Construct the rendition URLs for a document from its application's format availability.
|
|
195
229
|
* `buildDocumentContentUrl` throws `ValidationError` for an unsafe document number.
|
|
196
230
|
*
|
|
197
|
-
* A companion document (`contentName`) is published as XML, HTML and PDF
|
|
198
|
-
*
|
|
199
|
-
* `.pdfsig`
|
|
200
|
-
*
|
|
231
|
+
* A companion document (`contentName`) is published as XML, HTML and PDF on 20,654 of the
|
|
232
|
+
* 23,483 companions in the live Begut + RegV corpus — RTF on 61% of those, the signed
|
|
233
|
+
* `.pdfsig` on none — so those three are what gets constructed. The other 2,829 (12.0%,
|
|
234
|
+
* nearly all Begut covering letters) carry PDF, or PDF and RTF, and nothing else: RIS 404s a
|
|
235
|
+
* rendition it does not list, so the constructed XML and HTML are dead for them and only the
|
|
236
|
+
* PDF resolves. Which case a companion falls in is legible only from the search result that
|
|
237
|
+
* listed it, never from the URL, so the same three are built either way.
|
|
201
238
|
*/
|
|
202
239
|
function buildRenditionUrls(service, app, documentNumber, contentName) {
|
|
203
240
|
if (app.formats === 'none')
|
|
@@ -265,6 +302,7 @@ export function segmentDocument(text) {
|
|
|
265
302
|
const seen = (counts.get(name) ?? 0) + 1;
|
|
266
303
|
counts.set(name, seen);
|
|
267
304
|
sections.push({
|
|
305
|
+
kind: 'section',
|
|
268
306
|
name: seen > 1 ? `${name} (${seen})` : name,
|
|
269
307
|
text: slice,
|
|
270
308
|
bytes: encoder.encode(slice).length,
|
|
@@ -286,24 +324,142 @@ export function segmentDocument(text) {
|
|
|
286
324
|
});
|
|
287
325
|
return sections;
|
|
288
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Shape of a window name. `Part n of N` is the one entry name {@link segmentDocument} can
|
|
329
|
+
* never produce — a section name always opens with a §/Artikel/Anlage marker — so it also
|
|
330
|
+
* identifies a roster's kind on a surface that carries only names and byte sizes.
|
|
331
|
+
*/
|
|
332
|
+
const WINDOW_NAME = /^Part \d+ of \d+$/u;
|
|
333
|
+
/**
|
|
334
|
+
* Cut rendered document text into near-even windows snapped to line boundaries, named
|
|
335
|
+
* `Part 1 of N` … `Part N of N` in document order. The retrieval path for a rendition
|
|
336
|
+
* {@link segmentDocument} cannot split; returns `[]` under the budget and for text that
|
|
337
|
+
* carries no line break to cut on, which the caller reads as "nothing to outline".
|
|
338
|
+
*
|
|
339
|
+
* Even windows rather than fill-the-budget windows: filling leaves a runt tail (a 40,440-byte
|
|
340
|
+
* decision splits 39,602 + 838, buying a round-trip for a scrap) while an even split of the
|
|
341
|
+
* same document is 20,196 + 20,244. Each window's target is the bytes still unassigned over
|
|
342
|
+
* the windows still to cut, so the remainder that snapping to the next line break carries
|
|
343
|
+
* forward is spread across the rest instead of piling onto the tail.
|
|
344
|
+
*
|
|
345
|
+
* Two rules keep the budget a ceiling rather than an aspiration. A window never takes a line
|
|
346
|
+
* that would carry it past the budget, so snapping to the next line break cannot overshoot;
|
|
347
|
+
* and when the tail still lands over — the target sitting just under the budget leaves the
|
|
348
|
+
* last window everything the earlier ones declined — the window count rises until every
|
|
349
|
+
* window fits. A line longer than the budget is the exception, and the reason the count stops
|
|
350
|
+
* rising rather than climbing to one window per line: splitting inside a line would put the
|
|
351
|
+
* cut where the caller cannot see it, so such a line sits alone in a window that goes over.
|
|
352
|
+
*
|
|
353
|
+
* Window bytes sum to the document's byte size exactly — the windows concatenate back to the
|
|
354
|
+
* input, so nothing is dropped or double-counted.
|
|
355
|
+
*/
|
|
356
|
+
export function windowDocument(text, budget) {
|
|
357
|
+
const encoder = new TextEncoder();
|
|
358
|
+
const total = encoder.encode(text).length;
|
|
359
|
+
if (total <= budget)
|
|
360
|
+
return [];
|
|
361
|
+
const lines = text.split('\n');
|
|
362
|
+
if (lines.length < 2)
|
|
363
|
+
return []; // no line break to cut on
|
|
364
|
+
const lineBytes = lines.map((line, i) => encoder.encode(line).length + (i === lines.length - 1 ? 0 : 1));
|
|
365
|
+
/** Line-index boundaries of `count` runs, each targeting an even share of what is left. */
|
|
366
|
+
const cut = (count) => {
|
|
367
|
+
const bounds = [0];
|
|
368
|
+
let cursor = 0;
|
|
369
|
+
let consumed = 0;
|
|
370
|
+
for (let remaining = count; remaining > 1; remaining -= 1) {
|
|
371
|
+
const target = (total - consumed) / remaining;
|
|
372
|
+
const limit = lines.length - (remaining - 1); // leave one line per remaining window
|
|
373
|
+
let bytes = 0;
|
|
374
|
+
while (cursor < limit && bytes < target) {
|
|
375
|
+
const next = lineBytes[cursor] ?? 0;
|
|
376
|
+
if (bytes > 0 && bytes + next > budget)
|
|
377
|
+
break;
|
|
378
|
+
bytes += next;
|
|
379
|
+
cursor += 1;
|
|
380
|
+
}
|
|
381
|
+
bounds.push(cursor);
|
|
382
|
+
consumed += bytes;
|
|
383
|
+
}
|
|
384
|
+
bounds.push(lines.length);
|
|
385
|
+
return bounds;
|
|
386
|
+
};
|
|
387
|
+
/** Every run fits the budget, or is the lone over-budget line nothing can split. */
|
|
388
|
+
const fits = (bounds) => bounds.slice(0, -1).every((from, i) => {
|
|
389
|
+
const to = bounds[i + 1] ?? lines.length;
|
|
390
|
+
return to - from <= 1 || lineBytes.slice(from, to).reduce((sum, n) => sum + n, 0) <= budget;
|
|
391
|
+
});
|
|
392
|
+
// A document with fewer lines than the budget asks windows for cannot have more windows
|
|
393
|
+
// than it has lines — every line over the budget is one nothing can split.
|
|
394
|
+
const floor = Math.min(Math.ceil(total / budget), lines.length);
|
|
395
|
+
let bounds = cut(floor);
|
|
396
|
+
for (let count = floor + 1; count <= lines.length && !fits(bounds); count += 1) {
|
|
397
|
+
bounds = cut(count);
|
|
398
|
+
}
|
|
399
|
+
const slices = bounds
|
|
400
|
+
.slice(0, -1)
|
|
401
|
+
.map((from, i) => {
|
|
402
|
+
const to = bounds[i + 1] ?? lines.length;
|
|
403
|
+
return lines.slice(from, to).join('\n') + (to < lines.length ? '\n' : '');
|
|
404
|
+
})
|
|
405
|
+
.filter((slice) => slice !== '');
|
|
406
|
+
if (slices.length < 2)
|
|
407
|
+
return [];
|
|
408
|
+
return slices.map((slice, i) => ({
|
|
409
|
+
kind: 'window',
|
|
410
|
+
name: `Part ${i + 1} of ${slices.length}`,
|
|
411
|
+
text: slice,
|
|
412
|
+
bytes: encoder.encode(slice).length,
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The one roster of what a caller can address in a rendition — §/Artikel/Anlage sections
|
|
417
|
+
* where the markdown carries at least two of them, else the byte windows an over-budget
|
|
418
|
+
* document is cut into. Both the outline and the `sections:[…]` selector resolve through
|
|
419
|
+
* here, so every entry the outline advertises is retrievable by name.
|
|
420
|
+
*
|
|
421
|
+
* Markdown only. `html` and `xml` are byte-identical passthroughs of the published artifact
|
|
422
|
+
* and a mid-document slice of either is not well-formed, so they stay whole at any size and
|
|
423
|
+
* a caller who needs one has `content_urls`.
|
|
424
|
+
*
|
|
425
|
+
* The two-entry floor matches `outlineOnOverflow`'s own short-circuit: an outline whose only
|
|
426
|
+
* possible `sections` argument returns the same bytes costs a round-trip and buys nothing.
|
|
427
|
+
*/
|
|
428
|
+
export function addressableSections(text, format) {
|
|
429
|
+
const sections = segmentDocument(text);
|
|
430
|
+
if (sections.length >= 2)
|
|
431
|
+
return sections;
|
|
432
|
+
return format === 'markdown' ? windowDocument(text, OUTLINE_BUDGET_BYTES) : [];
|
|
433
|
+
}
|
|
289
434
|
/**
|
|
290
435
|
* Resolve a `sections:[…]` selector against rendered document text — the selective-retrieval
|
|
291
|
-
* counterpart to the outline
|
|
292
|
-
*
|
|
436
|
+
* counterpart to the outline, resolving through the same {@link addressableSections} roster
|
|
437
|
+
* so every advertised name retrieves. The handler re-fetches the document (the upstream query
|
|
438
|
+
* is deterministic, so it reproduces the same text) and slices it to the requested entries.
|
|
293
439
|
* Reports the unmatched names and the full roster alongside the matched text so the handler
|
|
294
440
|
* can disclose a mismatch instead of silently returning something the caller didn't ask for.
|
|
295
441
|
*/
|
|
296
|
-
export function selectDocumentSections(text, want) {
|
|
297
|
-
const sections =
|
|
442
|
+
export function selectDocumentSections(text, want, format) {
|
|
443
|
+
const sections = addressableSections(text, format);
|
|
444
|
+
const windowed = sections[0]?.kind === 'window';
|
|
298
445
|
const names = new Set(sections.map((section) => section.name));
|
|
299
446
|
const wanted = new Set(want);
|
|
300
447
|
const matched = sections.filter((section) => wanted.has(section.name));
|
|
448
|
+
const available = sections.map(({ name, bytes }) => ({ name, bytes }));
|
|
449
|
+
// Same split as the outline's: a section roster is a menu, so largest first puts the
|
|
450
|
+
// substantial units on top; a window roster is a reading sequence and keeps document order.
|
|
451
|
+
if (!windowed)
|
|
452
|
+
available.sort((a, b) => b.bytes - a.bytes);
|
|
301
453
|
return {
|
|
302
|
-
available
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
454
|
+
available,
|
|
455
|
+
// No separator: every entry is a contiguous slice that already carries the line break
|
|
456
|
+
// ending it, so a multi-entry selection reassembles the source byte for byte and its
|
|
457
|
+
// size is the sum of the byte sizes the outline advertised. Inserting one would put
|
|
458
|
+
// bytes on the wire that are not in the document — enough, in a table-heavy RIS body,
|
|
459
|
+
// to split a table at the seam and orphan its rows from their header row.
|
|
460
|
+
text: matched.map((section) => section.text).join(''),
|
|
306
461
|
unmatched: [...new Set(want)].filter((name) => !names.has(name)),
|
|
462
|
+
windowed,
|
|
307
463
|
};
|
|
308
464
|
}
|
|
309
465
|
/** Quote a caller-supplied section-name list for a notice. */
|
|
@@ -312,16 +468,29 @@ function quoteNames(names) {
|
|
|
312
468
|
}
|
|
313
469
|
/**
|
|
314
470
|
* Apply the outline-on-overflow contract to rendered document text: whole under the byte
|
|
315
|
-
* budget (or with fewer than two
|
|
316
|
-
*
|
|
317
|
-
*
|
|
471
|
+
* budget (or with fewer than two addressable entries), else an outline of `roster`. The
|
|
472
|
+
* caller passes the roster it already resolved through {@link addressableSections}, so the
|
|
473
|
+
* outline it advertises and the wording of its notice describe the same entries; the `notice`
|
|
474
|
+
* builder is caller-specific (each points at its own re-call).
|
|
475
|
+
*
|
|
476
|
+
* `outlineOnOverflow` sorts entries largest first and offers no opt-out. That reads as a menu
|
|
477
|
+
* for a section roster, where the names say what each entry is. A window roster is a reading
|
|
478
|
+
* sequence, and a caller that walks `sections` in array order — the obvious way to read one
|
|
479
|
+
* — would take the document out of order, so document order is restored for it here.
|
|
318
480
|
*/
|
|
319
|
-
export function outlineDocument(text, notice) {
|
|
320
|
-
|
|
481
|
+
export function outlineDocument(text, roster, notice) {
|
|
482
|
+
const decision = outlineOnOverflow({ text }, {
|
|
321
483
|
budget: OUTLINE_BUDGET_BYTES,
|
|
322
|
-
extract: (
|
|
484
|
+
extract: () => roster.map(({ name, bytes }) => ({ name, bytes })),
|
|
323
485
|
notice,
|
|
324
486
|
});
|
|
487
|
+
if (decision.kind === 'full' || roster[0]?.kind !== 'window')
|
|
488
|
+
return decision;
|
|
489
|
+
const order = new Map(roster.map((entry, i) => [entry.name, i]));
|
|
490
|
+
return {
|
|
491
|
+
...decision,
|
|
492
|
+
sections: [...decision.sections].sort((a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0)),
|
|
493
|
+
};
|
|
325
494
|
}
|
|
326
495
|
/** Name the three largest sections, quoted, as examples for a re-call notice. */
|
|
327
496
|
export function exampleSectionNames(sections) {
|
|
@@ -331,15 +500,20 @@ export function exampleSectionNames(sections) {
|
|
|
331
500
|
.join(', ');
|
|
332
501
|
}
|
|
333
502
|
/**
|
|
334
|
-
* Render an outline — the
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
503
|
+
* Render an outline — the roster the agent picks from — to markdown. Shared by the tool's
|
|
504
|
+
* `format()` (as a content block) and the resource (as its body), so both client surfaces
|
|
505
|
+
* list identical entries. A roster is all sections or all windows ({@link addressableSections}
|
|
506
|
+
* never mixes them), and the heading names which, since this surface carries only names and
|
|
507
|
+
* byte sizes: windows are a reading sequence listed in document order, sections a menu listed
|
|
508
|
+
* largest first. Deliberately says nothing about *why* the outline was returned: the tool
|
|
509
|
+
* emits one both on a byte overflow and on an unmatched `sections` selector, and the
|
|
510
|
+
* accompanying notice is what separates the two.
|
|
339
511
|
*/
|
|
340
512
|
export function renderOutlineSections(sections) {
|
|
341
513
|
return [
|
|
342
|
-
|
|
514
|
+
WINDOW_NAME.test(sections[0]?.name ?? '')
|
|
515
|
+
? `**${sections.length} windows** (retrieve by name, in this order):`
|
|
516
|
+
: `**${sections.length} sections** (retrieve by name):`,
|
|
343
517
|
...sections.map((section) => `- \`${section.name}\` — ${section.bytes} bytes`),
|
|
344
518
|
].join('\n');
|
|
345
519
|
}
|
|
@@ -398,10 +572,10 @@ const ContentUrlsSchema = z
|
|
|
398
572
|
pdf: z.string().optional().describe('PDF rendition URL.'),
|
|
399
573
|
rtf: z.string().optional().describe('RTF rendition URL.'),
|
|
400
574
|
})
|
|
401
|
-
.describe('Constructed rendition URLs. Empty for authentic-PDF-only (Bvb/GrA/KmGer) and metadata-only (BgblAlt) applications — see authentic_pdf_url and the notice. For a companion document (a draft’s Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex) these are the companion’s own XML/HTML/PDF, not the parent document’s.');
|
|
575
|
+
.describe('Constructed rendition URLs. Empty for authentic-PDF-only (Bvb/GrA/KmGer) and metadata-only (BgblAlt) applications — see authentic_pdf_url and the notice. For a companion document (a draft’s Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex) these are the companion’s own XML/HTML/PDF, not the parent document’s — constructed rather than read back from RIS, so for the roughly one companion in eight that RIS files as a PDF only, xml and html return 404 and pdf is the one that resolves.');
|
|
402
576
|
export const risGetDocument = tool('ris_get_document', {
|
|
403
577
|
title: 'Get RIS Document',
|
|
404
|
-
description: 'Fetch one RIS document’s full text or its rendition URLs, with explicit binding status and the amtssigniert authentic PDF surfaced wherever it exists. Address the document exactly one of two ways: document_number plus application (both copied verbatim from a ris_search_* or ris_lookup_citation result), or a document_url from a result’s content_urls — or, for a draft’s companion documents (Erläuterungen, Textgegenüberstellung, WFA, cover letter, annexes),
|
|
578
|
+
description: 'Fetch one RIS document’s full text or its rendition URLs, with explicit binding status and the amtssigniert authentic PDF surfaced wherever it exists. Address the document exactly one of two ways: document_number plus application (both copied verbatim from a ris_search_* or ris_lookup_citation result), or a document_url from a result’s content_urls — or, for a draft’s companion documents (Erläuterungen, Textgegenüberstellung, WFA, cover letter, annexes), a ris_search_drafts record’s materials[].url, which is the only route to them. format: markdown (default — the HTML rendition converted to markdown), html (raw HTML rendition), xml (the RIS Nutzdaten XML), or urls_only (no fetch — every rendition URL, including the Authentisch PDF). Format availability varies by application and the tool degrades explicitly, never silently: consolidated law, gazettes, case law, drafts, and most sectoral collections carry full text; district and municipal promulgations and court rules (Bvb, GrA, KmGer) publish only the signed authentic PDF; party-transparency decisions and council minutes (Upts, Mrp) are PDF-only; the 1848–1940 imperial gazettes (BgblAlt) are metadata-only — for these a text-format request returns a format_unavailable notice with the usable URL, not an error. Every result carries binding_status; only authentic (amtssigniert) publications are legally binding. This tool returns content, not fresh metadata — the metadata rides the search/lookup step that produced the document number. When the markdown text overflows the 40,000-byte budget the tool returns an outline (kind: outline) instead of truncating: the document’s §/Artikel/Anlage sections where it carries at least two such headings, otherwise contiguous byte windows named Part 1 of N … Part N of N covering the whole text and listed in document order. Re-call with sections:[…] naming outline entries to retrieve just those; a name matching no entry returns the outline again with a notice rather than the whole document. Windows are cut at line breaks, not at sentence or § boundaries, so one can open mid-sentence — read them in order and pull the neighbour when a passage straddles a cut. Raw html and xml renditions are never sliced and return whole at any size. Markdown drops the screen-reader expansions RIS ships alongside each abbreviated citation, keeping the visible citation form; raw html/xml renditions are returned exactly as published.',
|
|
405
579
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
406
580
|
input: z.object({
|
|
407
581
|
document_number: z
|
|
@@ -415,7 +589,7 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
415
589
|
document_url: z
|
|
416
590
|
.string()
|
|
417
591
|
.optional()
|
|
418
|
-
.describe('A https://www.ris.bka.gv.at/Dokumente/… rendition URL as returned in a result’s content_urls — the alternative to document_number + application. Also the only way to read a draft’s companion documents: pass a
|
|
592
|
+
.describe('A https://www.ris.bka.gv.at/Dokumente/… rendition URL as returned in a result’s content_urls — the alternative to document_number + application. Also the only way to read a draft’s companion documents: pass a ris_search_drafts record’s materials[].url (the Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex), whose filenames are opaque and per-record and so cannot be reached through document_number. Every companion filename RIS publishes is accepted, whichever shape it carries. The URL’s own extension is only checked against the rendition extensions RIS uses and is then discarded — format selects which rendition is returned, for a companion exactly as for a main document. A filename that is neither this document’s own rendition nor one of its companions is rejected; companion filenames cannot be composed by hand, so copy one verbatim.'),
|
|
419
593
|
format: z
|
|
420
594
|
.enum(['markdown', 'html', 'xml', 'urls_only'])
|
|
421
595
|
.default('markdown')
|
|
@@ -423,7 +597,7 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
423
597
|
sections: z
|
|
424
598
|
.array(z.string())
|
|
425
599
|
.optional()
|
|
426
|
-
.describe('
|
|
600
|
+
.describe('Entry names to retrieve, each copied verbatim from a prior outline response (kind: outline) — §/Artikel/Anlage section names, or window names of the form "Part 2 of 6". Omit for the full document, which returns an outline instead when the markdown overflows the 40,000-byte budget. A name that matches no entry is never silently ignored: a total miss returns the outline (kind: outline) with a notice, a partial miss returns the matched entries with a notice naming others to pick from. Applies to markdown only — html, xml, and urls_only are never sliced and return in full.'),
|
|
427
601
|
}),
|
|
428
602
|
output: z.object({
|
|
429
603
|
text: z
|
|
@@ -440,14 +614,14 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
440
614
|
truncated: z
|
|
441
615
|
.boolean()
|
|
442
616
|
.optional()
|
|
443
|
-
.describe('Present and true when the full text isn’t inline because
|
|
617
|
+
.describe('Present and true when the full text isn’t inline because an outline was returned instead (kind: outline) — either the document overflowed the byte budget, or a sections:[…] selector matched nothing. The notice names which. Retrieve entries via the sections input, or fetch content_urls for the whole artifact.'),
|
|
444
618
|
kind: z
|
|
445
619
|
.enum(['full', 'outline'])
|
|
446
|
-
.describe('full — the complete response (document text, selected
|
|
620
|
+
.describe('full — the complete response (document text, selected entries, or rendition URLs). outline — sections lists the retrievable entries instead of the text, either because the document overflowed the byte budget or because a sections:[…] selector matched nothing; re-call with sections:[…] naming entries from it.'),
|
|
447
621
|
sections: z
|
|
448
|
-
.array(OUTLINE_VARIANT.shape.sections.element.describe('A retrievable §/Artikel/Anlage section
|
|
622
|
+
.array(OUTLINE_VARIANT.shape.sections.element.describe('A retrievable entry — a §/Artikel/Anlage section or a Part n of N byte window, with its UTF-8 byte size.'))
|
|
449
623
|
.optional()
|
|
450
|
-
.describe('Present when kind = outline: the document’s §/Artikel/Anlage sections
|
|
624
|
+
.describe('Present when kind = outline: the document’s addressable entries, each with its UTF-8 byte size — §/Artikel/Anlage sections listed largest first, or, when the markdown carries no such headings, Part n of N byte windows listed in document order and summing to byte_size. Copy names into the sections input verbatim to retrieve them; naming several in one call returns their text concatenated in document order, with nothing inserted between them.'),
|
|
451
625
|
binding_status: z
|
|
452
626
|
.enum(BINDING_STATUSES)
|
|
453
627
|
.describe('Legal binding status: authentic (amtssigniert, legally binding), consolidated_informational (consolidated view — not binding), historical_record (superseded/pre-e-Recht promulgation), decision (court/tribunal ruling), preparatory (draft/bill/minutes), administrative_directive (binds the administration, not citizens), or translation (unofficial English).'),
|
|
@@ -475,8 +649,8 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
475
649
|
{
|
|
476
650
|
reason: 'unsupported_url',
|
|
477
651
|
code: JsonRpcErrorCode.ValidationError,
|
|
478
|
-
when: 'document_url fails the host + /Dokumente/ path-prefix allowlist,
|
|
479
|
-
recovery: 'Only ris.bka.gv.at /Dokumente/ URLs are fetchable
|
|
652
|
+
when: 'document_url fails the host + /Dokumente/ path-prefix allowlist, its path segment is not a recognized RIS application, or its trailing filename addresses neither the document’s own rendition nor one of its companion documents — thrown locally, nothing fetched.',
|
|
653
|
+
recovery: 'Only ris.bka.gv.at /Dokumente/ URLs are fetchable, and only a document’s own rendition or a companion filed in the same folder — copy the URL verbatim from a result’s content_urls or a ris_search_drafts record’s materials, or switch to document_number + application.',
|
|
480
654
|
},
|
|
481
655
|
{
|
|
482
656
|
reason: 'document_not_found',
|
|
@@ -538,9 +712,18 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
538
712
|
});
|
|
539
713
|
}
|
|
540
714
|
if (err.code === JsonRpcErrorCode.NotFound) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
715
|
+
// The contract's recovery is written for the document_number + application mode.
|
|
716
|
+
// A companion was addressed by a URL, and its likeliest 404 is a rendition RIS
|
|
717
|
+
// never filed rather than a mistyped identifier — one companion in eight is filed
|
|
718
|
+
// as PDF (or PDF and RTF) with no html or xml twin to render, nearly all of them
|
|
719
|
+
// review-draft covering letters, and RIS 404s a rendition it does not list.
|
|
720
|
+
throw ctx.fail('document_not_found', err.message, resolvedContentName === undefined
|
|
721
|
+
? { ...ctx.recoveryFor('document_not_found') }
|
|
722
|
+
: {
|
|
723
|
+
recovery: {
|
|
724
|
+
hint: `This companion document has no ${format} rendition — about one in eight is filed as a PDF with no text rendition at all, and RIS returns 404 for a rendition it does not publish. Re-call with format: urls_only and fetch content_urls.pdf yourself. If the PDF 404s too, the URL itself is stale — take a fresh one from a ris_search_drafts record's materials.`,
|
|
725
|
+
},
|
|
726
|
+
});
|
|
544
727
|
}
|
|
545
728
|
if (err.code === JsonRpcErrorCode.ServiceUnavailable) {
|
|
546
729
|
throw ctx.fail('upstream_error', err.message, { ...ctx.recoveryFor('upstream_error') });
|
|
@@ -579,13 +762,18 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
579
762
|
// roster (driven by the no-match, not by the byte budget) so the caller can tell "your
|
|
580
763
|
// names were wrong" from "that section really is that large".
|
|
581
764
|
if (requestedSections.length > 0) {
|
|
582
|
-
const selection = selectDocumentSections(rendition.text, requestedSections);
|
|
765
|
+
const selection = selectDocumentSections(rendition.text, requestedSections, format);
|
|
766
|
+
// How the roster was built — the two kinds want different re-call guidance, and for a
|
|
767
|
+
// windowed roster the outline exists *because* of the byte budget.
|
|
768
|
+
const roster = selection.windowed
|
|
769
|
+
? `${selection.available.length} contiguous byte windows, since it carries no §/Artikel/Anlage headings and overflows the ${OUTLINE_BUDGET_BYTES}-byte budget`
|
|
770
|
+
: `${selection.available.length} §/Artikel/Anlage sections`;
|
|
583
771
|
if (selection.text !== '') {
|
|
584
772
|
if (selection.unmatched.length > 0) {
|
|
585
773
|
// Names candidates from the roster rather than promising an outline on a re-call
|
|
586
774
|
// without sections:[…] — that re-call only outlines a document over the byte
|
|
587
775
|
// budget, and returns the whole text for every document under it.
|
|
588
|
-
ctx.enrich.notice(`Returned only the matched
|
|
776
|
+
ctx.enrich.notice(`Returned only the matched entries — ${quoteNames(selection.unmatched)} named no entry of this document and ${selection.unmatched.length === 1 ? 'was' : 'were'} skipped. This document has ${roster}; copy a name verbatim and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`);
|
|
589
777
|
}
|
|
590
778
|
return {
|
|
591
779
|
...base,
|
|
@@ -595,7 +783,9 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
595
783
|
};
|
|
596
784
|
}
|
|
597
785
|
if (selection.available.length > 0) {
|
|
598
|
-
ctx.enrich.notice(
|
|
786
|
+
ctx.enrich.notice(selection.windowed
|
|
787
|
+
? `sections:[…] matched nothing — ${quoteNames(selection.unmatched)} named no entry of this document. It is addressed as ${roster}. Copy a name verbatim from sections and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`
|
|
788
|
+
: `sections:[…] matched nothing — ${quoteNames(selection.unmatched)} named no section of this document, so the outline below is a selector mismatch, not a size overflow. Copy a name verbatim from sections and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`);
|
|
599
789
|
return {
|
|
600
790
|
...base,
|
|
601
791
|
kind: 'outline',
|
|
@@ -604,12 +794,18 @@ export const risGetDocument = tool('ris_get_document', {
|
|
|
604
794
|
...(byteSize !== undefined && { byte_size: byteSize }),
|
|
605
795
|
};
|
|
606
796
|
}
|
|
607
|
-
// Nothing to select from and nothing to list —
|
|
608
|
-
// no §/Artikel/Anlage headings.
|
|
609
|
-
|
|
797
|
+
// Nothing to select from and nothing to list — a raw html/xml rendition, or markdown
|
|
798
|
+
// under the budget with no §/Artikel/Anlage headings. Both halves are named: a caller
|
|
799
|
+
// told only about headings would retry the same selector on a larger document of the
|
|
800
|
+
// same shape. Say so, then return the document whole below.
|
|
801
|
+
ctx.enrich.notice(`sections:[…] was ignored — this rendition has no addressable entries, so ${quoteNames(selection.unmatched)} could not be resolved. Raw html and xml are never sliced; markdown carries entries when it has §/Artikel/Anlage headings, or once it overflows the ${OUTLINE_BUDGET_BYTES}-byte budget. The whole document follows.`);
|
|
610
802
|
}
|
|
611
|
-
// Disclosure — whole text under budget (or
|
|
612
|
-
const
|
|
803
|
+
// Disclosure — whole text under budget (or with nothing addressable), else an outline.
|
|
804
|
+
const addressable = addressableSections(rendition.text, format);
|
|
805
|
+
const size = byteSize !== undefined ? ` (${byteSize} bytes)` : '';
|
|
806
|
+
const decision = outlineDocument(rendition.text, addressable, (sections) => addressable[0]?.kind === 'window'
|
|
807
|
+
? `Document too large to return in full${size}. It carries no §/Artikel/Anlage headings, so the outline lists ${sections.length} contiguous windows covering the whole text — read them in the order they are named, and expect a window to open mid-sentence, since the cuts are line breaks rather than sentence or § boundaries. Re-call ris_get_document with the same addressing plus sections:[…] naming entries from the outline — e.g. ${exampleSectionNames(addressable)}.`
|
|
808
|
+
: `Document too large to return in full${size}. Re-call ris_get_document with the same addressing plus sections:[…] naming entries from the outline — e.g. ${exampleSectionNames(sections)}.`);
|
|
613
809
|
if (decision.kind === 'full') {
|
|
614
810
|
return {
|
|
615
811
|
...base,
|