@cyanheads/ris-austria-mcp-server 0.5.1 → 0.5.2
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 +10 -5
- package/CLAUDE.md +10 -5
- package/Dockerfile +3 -3
- package/README.md +12 -7
- package/changelog/0.5.x/0.5.2.md +46 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared.d.ts +35 -4
- package/dist/mcp-server/tools/definitions/_shared.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared.js +47 -6
- package/dist/mcp-server/tools/definitions/_shared.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts +24 -1
- 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 +132 -15
- package/dist/mcp-server/tools/definitions/ris-get-document.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.d.ts +2 -2
- package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.js +548 -64
- package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.d.ts +16 -3
- package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.js +111 -8
- package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.d.ts +6 -5
- package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.js +22 -18
- package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.d.ts +12 -2
- package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.js +45 -8
- package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.d.ts +4 -2
- package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.js +10 -8
- package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-track-changes.tool.d.ts +4 -0
- package/dist/mcp-server/tools/definitions/ris-track-changes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/ris-track-changes.tool.js +49 -2
- package/dist/mcp-server/tools/definitions/ris-track-changes.tool.js.map +1 -1
- package/dist/services/ris/normalizer.d.ts.map +1 -1
- package/dist/services/ris/normalizer.js +100 -19
- package/dist/services/ris/normalizer.js.map +1 -1
- package/dist/services/ris/reference/citation-formats.d.ts +7 -7
- package/dist/services/ris/reference/citation-formats.d.ts.map +1 -1
- package/dist/services/ris/reference/citation-formats.js +18 -6
- package/dist/services/ris/reference/citation-formats.js.map +1 -1
- package/dist/services/ris/reference/courts.d.ts +1 -1
- package/dist/services/ris/reference/courts.d.ts.map +1 -1
- package/dist/services/ris/reference/courts.js +1 -1
- package/dist/services/ris/reference/courts.js.map +1 -1
- package/dist/services/ris/reference/decision-types.d.ts +1 -1
- package/dist/services/ris/reference/decision-types.d.ts.map +1 -1
- package/dist/services/ris/reference/decision-types.js +1 -1
- package/dist/services/ris/reference/decision-types.js.map +1 -1
- package/dist/services/ris/reference/issuing-bodies.d.ts +5 -0
- package/dist/services/ris/reference/issuing-bodies.d.ts.map +1 -1
- package/dist/services/ris/reference/issuing-bodies.js +6 -0
- package/dist/services/ris/reference/issuing-bodies.js.map +1 -1
- package/dist/services/ris/reference/states.d.ts +12 -1
- package/dist/services/ris/reference/states.d.ts.map +1 -1
- package/dist/services/ris/reference/states.js +39 -1
- package/dist/services/ris/reference/states.js.map +1 -1
- package/dist/services/ris/request-builder.d.ts +2 -4
- package/dist/services/ris/request-builder.d.ts.map +1 -1
- package/dist/services/ris/request-builder.js +61 -25
- package/dist/services/ris/request-builder.js.map +1 -1
- package/dist/services/ris/types.d.ts +28 -5
- package/dist/services/ris/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/server.json +3 -3
|
@@ -20,7 +20,7 @@ import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
|
20
20
|
import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
|
|
21
21
|
import { RIS_COURTS, RIS_STATES } from '../../../services/ris/reference/index.js';
|
|
22
22
|
import { getRisService } from '../../../services/ris/ris-service.js';
|
|
23
|
-
import { isoDateString } from './_shared.js';
|
|
23
|
+
import { foldStateNames, isoDateString } from './_shared.js';
|
|
24
24
|
import { toRecord as toCaseLawRecord } from './ris-search-case-law.tool.js';
|
|
25
25
|
import { toRecord as toGazetteRecord } from './ris-search-gazette.tool.js';
|
|
26
26
|
import { toRecord as toLegislationRecord } from './ris-search-legislation.tool.js';
|
|
@@ -38,6 +38,12 @@ const COURT_APPLICATION = new Map(RIS_COURTS.map((c) => [c.code, c.application])
|
|
|
38
38
|
function courtApplication(code) {
|
|
39
39
|
return COURT_APPLICATION.get(code) ?? code;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Line breaks inside a citation (one copied across a wrapped line), read as a space before any
|
|
43
|
+
* parsing. Several parsers end on `.*$`, and `.` stops at a line break, so a citation carrying
|
|
44
|
+
* one made them rescan the rest of it from every position tried — quadratic in its length.
|
|
45
|
+
*/
|
|
46
|
+
const LINE_BREAKS = /[\n\r\p{Zl}\p{Zp}]+/gu;
|
|
41
47
|
/** Gazette prefixes that signal a promulgation citation (federal, imperial, or state). */
|
|
42
48
|
const GAZETTE_PREFIX = /\b(?:BGBl|RGBl|StGBl|LGBl)\b|GBl[ÖO]/i;
|
|
43
49
|
/** An explicit norm citation — a section sign, or an Artikel keyword followed by a number. */
|
|
@@ -69,8 +75,12 @@ function parseNorm(input) {
|
|
|
69
75
|
* "§22 Abs1" as "§ <section> <abbreviation>" and mis-parse the abbreviation to "Abs1" — and
|
|
70
76
|
* BEFORE the BARE_ABBREVIATION fallback, which would otherwise swallow "DSGVO Art32" as a
|
|
71
77
|
* literal abbreviation and resolve nothing.
|
|
78
|
+
*
|
|
79
|
+
* The abbreviation ends on a non-space and the section-first `§` run is matched from its
|
|
80
|
+
* head, so a whitespace run or a run of `§` is scanned once rather than from every position
|
|
81
|
+
* in it. The captures are the ones the plain lazy forms take.
|
|
72
82
|
*/
|
|
73
|
-
const abbrevParagraph = /^([A-Za-zÄÖÜäöüß][^§]*?)\s+§+\s*(\d+\s?[a-zA-Z]?)(?:\s+\S.*)?$/.exec(s);
|
|
83
|
+
const abbrevParagraph = /^([A-Za-zÄÖÜäöüß](?:[^§]*?[^§\s])?)\s+§+\s*(\d+\s?[a-zA-Z]?)(?:\s+\S.*)?$/.exec(s);
|
|
74
84
|
if (abbrevParagraph) {
|
|
75
85
|
const [, abbreviation, section] = abbrevParagraph;
|
|
76
86
|
if (abbreviation !== undefined && section !== undefined) {
|
|
@@ -81,7 +91,7 @@ function parseNorm(input) {
|
|
|
81
91
|
};
|
|
82
92
|
}
|
|
83
93
|
}
|
|
84
|
-
const abbrevArtikel = /^([A-Za-zÄÖÜäöüß]
|
|
94
|
+
const abbrevArtikel = /^([A-Za-zÄÖÜäöüß](?:.*?\S)?)\s+art(?:ikel)?\.?\s*(\d+\s?[a-zA-Z]?)(?:\s+\S.*)?$/i.exec(s);
|
|
85
95
|
if (abbrevArtikel) {
|
|
86
96
|
const [, abbreviation, section] = abbrevArtikel;
|
|
87
97
|
if (abbreviation !== undefined && section !== undefined) {
|
|
@@ -92,7 +102,7 @@ function parseNorm(input) {
|
|
|
92
102
|
};
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
|
-
const paragraph =
|
|
105
|
+
const paragraph = /(?<!§)§+\s*(\d+\s?[a-zA-Z]?)\s+(\S.*)$/.exec(s);
|
|
96
106
|
if (paragraph) {
|
|
97
107
|
const [, section, rest] = paragraph;
|
|
98
108
|
if (section !== undefined && rest !== undefined) {
|
|
@@ -158,6 +168,36 @@ function parseCollection(input) {
|
|
|
158
168
|
...(partLetter !== undefined && { partLetter }),
|
|
159
169
|
};
|
|
160
170
|
}
|
|
171
|
+
/** How each series is written in a citation. */
|
|
172
|
+
const SERIES_LABEL = {
|
|
173
|
+
BGBl: 'BGBl.',
|
|
174
|
+
GBlÖ: 'GBlÖ',
|
|
175
|
+
RGBl: 'RGBl.',
|
|
176
|
+
StGBl: 'StGBl.',
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* How a hit's promulgation opens, per series: BgblPdf's Fundstelle with the cite prefix
|
|
180
|
+
* ("StGBl. Nr. 1/1945"), BgblAlt's Kundmachungsorgan with the gazette title
|
|
181
|
+
* ("Staatsgesetzblatt für die Republik Österreich").
|
|
182
|
+
*/
|
|
183
|
+
const SERIES_BY_PREFIX = [
|
|
184
|
+
['StGBl.', 'StGBl'],
|
|
185
|
+
['Staatsgesetzblatt', 'StGBl'],
|
|
186
|
+
['BGBl.', 'BGBl'],
|
|
187
|
+
['Bundesgesetzblatt', 'BGBl'],
|
|
188
|
+
['RGBl.', 'RGBl'],
|
|
189
|
+
['Reichsgesetzblatt', 'RGBl'],
|
|
190
|
+
['Gesetzblatt für das Land Österreich', 'GBlÖ'],
|
|
191
|
+
];
|
|
192
|
+
/** The series a federal gazette hit belongs to, or `undefined` when it records none. */
|
|
193
|
+
function hitSeries({ metadata }) {
|
|
194
|
+
if (metadata.controller !== 'Bundesrecht')
|
|
195
|
+
return;
|
|
196
|
+
const { promulgation } = metadata;
|
|
197
|
+
if (promulgation === undefined)
|
|
198
|
+
return;
|
|
199
|
+
return SERIES_BY_PREFIX.find(([prefix]) => promulgation.startsWith(prefix))?.[1];
|
|
200
|
+
}
|
|
161
201
|
/** Bundesländer RIS carries in the historical non-authentic Lgbl gazette. */
|
|
162
202
|
const LGBL_STATES = new Set(RIS_STATES.filter((state) => state.inLgbl).map((state) => state.code));
|
|
163
203
|
/**
|
|
@@ -174,27 +214,57 @@ function legacyStateSeries(state) {
|
|
|
174
214
|
return 'LgblNO';
|
|
175
215
|
return LGBL_STATES.has(state) ? 'Lgbl' : undefined;
|
|
176
216
|
}
|
|
217
|
+
/** True for a four-digit year from 1848 on — the start of the oldest gazette RIS holds. */
|
|
218
|
+
function isGazetteYear(digits) {
|
|
219
|
+
return digits.length === 4 && Number.parseInt(digits, 10) >= 1848;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Read the number and year from the first "A/B" pair, number-first ("Nr. 24/2018") or
|
|
223
|
+
* year-first ("BGBl I 2018/24", the order commentaries cite in). B stays the year when it is a
|
|
224
|
+
* four-digit year from 1848 on; otherwise a four-digit A in that range is the year. B is checked
|
|
225
|
+
* first because pre-1997 numbers ran past 999 — "BGBl. Nr. 1005/1994" is number 1005, not a
|
|
226
|
+
* year. The lookbehind starts the match only at the head of a digit run, which keeps the scan
|
|
227
|
+
* linear over a long run of digits.
|
|
228
|
+
*/
|
|
229
|
+
function readGazetteNumber(s) {
|
|
230
|
+
const pair = /(?<!\d)(\d+)\s*\/\s*(\d+)/.exec(s);
|
|
231
|
+
const [, a, b] = pair ?? [];
|
|
232
|
+
if (a === undefined || b === undefined)
|
|
233
|
+
return null;
|
|
234
|
+
if (isGazetteYear(b))
|
|
235
|
+
return { number: `${a}/${b}`, year: Number.parseInt(b, 10) };
|
|
236
|
+
if (isGazetteYear(a))
|
|
237
|
+
return { number: `${b}/${a}`, year: Number.parseInt(a, 10) };
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/** The federal or imperial gazette a citation names by its prefix, or `undefined` for none. */
|
|
241
|
+
function citedSeries(s) {
|
|
242
|
+
if (/\bStGBl\b/i.test(s))
|
|
243
|
+
return 'StGBl';
|
|
244
|
+
if (/\bRGBl\b/i.test(s))
|
|
245
|
+
return 'RGBl';
|
|
246
|
+
if (/GBl[ÖO]/i.test(s))
|
|
247
|
+
return 'GBlÖ';
|
|
248
|
+
return /\bBGBl\b/i.test(s) ? 'BGBl' : undefined;
|
|
249
|
+
}
|
|
177
250
|
/**
|
|
178
251
|
* Parse a gazette citation and route it to the owning application: federal BGBl. by year
|
|
179
|
-
* (BgblAuth 2004+, BgblPdf 1945–2003, BgblAlt before 1945),
|
|
180
|
-
*
|
|
181
|
-
*
|
|
252
|
+
* (BgblAuth 2004+, BgblPdf 1945–2003, BgblAlt before 1945), the StGBl. by year too — RIS files
|
|
253
|
+
* the 1918–1920 Staatsgesetzblatt in BgblAlt and the 1945 one in BgblPdf, beside the BGBl. —
|
|
254
|
+
* RGBl./GBlÖ to BgblAlt, and LGBl. to the state Landesgesetzblatt (LgblAuth) when a state hint
|
|
255
|
+
* is present — carrying that state's legacy series alongside, since a pre-switch citation
|
|
256
|
+
* lives there.
|
|
182
257
|
*/
|
|
183
258
|
function parseGazette(input, stateHint) {
|
|
184
259
|
const s = input.trim();
|
|
185
260
|
const isState = /\bLGBl\b/i.test(s);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
if (!isState && !isImperial && !isFederal)
|
|
261
|
+
const series = isState ? undefined : citedSeries(s);
|
|
262
|
+
if (!isState && series === undefined)
|
|
189
263
|
return null;
|
|
190
|
-
const numYear =
|
|
264
|
+
const numYear = readGazetteNumber(s);
|
|
191
265
|
if (!numYear)
|
|
192
266
|
return null;
|
|
193
|
-
const
|
|
194
|
-
if (numberPart === undefined || yearPart === undefined)
|
|
195
|
-
return null;
|
|
196
|
-
const number = `${numberPart}/${yearPart}`;
|
|
197
|
-
const year = Number.parseInt(yearPart, 10);
|
|
267
|
+
const { number, year } = numYear;
|
|
198
268
|
let part;
|
|
199
269
|
const partMatch = /\bBGBl\.?\s*(I{1,3})\b/i.exec(s);
|
|
200
270
|
if (partMatch?.[1] !== undefined) {
|
|
@@ -210,8 +280,11 @@ function parseGazette(input, stateHint) {
|
|
|
210
280
|
...(legacy !== undefined && { legacyApplication: legacy }),
|
|
211
281
|
};
|
|
212
282
|
}
|
|
213
|
-
if (
|
|
214
|
-
return { application: 'BgblAlt', needsState: false, number };
|
|
283
|
+
if (series === 'RGBl' || series === 'GBlÖ' || (series === 'StGBl' && year < 1945)) {
|
|
284
|
+
return { application: 'BgblAlt', needsState: false, number, series };
|
|
285
|
+
}
|
|
286
|
+
if (series === 'StGBl') {
|
|
287
|
+
return { application: 'BgblPdf', needsState: false, number, series };
|
|
215
288
|
}
|
|
216
289
|
const application = year >= 2004 ? 'BgblAuth' : year >= 1945 ? 'BgblPdf' : 'BgblAlt';
|
|
217
290
|
// Parts exist only from 1997 and never on the metadata-only imperial tier.
|
|
@@ -219,38 +292,265 @@ function parseGazette(input, stateHint) {
|
|
|
219
292
|
application,
|
|
220
293
|
needsState: false,
|
|
221
294
|
number,
|
|
295
|
+
series: 'BGBl',
|
|
222
296
|
...(part !== undefined && application !== 'BgblAlt' && { part }),
|
|
223
297
|
};
|
|
224
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Geschäftszahl formats per court, first match wins. Order is load-bearing where shapes nest:
|
|
301
|
+
* the LVwG rows run before VfGH because Burgenland's "E 301/18/2026.019/006" opens like a VfGH
|
|
302
|
+
* number, BVwG's "G13 2337298-1" needs its register number to keep VfGH's "G13/2025" out, and
|
|
303
|
+
* VwGH's "Fr 2026/04/0013" runs before VfGH, whose row would claim its "Fr 2026/04" head. VfGH
|
|
304
|
+
* stores its numbers without a space ("G13/2025", "WIII1/2026") and is cited with one. BVwG is
|
|
305
|
+
* also cited with a hyphen after the chamber ("W213-2337298-1"), and older VwGH numbers carry
|
|
306
|
+
* a two-digit year ("83/16/0049").
|
|
307
|
+
*/
|
|
308
|
+
const CASE_COURT_PATTERNS = [
|
|
309
|
+
[/\bUPTS\b/i, ['upts']],
|
|
310
|
+
[/^(?:K?LVwG[-\s]|VGW-|405-)/i, ['lvwg']],
|
|
311
|
+
[/^[A-Z] [A-Z0-9]{3}\/\d{2}\/\d{4}\./, ['lvwg']],
|
|
312
|
+
[/^B-GBK\b/i, ['gbk']],
|
|
313
|
+
[/\bPVA[BK]\b/i, ['pvak']],
|
|
314
|
+
[/^VKS-/i, ['verg']],
|
|
315
|
+
[/^[WGLI]\d{2,4}(?:\s+|-)\d+-\d+/i, ['bvwg']],
|
|
316
|
+
[/^[A-Za-z]{0,3}\s?(?:\d{2}){1,2}\/\d{2}\/\d{4}\b/, ['vwgh']],
|
|
317
|
+
[/^\d{4}-\d\.\d{3}\.\d{3}\b/, ['dsk', 'dok']],
|
|
318
|
+
[/^[A-Za-z]{1,4}\s?\d+\/\d{2,4}\b/, ['vfgh']],
|
|
319
|
+
[/^\d{1,2}\s?[A-Za-zÄÖÜ]{1,4}\s?\d+\/\d{2}[a-z]?\b/, ['justiz']],
|
|
320
|
+
];
|
|
225
321
|
/**
|
|
226
322
|
* Match a Geschäftszahl to its owning court(s) by format. Returns up to two candidate courts
|
|
227
323
|
* (probed in order); the shared DSB/Dok "YYYY-0.NNN.NNN" shape yields both. An empty result
|
|
228
|
-
* means the format wasn't recognized — the caller falls back to `found: false`.
|
|
324
|
+
* means the format wasn't recognized — the caller falls back to `found: false`. Historical
|
|
325
|
+
* courts' formats are not detected; they need the court hint.
|
|
229
326
|
*/
|
|
230
327
|
function matchCaseCourts(input) {
|
|
231
328
|
const s = input.trim();
|
|
232
|
-
const
|
|
233
|
-
[/\bUPTS\b/i, ['upts']],
|
|
234
|
-
[/^LVwG-/i, ['lvwg']],
|
|
235
|
-
[/^B-GBK\b/i, ['gbk']],
|
|
236
|
-
[/\bPVA[BK]\b/i, ['pvak']],
|
|
237
|
-
[/^VKS-/i, ['verg']],
|
|
238
|
-
[/^[WLG]\d{2,4}\b/i, ['bvwg']],
|
|
239
|
-
[/^R[aou]\s+\d{4}\//i, ['vwgh']],
|
|
240
|
-
[/^\d{4}-\d\.\d{3}\.\d{3}\b/, ['dsk', 'dok']],
|
|
241
|
-
[/^[A-Za-z]{1,3}\s+\d+\/\d{2,4}\b/, ['vfgh']],
|
|
242
|
-
[/^\d{1,2}\s?[A-Za-zÄÖÜ]{1,4}\s?\d+\/\d{2}[a-z]?\b/, ['justiz']],
|
|
243
|
-
];
|
|
244
|
-
for (const [re, courts] of patterns) {
|
|
329
|
+
for (const [re, courts] of CASE_COURT_PATTERNS) {
|
|
245
330
|
if (re.test(s))
|
|
246
331
|
return [...courts];
|
|
247
332
|
}
|
|
248
333
|
return [];
|
|
249
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* A leading court name as practitioners cite it ("VfGH G 13/2025", "OGH 6 Ob 236/23h"). LVwG is
|
|
337
|
+
* deliberately absent: Steiermark's own numbers begin "LVwG " ("LVwG 47.14-2930/2026-8"), so
|
|
338
|
+
* stripping it would destroy the number — format detection finds those instead.
|
|
339
|
+
*/
|
|
340
|
+
const COURT_PREFIX = /^(vfgh|vwgh|ogh|bvwg|dsb)\s+/i;
|
|
341
|
+
const COURT_BY_PREFIX = {
|
|
342
|
+
bvwg: 'bvwg',
|
|
343
|
+
dsb: 'dsk',
|
|
344
|
+
ogh: 'justiz',
|
|
345
|
+
vfgh: 'vfgh',
|
|
346
|
+
vwgh: 'vwgh',
|
|
347
|
+
};
|
|
348
|
+
/**
|
|
349
|
+
* Digits joined by "/" or "-", which every Geschäftszahl a court prefix can name carries — and
|
|
350
|
+
* a law named after a court ("VwGH Gesetz", "VfGH Art 140 B-VG") does not.
|
|
351
|
+
*/
|
|
352
|
+
const CASE_NUMBER_SHAPE = /\d\s*[/-]\s*\d/;
|
|
353
|
+
/** Split a court-prefixed case number into its court and the number RIS stores. */
|
|
354
|
+
function stripCourtPrefix(input) {
|
|
355
|
+
const m = COURT_PREFIX.exec(input);
|
|
356
|
+
const court = m?.[1] === undefined ? undefined : COURT_BY_PREFIX[m[1].toLowerCase()];
|
|
357
|
+
if (m === null || court === undefined)
|
|
358
|
+
return null;
|
|
359
|
+
return { caseNumber: input.slice(m[0].length), court };
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The Geschäftszahl as `court` stores it. BVwG stores a space after the chamber
|
|
363
|
+
* ("W213 2337298-1"), and its search matches nothing for the hyphenated cite form.
|
|
364
|
+
*/
|
|
365
|
+
function storedCaseNumber(court, caseNumber) {
|
|
366
|
+
return court === 'bvwg' ? caseNumber.replace(/^([WGLI]\d{2,4})-(?=\d+-\d)/i, '$1 ') : caseNumber;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* ECLI court segments RIS carries, mapped to the application holding that court's decisions.
|
|
370
|
+
* Justiz segments are a court type plus its number (OGH0002, OLG0009, LG00003, BG00320); LVwG
|
|
371
|
+
* segments append the state (LVWGNI). The historical bodies carry no ECLI in RIS.
|
|
372
|
+
*/
|
|
373
|
+
const ECLI_COURTS = [
|
|
374
|
+
[/^VFGH$/, 'vfgh'],
|
|
375
|
+
[/^VWGH$/, 'vwgh'],
|
|
376
|
+
[/^(?:OGH|OLG|LG|BG)\d{4,5}$/, 'justiz'],
|
|
377
|
+
[/^BVWG$/, 'bvwg'],
|
|
378
|
+
[/^LVWG[A-Z]{2}$/, 'lvwg'],
|
|
379
|
+
[/^DSB$/, 'dsk'],
|
|
380
|
+
// The Parlamentarisches Datenschutzkomitee, whose decisions RIS files in Dsk.
|
|
381
|
+
[/^PDK$/, 'dsk'],
|
|
382
|
+
[/^PVAB$/, 'pvak'],
|
|
383
|
+
];
|
|
384
|
+
/**
|
|
385
|
+
* Read an ECLI — `ECLI:<country>:<court>:<year>:<identifier>` — or return `null` when the
|
|
386
|
+
* citation is not one. Split on colons rather than matched with one pattern, so no shape of
|
|
387
|
+
* caller text can make it backtrack.
|
|
388
|
+
*/
|
|
389
|
+
function parseEcli(input) {
|
|
390
|
+
if (!/^ECLI:/i.test(input))
|
|
391
|
+
return null;
|
|
392
|
+
const parts = input.split(':');
|
|
393
|
+
const [, country, segment, year, identifier] = parts;
|
|
394
|
+
if (parts.length !== 5 ||
|
|
395
|
+
country === undefined ||
|
|
396
|
+
segment === undefined ||
|
|
397
|
+
identifier === undefined ||
|
|
398
|
+
country === '' ||
|
|
399
|
+
segment === '' ||
|
|
400
|
+
identifier === '' ||
|
|
401
|
+
!/^\d{4}$/.test(year ?? '')) {
|
|
402
|
+
return { status: 'malformed' };
|
|
403
|
+
}
|
|
404
|
+
if (country.toUpperCase() !== 'AT')
|
|
405
|
+
return { status: 'foreign' };
|
|
406
|
+
const upper = segment.toUpperCase();
|
|
407
|
+
const court = ECLI_COURTS.find(([re]) => re.test(upper))?.[1];
|
|
408
|
+
return { segment, status: 'austrian', ...(court !== undefined && { court }) };
|
|
409
|
+
}
|
|
410
|
+
/* ------------------------------------------------------------------------------------ */
|
|
411
|
+
/* Candidate ranking — which hit on the page the citation names */
|
|
412
|
+
/* ------------------------------------------------------------------------------------ */
|
|
413
|
+
/**
|
|
414
|
+
* Hits requested per routed norm or case-number search. The ranking can only pick from the
|
|
415
|
+
* page RIS returns, and RIS sorts neither route by relevance to the cite: a bare abbreviation
|
|
416
|
+
* lists titles alphabetically (the Unternehmensgesetzbuch sits behind 12 "UGB-…" Verordnung
|
|
417
|
+
* sections), and a Geschäftszahl lists newest first, so Rechtssätze that later decisions
|
|
418
|
+
* extended outrank the decision text (2Ob1/09z: 78 matches, the text at 57 — measured
|
|
419
|
+
* 2026-10-17). A filtered search costs the same at 20 or 100 when it matches few documents
|
|
420
|
+
* (6Ob236/23h, 6 matches: 0.82 s either way); the 78-match page took 3.0 s against 1.9 s.
|
|
421
|
+
*/
|
|
422
|
+
const RANKED_PAGE_SIZE = 100;
|
|
423
|
+
/** Pick the lowest-ranked hit; RIS order breaks ties, so an all-equal page keeps its first. */
|
|
424
|
+
function pickRanked(hits, rank) {
|
|
425
|
+
let best;
|
|
426
|
+
let bestRank = Number.POSITIVE_INFINITY;
|
|
427
|
+
for (const hit of hits) {
|
|
428
|
+
const value = rank(hit);
|
|
429
|
+
if (value < bestRank) {
|
|
430
|
+
best = hit;
|
|
431
|
+
bestRank = value;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return best;
|
|
435
|
+
}
|
|
436
|
+
/** An abbreviation as compared: case-insensitive, whitespace collapsed. */
|
|
437
|
+
function comparableAbbreviation(value) {
|
|
438
|
+
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
439
|
+
}
|
|
440
|
+
/** True when `abbreviation` is the cited one plus a year ("estg 1988" for "estg"). */
|
|
441
|
+
function isCitedPlusYear(abbreviation, cited) {
|
|
442
|
+
return (abbreviation.length === cited.length + 5 &&
|
|
443
|
+
abbreviation.startsWith(`${cited} `) &&
|
|
444
|
+
/^\d{4}$/.test(abbreviation.slice(cited.length + 1)));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* True when the label is the cited section with nothing in front ("§ 1", not "Art. 2 § 1").
|
|
448
|
+
* A citation without a section has no label to prefer.
|
|
449
|
+
*/
|
|
450
|
+
function isExactSectionLabel(label, parsed) {
|
|
451
|
+
if (parsed.section === undefined)
|
|
452
|
+
return true;
|
|
453
|
+
if (label === undefined)
|
|
454
|
+
return false;
|
|
455
|
+
const compact = label.replace(/\s+/g, '').toLowerCase();
|
|
456
|
+
const section = parsed.section.toLowerCase();
|
|
457
|
+
const markers = parsed.sectionType === 'Artikel' ? ['art.', 'art', 'artikel'] : ['§'];
|
|
458
|
+
return markers.some((marker) => compact === `${marker}${section}`);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Rank a consolidated-law hit against a norm citation: the cited abbreviation (0–1), then the
|
|
462
|
+
* cited abbreviation plus a year — "EStG 1988" for "EStG" (2–3), each with the exact section
|
|
463
|
+
* label ahead of a prefixed one. A hit whose abbreviation is neither keeps its RIS position
|
|
464
|
+
* behind them (4): `Titel` matches anywhere in the title, so the first hits for "UGB" are the
|
|
465
|
+
* UGB-Formblatt-V and the UGB-Schwellenwerte-Verordnung, and a state law's abbreviation can
|
|
466
|
+
* carry a prefix the cite omits ("Stmk. BauG").
|
|
467
|
+
*/
|
|
468
|
+
function normRank(parsed) {
|
|
469
|
+
const cited = comparableAbbreviation(parsed.abbreviation);
|
|
470
|
+
return ({ metadata }) => {
|
|
471
|
+
if (metadata.controller !== 'Bundesrecht' && metadata.controller !== 'Landesrecht')
|
|
472
|
+
return 4;
|
|
473
|
+
if (metadata.abbreviation === undefined)
|
|
474
|
+
return 4;
|
|
475
|
+
const abbreviation = comparableAbbreviation(metadata.abbreviation);
|
|
476
|
+
const tier = abbreviation === cited ? 0 : isCitedPlusYear(abbreviation, cited) ? 1 : undefined;
|
|
477
|
+
if (tier === undefined)
|
|
478
|
+
return 4;
|
|
479
|
+
return tier * 2 + (isExactSectionLabel(metadata.sectionLabel, parsed) ? 0 : 1);
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* A Geschäftszahl as compared: no whitespace, lowercase, and without the parenthesized suffix
|
|
484
|
+
* or trailing "ua" (und andere) RIS attaches to a stored number — "G8/2026 (G8/2026-15)" and
|
|
485
|
+
* "E3811/2025 ua" compare as "g8/2026" and "e3811/2025". The "ua" is stripped only after a
|
|
486
|
+
* digit: a Justiz number ends in one check letter ("23h"), never two.
|
|
487
|
+
*/
|
|
488
|
+
function comparableCaseNumber(value) {
|
|
489
|
+
let compact = value.replace(/\s+/g, '').toLowerCase();
|
|
490
|
+
if (compact.endsWith(')')) {
|
|
491
|
+
const open = compact.lastIndexOf('(');
|
|
492
|
+
if (open > 0)
|
|
493
|
+
compact = compact.slice(0, open);
|
|
494
|
+
}
|
|
495
|
+
if (compact.endsWith('ua') && /\d/.test(compact.charAt(compact.length - 3))) {
|
|
496
|
+
compact = compact.slice(0, -2);
|
|
497
|
+
}
|
|
498
|
+
return compact;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Rank a case-law hit against a cited decision: its decision text (0), then a Rechtssatz the
|
|
502
|
+
* decision leads — `case_numbers[0]` is the decision a headnote was first written for (1) —
|
|
503
|
+
* then RIS order (2). A Rechtssatz merely listing the cite among later decisions is the last
|
|
504
|
+
* kind: the 1952 headnote that later decisions extended through 6Ob236/23h is not that
|
|
505
|
+
* decision.
|
|
506
|
+
*
|
|
507
|
+
* Without a cited case number (a collection number names the decision through its
|
|
508
|
+
* Sammlungsnummer instead) every decision text ranks first.
|
|
509
|
+
*/
|
|
510
|
+
function caseLawRank(citedCaseNumber) {
|
|
511
|
+
const cited = citedCaseNumber === undefined ? undefined : comparableCaseNumber(citedCaseNumber);
|
|
512
|
+
return ({ metadata }) => {
|
|
513
|
+
if (metadata.controller !== 'Judikatur')
|
|
514
|
+
return 2;
|
|
515
|
+
const { caseNumbers, decisionDocumentType } = metadata;
|
|
516
|
+
if (decisionDocumentType === 'Text') {
|
|
517
|
+
if (cited === undefined)
|
|
518
|
+
return 0;
|
|
519
|
+
if (caseNumbers.some((caseNumber) => comparableCaseNumber(caseNumber) === cited))
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
const lead = caseNumbers[0];
|
|
523
|
+
if (cited !== undefined &&
|
|
524
|
+
decisionDocumentType === 'Rechtssatz' &&
|
|
525
|
+
lead !== undefined &&
|
|
526
|
+
comparableCaseNumber(lead) === cited) {
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
return 2;
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Rank a case-law hit against a cited ECLI: the decision text carrying it (0), then a Rechtssatz
|
|
534
|
+
* carrying it (1) — VfGH and LVwG Rechtssätze carry their decision's ECLI. A hit whose own ECLI
|
|
535
|
+
* differs is never picked: a full-text search also matches a decision that quotes the cite.
|
|
536
|
+
*/
|
|
537
|
+
function ecliRank(cited) {
|
|
538
|
+
const upper = cited.toUpperCase();
|
|
539
|
+
return ({ metadata }) => {
|
|
540
|
+
if (metadata.controller !== 'Judikatur' || metadata.ecli?.toUpperCase() !== upper) {
|
|
541
|
+
return Number.POSITIVE_INFINITY;
|
|
542
|
+
}
|
|
543
|
+
return metadata.decisionDocumentType === 'Text' ? 0 : 1;
|
|
544
|
+
};
|
|
545
|
+
}
|
|
250
546
|
/** Classify a citation by shape (auto mode). Returns `null` when it doesn't classify. */
|
|
251
547
|
function classify(input, courtHint) {
|
|
252
548
|
if (parseCollection(input))
|
|
253
549
|
return 'collection_number';
|
|
550
|
+
if (parseEcli(input) !== null)
|
|
551
|
+
return 'case_number';
|
|
552
|
+
if (COURT_PREFIX.test(input) && CASE_NUMBER_SHAPE.test(input))
|
|
553
|
+
return 'case_number';
|
|
254
554
|
if (GAZETTE_PREFIX.test(input))
|
|
255
555
|
return 'gazette';
|
|
256
556
|
if (NORM_EXPLICIT.test(input))
|
|
@@ -265,7 +565,23 @@ function classify(input, courtHint) {
|
|
|
265
565
|
/* found: false guidance (verbatim per parse outcome — design.md) */
|
|
266
566
|
/* ------------------------------------------------------------------------------------ */
|
|
267
567
|
function unknownGuidance(input) {
|
|
268
|
-
return `Could not classify '${input}'. Expected forms — norm: '§ 6 DSG' / 'Art 10 B-VG'; gazette: 'BGBl. I Nr. 165/1999' (also pre-2004 and RGBl/StGBl forms, and LGBl with a state hint); case number: 'Ro 2026/03/0016'; collection: 'VfSlg 19.632/2012'. Formats: ris_list_reference topic citation_formats. Or set kind explicitly; for keyword search use ris_search_legislation / ris_search_case_law.`;
|
|
568
|
+
return `Could not classify '${input}'. Expected forms — norm: '§ 6 DSG' / 'Art 10 B-VG'; gazette: 'BGBl. I Nr. 165/1999' or year-first 'BGBl I 2018/24' (also pre-2004 and RGBl/StGBl forms, and LGBl with a state hint); case number: 'Ro 2026/03/0016', court-prefixed 'VfGH G 13/2025', or an ECLI 'ECLI:AT:VFGH:2026:G13.2025'; collection: 'VfSlg 19.632/2012'. Formats: ris_list_reference topic citation_formats. Or set kind explicitly; for keyword search use ris_search_legislation / ris_search_case_law.`;
|
|
569
|
+
}
|
|
570
|
+
/** The ECLI court segments this lookup maps, as listed in guidance. */
|
|
571
|
+
const MAPPED_ECLI_SEGMENTS = 'VFGH, VWGH, OGH/OLG/LG/BG with a court number (Justiz), BVWG, LVWG with a state code (LVWGNI), DSB, PDK, PVAB';
|
|
572
|
+
/** Guidance for an ECLI that names no searchable court — nothing was searched. */
|
|
573
|
+
function ecliParseGuidance(citation, parsed) {
|
|
574
|
+
if (parsed.status === 'foreign') {
|
|
575
|
+
return `'${citation}' is not an Austrian ECLI — RIS carries Austrian decisions only, so an ECLI resolves here when it starts 'ECLI:AT:'.`;
|
|
576
|
+
}
|
|
577
|
+
if (parsed.status === 'austrian') {
|
|
578
|
+
return `No court this lookup knows uses the ECLI court segment '${parsed.segment}' in '${citation}'. Mapped segments: ${MAPPED_ECLI_SEGMENTS}. Pass court to search that court for the ECLI.`;
|
|
579
|
+
}
|
|
580
|
+
return `Could not read an ECLI from '${citation}'. An Austrian ECLI has five colon-separated parts — 'ECLI:AT:VFGH:2026:G13.2025': country, court, decision year, and the court's own identifier. Formats: ris_list_reference topic citation_formats.`;
|
|
581
|
+
}
|
|
582
|
+
/** Guidance for an ECLI whose court was searched without a hit carrying it. */
|
|
583
|
+
function ecliGuidance(citation, court) {
|
|
584
|
+
return `No decision in ${courtApplication(court)} carries ECLI '${citation}' — a full-text search for it found no document with that identifier. Verify the ECLI against the cite, or resolve the decision by its Geschäftszahl with court: ${court}. Keyword fallback: ris_search_case_law court: ${court} with query.`;
|
|
269
585
|
}
|
|
270
586
|
/**
|
|
271
587
|
* Guidance for an unresolved norm citation. Takes the whole parse so the section type reaches
|
|
@@ -316,6 +632,9 @@ function gazetteGuidance(parsed, state) {
|
|
|
316
632
|
if (application === 'BgblAlt') {
|
|
317
633
|
return `No gazette entry for ${number} in BgblAlt, which covers 1848–1940 and carries no part split — RIS holds no federal gazette for 1941–1944. Verify the number and year against the cite; browse the surrounding range with ris_search_gazette published_from/published_to to find the actual number.`;
|
|
318
634
|
}
|
|
635
|
+
if (parsed.series === 'StGBl') {
|
|
636
|
+
return `No gazette entry for ${number} in ${application}. RIS carries the Staatsgesetzblatt for 1918–1920 (BgblAlt) and 1945 (BgblPdf); from 1946 the federal gazette is the BGBl. Verify the number and year against the cite; browse the surrounding range with ris_search_gazette published_from/published_to to find the actual number.`;
|
|
637
|
+
}
|
|
319
638
|
const partLabel = part === undefined ? undefined : 'I'.repeat(Number(part.slice(-1)));
|
|
320
639
|
const partHint = partLabel === undefined
|
|
321
640
|
? 'No part filter was applied, so the number or the year is the mismatch.'
|
|
@@ -325,9 +644,81 @@ function gazetteGuidance(parsed, state) {
|
|
|
325
644
|
: '';
|
|
326
645
|
return `No gazette entry for ${number} in ${application}. ${partHint}${pre1997} Browse the surrounding range with ris_search_gazette published_from/published_to to find the actual number.`;
|
|
327
646
|
}
|
|
647
|
+
/** Join cites as "A and B" or "A, B and C". */
|
|
648
|
+
function joinCites(cites) {
|
|
649
|
+
return cites.length > 1 ? `${cites.slice(0, -1).join(', ')} and ${cites.at(-1)}` : cites.join('');
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Split a gazette page by the cited series. RIS files several gazettes in one application
|
|
653
|
+
* under one number space — the 1945 Staatsgesetzblatt beside the BGBl. in BgblPdf ("1/1945" is
|
|
654
|
+
* both a proclamation and a currency ordinance), and in BgblAlt the gazettes that overlap in a
|
|
655
|
+
* changeover year (RGBl./StGBl. 1918, StGBl./BGBl. 1920, BGBl./GBlÖ 1938) — so a number search
|
|
656
|
+
* returns entries from gazettes the citation did not name. Those are `others`, cited by their
|
|
657
|
+
* own series; a hit recording no gazette stays eligible, behind the ones confirmed to match.
|
|
658
|
+
* `otherHits` counts those hits, which `others` (one cite per series) can undercount.
|
|
659
|
+
*/
|
|
660
|
+
function splitBySeries(hits, cited, number) {
|
|
661
|
+
if (cited === undefined)
|
|
662
|
+
return { eligible: [...hits], otherHits: 0, others: [] };
|
|
663
|
+
const eligible = [];
|
|
664
|
+
const others = new Set();
|
|
665
|
+
for (const hit of hits) {
|
|
666
|
+
const series = hitSeries(hit);
|
|
667
|
+
if (series === undefined || series === cited)
|
|
668
|
+
eligible.push(hit);
|
|
669
|
+
else
|
|
670
|
+
others.add(`${SERIES_LABEL[series]} Nr. ${number}`);
|
|
671
|
+
}
|
|
672
|
+
return { eligible, otherHits: hits.length - eligible.length, others: [...others] };
|
|
673
|
+
}
|
|
674
|
+
/** "an entry in another gazette series" or its plural, closing on what that makes it. */
|
|
675
|
+
function otherSeriesPhrase(others) {
|
|
676
|
+
return others.length === 1
|
|
677
|
+
? 'an entry in another gazette series that shares its numbering'
|
|
678
|
+
: 'entries in other gazette series that share its numbering';
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Guidance for a gazette number that matched only entries of gazettes other than the cited
|
|
682
|
+
* one. The miss is the series, not the number, so the retry names the entry that did match.
|
|
683
|
+
*/
|
|
684
|
+
function gazetteSeriesGuidance(parsed, application, others) {
|
|
685
|
+
const { number, part, series } = parsed;
|
|
686
|
+
const label = series === undefined ? 'gazette' : SERIES_LABEL[series];
|
|
687
|
+
const recipe = `number: ${JSON.stringify(number)}${part !== undefined ? `, part: ${part}` : ''}`;
|
|
688
|
+
return `No ${label} entry for ${number} in ${application} — number "${number}" matched only ${joinCites(others)}, ${otherSeriesPhrase(others)}. Verify the series against the cite; if ${others[0]} is the entry meant, retry with that citation. All matches: ris_search_gazette ${recipe}.`;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Distinct federal gazette parts across one result page, in part order, as Roman numerals —
|
|
692
|
+
* RIS carries the part as `Teil1`/`Teil2`/`Teil3`, and no part at all before 1997.
|
|
693
|
+
*/
|
|
694
|
+
function distinctGazetteParts(hits) {
|
|
695
|
+
const parts = new Set();
|
|
696
|
+
for (const { metadata } of hits) {
|
|
697
|
+
if (metadata.controller === 'Bundesrecht' && metadata.part !== undefined) {
|
|
698
|
+
parts.add(metadata.part);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return [...parts].sort().map((part) => {
|
|
702
|
+
const digit = Number(part.slice(-1));
|
|
703
|
+
return digit >= 1 && digit <= 3 ? 'I'.repeat(digit) : part;
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Guidance for a partless federal BGBl. number that matched more than one part. From 1997 the
|
|
708
|
+
* three parts number independently, so "BGBl. Nr. 50/2025" is three documents — a law, an
|
|
709
|
+
* ordinance, and a treaty notice — and returning the first would answer a cite the caller may
|
|
710
|
+
* not have meant. Mirrors the VwSlg collision: each candidate named, the retry stated.
|
|
711
|
+
*/
|
|
712
|
+
function gazetteAmbiguityGuidance(number, parts) {
|
|
713
|
+
const cites = parts.map((part) => `BGBl. ${part} Nr. ${number}`);
|
|
714
|
+
return `BGBl. Nr. ${number} names more than one gazette entry — number "${number}" matched ${joinCites(cites)}. From 1997 the federal gazette runs three parts that reuse numbers (I laws, II ordinances, III treaties), so the part is what picks one — retry with it, e.g. '${cites[0]}'. All matches: ris_search_gazette number: "${number}".`;
|
|
715
|
+
}
|
|
328
716
|
function caseGuidance(gz, candidates) {
|
|
329
|
-
|
|
330
|
-
|
|
717
|
+
return `No decision for '${gz}' in ${candidates.map(courtApplication).join(', ')}. Pass court explicitly if known — Geschäftszahl format examples per court: ris_list_reference topic courts. Note Justiz carries selected decisions only. Keyword fallback: ris_search_case_law with query.`;
|
|
718
|
+
}
|
|
719
|
+
/** Guidance for a case number whose format names no court, under a forced kind — nothing ran. */
|
|
720
|
+
function undetectedCourtGuidance(gz) {
|
|
721
|
+
return `Could not tell which court '${gz}' belongs to from its format, so no court was searched. Pass court — Geschäftszahl format examples per court: ris_list_reference topic courts. Keyword fallback: ris_search_case_law with query.`;
|
|
331
722
|
}
|
|
332
723
|
/**
|
|
333
724
|
* Guidance for an unresolved collection citation. Names the `Sammlungsnummer` value that was
|
|
@@ -349,7 +740,18 @@ function collectionGuidance(parsed) {
|
|
|
349
740
|
* answer a decision the caller did not cite.
|
|
350
741
|
*/
|
|
351
742
|
function collectionAmbiguityGuidance(parsed, matched) {
|
|
352
|
-
return `${parsed.label} ${parsed.number} names more than one decision — Sammlungsnummer "${parsed.filter}" matched ${matched.join(' and ')}. VwGH runs two collection series that reuse numbers (A administrative, F finance), so the part letter is what picks one — retry with the full cite. All matches: ris_search_case_law court: vwgh, collection_number
|
|
743
|
+
return `${parsed.label} ${parsed.number} names more than one decision — Sammlungsnummer "${parsed.filter}" matched ${matched.join(' and ')}. VwGH runs two collection series that reuse numbers (A administrative, F finance), so the part letter is what picks one — retry with the full cite. All matches: ris_search_case_law court: vwgh, collection_number: ${JSON.stringify(parsed.filter)}.`;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* The "N more matched" tail of a resolution note: the search-tool call that lists every match,
|
|
747
|
+
* written as that tool's own inputs (`key: value`, free text JSON-quoted, codes bare) carrying
|
|
748
|
+
* the filter the lookup searched with. Without the filter the call lists the whole court,
|
|
749
|
+
* collection, or gazette rather than the alternatives counted. Empty when nothing else matched.
|
|
750
|
+
*/
|
|
751
|
+
function alternativesRecipe(alternatives, searchTool, inputs) {
|
|
752
|
+
if (alternatives === undefined)
|
|
753
|
+
return '';
|
|
754
|
+
return ` ${alternatives} more matched — list them with ${searchTool} ${inputs.join(', ')}.`;
|
|
353
755
|
}
|
|
354
756
|
/** Distinct `Sammlungsnummer` values across one case-law result page, in hit order. */
|
|
355
757
|
function distinctCollectionNumbers(hits) {
|
|
@@ -428,12 +830,12 @@ function todayInAustria() {
|
|
|
428
830
|
}
|
|
429
831
|
export const risLookupCitation = tool('ris_lookup_citation', {
|
|
430
832
|
title: 'Resolve Austrian Legal Citation',
|
|
431
|
-
description: 'Resolve a single Austrian legal citation to its canonical RIS document deterministically — no keyword search. Four routes are auto-detected from the citation shape (or forced with kind): a norm citation — section-first ("§ 6 DSG", "Art 10 B-VG"), abbreviation-first ("DSG §1", "DSGVO Art32", the shape ris_search_case_law returns in norms_cited), or a bare abbreviation like "ABGB" — resolves through consolidated federal law, or a Bundesland with a state hint, as in force today or on in_force_as_of; a gazette citation ("BGBl. I Nr. 165/1999", pre-2004 "BGBl. Nr. 194/1961", imperial "RGBl. Nr. 189/1902", or "LGBl. Nr. 61/2026" with a state hint) routes to the right federal era tier by year
|
|
833
|
+
description: 'Resolve a single Austrian legal citation to its canonical RIS document deterministically — no keyword search. Four routes are auto-detected from the citation shape (or forced with kind): a norm citation — section-first ("§ 6 DSG", "Art 10 B-VG"), abbreviation-first ("DSG §1", "DSGVO Art32", the shape ris_search_case_law returns in norms_cited), or a bare abbreviation like "ABGB" — resolves through consolidated federal law, or a Bundesland with a state hint, as in force today or on in_force_as_of; a gazette citation ("BGBl. I Nr. 165/1999", year-first "BGBl I 2018/24", pre-2004 "BGBl. Nr. 194/1961", imperial "RGBl. Nr. 189/1902", "StGBl. Nr. 1/1945", or "LGBl. Nr. 61/2026" with a state hint) routes to the right federal era tier by year — the 1945 Staatsgesetzblatt sits in the 1945–2003 tier beside the BGBl. — or to a state Landesgesetzblatt — falling back to that Bundesland’s pre-e-Recht series when the citation predates its switch; a case number ("Ro 2026/03/0016", "G 287/2022", "14Os49/26a", "2025-0.934.677", "W256 …") is matched to its court — a leading VfGH, VwGH, OGH, BVwG, or DSB names it ("VfGH G 13/2025"), court skips detection, and ambiguous formats probe up to two courts; an ECLI ("ECLI:AT:VFGH:2026:G13.2025") resolves to the decision that carries it; a collection number ("VfSlg 19.632/2012", "VwSlg 18.000 A/2010") resolves through the VfGH/VwGH collection — a VwSlg cite given without its part letter can name one decision in each of the two VwGH series, and comes back as ambiguous with both cites named rather than resolved to one of them. Matches are ranked before one is picked: a norm resolves to the hit whose abbreviation is the cited one, then the cited one plus a year ("EStG" → EStG 1988), the exact §/Artikel label ahead of a prefixed one ("§ 1" before "Art. 2 § 1"); a case number resolves to the decision text, then to a Rechtssatz that decision leads; a gazette cite resolves only to an entry of the gazette it names (StGBl. and BGBl. share numbers in 1945, as the older gazettes do in their changeover years); and a "BGBl. Nr." cite without its part that matches more than one of parts I/II/III (from 1997 they reuse numbers) comes back as ambiguous with each part named. Returns the single best-matching document in the same shape as the corresponding search tool, with alternatives_count when more than one matched. A citation that cannot be classified or resolved returns found: false with next-step guidance — it never throws for a miss; only an upstream RIS outage is an error. For keyword rather than citation lookup, use ris_search_legislation or ris_search_case_law.',
|
|
432
834
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
433
835
|
input: z.object({
|
|
434
836
|
citation: z
|
|
435
837
|
.string()
|
|
436
|
-
.describe('The legal citation to resolve, e.g. "§ 6 DSG" or "DSG §1", "Art 10 B-VG", "BGBl. I Nr. 165/1999", "Ro 2026/03/0016", or "VfSlg 19.632/2012".'),
|
|
838
|
+
.describe('The legal citation to resolve, e.g. "§ 6 DSG" or "DSG §1", "Art 10 B-VG", "BGBl. I Nr. 165/1999" or "BGBl I 2018/24", "Ro 2026/03/0016" or "VfGH G 13/2025", "ECLI:AT:VFGH:2026:G13.2025", or "VfSlg 19.632/2012".'),
|
|
437
839
|
kind: z
|
|
438
840
|
.enum(['auto', 'norm', 'gazette', 'case_number', 'collection_number'])
|
|
439
841
|
.default('auto')
|
|
@@ -441,11 +843,8 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
441
843
|
court: z
|
|
442
844
|
.enum(CASE_NUMBER_COURT_CODES)
|
|
443
845
|
.optional()
|
|
444
|
-
.describe('Court hint for a case number — short-circuits court detection to this court. Codes: ris_list_reference topic courts, minus normenliste (a norm index, which carries no case numbers).'),
|
|
445
|
-
state: z
|
|
446
|
-
.enum(STATE_CODES)
|
|
447
|
-
.optional()
|
|
448
|
-
.describe('Bundesland hint — routes a norm to that state’s consolidated law (LrKons) and an LGBl. gazette to that state’s Landesgesetzblatt (LgblAuth, then the legacy Lgbl series for a citation predating the state’s e-Recht switch).'),
|
|
846
|
+
.describe('Court hint for a case number — short-circuits court detection, and a leading court name, to this court. An ECLI’s own court segment takes precedence; the hint is searched only for a segment this lookup does not map. Codes: ris_list_reference topic courts, minus normenliste (a norm index, which carries no case numbers).'),
|
|
847
|
+
state: foldStateNames(z.enum(STATE_CODES).optional()).describe('Bundesland hint — routes a norm to that state’s consolidated law (LrKons) and an LGBl. gazette to that state’s Landesgesetzblatt (LgblAuth, then the legacy Lgbl series for a citation predating the state’s e-Recht switch).'),
|
|
449
848
|
in_force_as_of: isoDateString
|
|
450
849
|
.optional()
|
|
451
850
|
.describe('For a norm, resolve the version in force on this date (YYYY-MM-DD). Defaults to today in Austria.'),
|
|
@@ -468,7 +867,7 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
468
867
|
},
|
|
469
868
|
],
|
|
470
869
|
async handler(input, ctx) {
|
|
471
|
-
const citation = input.citation.trim();
|
|
870
|
+
const citation = input.citation.replace(LINE_BREAKS, ' ').trim();
|
|
472
871
|
const courtHint = input.court;
|
|
473
872
|
const stateHint = input.state;
|
|
474
873
|
const asOf = input.in_force_as_of ?? todayInAustria();
|
|
@@ -511,7 +910,7 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
511
910
|
};
|
|
512
911
|
}
|
|
513
912
|
const result = await runSearch(() => getRisService().searchCaseLaw({ collectionNumber: parsed.filter, court: parsed.court }, ctx));
|
|
514
|
-
const hit = result
|
|
913
|
+
const hit = result ? pickRanked(result.hits, caseLawRank(undefined)) : undefined;
|
|
515
914
|
if (!result || hit === undefined) {
|
|
516
915
|
return {
|
|
517
916
|
found: false,
|
|
@@ -542,7 +941,7 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
542
941
|
found: true,
|
|
543
942
|
kind: 'collection_number',
|
|
544
943
|
record: toCaseLawRecord(hit, application),
|
|
545
|
-
resolution_note: `Resolved via ris_search_case_law (${application}) — Sammlungsnummer "${parsed.filter}".${alternatives
|
|
944
|
+
resolution_note: `Resolved via ris_search_case_law (${application}) — Sammlungsnummer "${parsed.filter}".${alternativesRecipe(alternatives, 'ris_search_case_law', [`court: ${parsed.court}`, `collection_number: ${JSON.stringify(parsed.filter)}`])}`,
|
|
546
945
|
...(alternatives !== undefined && { alternatives_count: alternatives }),
|
|
547
946
|
};
|
|
548
947
|
}
|
|
@@ -559,6 +958,7 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
559
958
|
const params = {
|
|
560
959
|
application,
|
|
561
960
|
inForceAsOf: asOf,
|
|
961
|
+
pageSize: RANKED_PAGE_SIZE,
|
|
562
962
|
title: parsed.abbreviation,
|
|
563
963
|
...(parsed.section !== undefined && {
|
|
564
964
|
sectionFrom: parsed.section,
|
|
@@ -568,7 +968,7 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
568
968
|
...(stateHint !== undefined && { state: stateHint }),
|
|
569
969
|
};
|
|
570
970
|
const result = await runSearch(() => getRisService().searchLegislation(params, ctx));
|
|
571
|
-
const hit = result
|
|
971
|
+
const hit = result ? pickRanked(result.hits, normRank(parsed)) : undefined;
|
|
572
972
|
if (!result || hit === undefined) {
|
|
573
973
|
return { found: false, kind: 'norm', guidance: normGuidance(parsed, asOf) };
|
|
574
974
|
}
|
|
@@ -576,12 +976,24 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
576
976
|
const sectionPhrase = parsed.section !== undefined
|
|
577
977
|
? `, ${sectionMarker(parsed.sectionType)} ${parsed.section}`
|
|
578
978
|
: '';
|
|
979
|
+
const recipe = [
|
|
980
|
+
`title: ${JSON.stringify(parsed.abbreviation)}`,
|
|
981
|
+
...(parsed.section !== undefined
|
|
982
|
+
? [
|
|
983
|
+
`section_from: ${JSON.stringify(parsed.section)}`,
|
|
984
|
+
`section_to: ${JSON.stringify(parsed.section)}`,
|
|
985
|
+
`section_type: ${parsed.sectionType ?? 'Paragraph'}`,
|
|
986
|
+
]
|
|
987
|
+
: []),
|
|
988
|
+
`in_force_as_of: ${JSON.stringify(asOf)}`,
|
|
989
|
+
...(stateHint !== undefined ? [`scope: ${stateHint}`] : []),
|
|
990
|
+
];
|
|
579
991
|
ctx.log.info('Citation resolved', { kind: route, application, total: result.total });
|
|
580
992
|
return {
|
|
581
993
|
found: true,
|
|
582
994
|
kind: 'norm',
|
|
583
995
|
record: toLegislationRecord(hit, application),
|
|
584
|
-
resolution_note: `Resolved via ris_search_legislation (${application}) — title "${parsed.abbreviation}"${sectionPhrase}, in force ${asOf}.${alternatives
|
|
996
|
+
resolution_note: `Resolved via ris_search_legislation (${application}) — title "${parsed.abbreviation}"${sectionPhrase}, in force ${asOf}.${alternativesRecipe(alternatives, 'ris_search_legislation', recipe)}`,
|
|
585
997
|
...(alternatives !== undefined && { alternatives_count: alternatives }),
|
|
586
998
|
};
|
|
587
999
|
}
|
|
@@ -610,27 +1022,60 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
610
1022
|
(application === 'LgblAuth' || application === 'Lgbl') && { state: stateHint }),
|
|
611
1023
|
};
|
|
612
1024
|
const result = await runSearch(() => getRisService().searchGazette(params, ctx));
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
resolved = { application, hit, total: result.total };
|
|
1025
|
+
if (result && result.hits.length > 0) {
|
|
1026
|
+
resolved = { application, hits: result.hits, total: result.total };
|
|
616
1027
|
break;
|
|
617
1028
|
}
|
|
618
1029
|
}
|
|
619
1030
|
if (resolved === undefined) {
|
|
620
1031
|
return { found: false, kind: 'gazette', guidance: gazetteGuidance(parsed, stateHint) };
|
|
621
1032
|
}
|
|
622
|
-
const
|
|
1033
|
+
const { eligible, otherHits, others } = splitBySeries(resolved.hits, parsed.series, parsed.number);
|
|
1034
|
+
const hit = pickRanked(eligible, (candidate) => hitSeries(candidate) === parsed.series ? 0 : 1);
|
|
1035
|
+
if (hit === undefined) {
|
|
1036
|
+
return {
|
|
1037
|
+
found: false,
|
|
1038
|
+
kind: 'gazette',
|
|
1039
|
+
guidance: gazetteSeriesGuidance(parsed, resolved.application, others),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
if (parsed.part === undefined &&
|
|
1043
|
+
(resolved.application === 'BgblAuth' || resolved.application === 'BgblPdf')) {
|
|
1044
|
+
const parts = distinctGazetteParts(eligible);
|
|
1045
|
+
if (parts.length > 1) {
|
|
1046
|
+
return {
|
|
1047
|
+
found: false,
|
|
1048
|
+
kind: 'gazette',
|
|
1049
|
+
guidance: gazetteAmbiguityGuidance(parsed.number, parts),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// An entry of another gazette is a different document that shares the number, not an
|
|
1054
|
+
// alternative reading of this cite — it is named, and kept out of the count.
|
|
1055
|
+
const sameSeries = resolved.total - otherHits;
|
|
1056
|
+
const alternatives = sameSeries > 1 ? sameSeries - 1 : undefined;
|
|
1057
|
+
const othersPhrase = others.length > 0
|
|
1058
|
+
? ` Number "${parsed.number}" also names ${joinCites(others)}, ${otherSeriesPhrase(others)} — ${others.length === 1 ? 'not an alternative' : 'not alternatives'}.`
|
|
1059
|
+
: '';
|
|
623
1060
|
const partPhrase = parsed.part !== undefined ? `, part ${parsed.part.slice(-1)}` : '';
|
|
624
1061
|
/*
|
|
625
1062
|
* Both halves of the state recipe — a number alone reproduces nothing without the scope.
|
|
626
1063
|
* Only the two state series took the hint, so a stray state on a federal citation must
|
|
627
1064
|
* not be reported as a filter that was applied.
|
|
628
1065
|
*/
|
|
629
|
-
const
|
|
1066
|
+
const stateScope = stateHint !== undefined &&
|
|
630
1067
|
(resolved.application === 'LgblAuth' || resolved.application === 'Lgbl')
|
|
631
|
-
?
|
|
632
|
-
:
|
|
1068
|
+
? `scope: ${stateHint}`
|
|
1069
|
+
: undefined;
|
|
1070
|
+
const scopePhrase = stateScope !== undefined ? `, ${stateScope}` : '';
|
|
633
1071
|
const eraPhrase = resolved.application === 'Lgbl' ? ', state_era: legacy' : '';
|
|
1072
|
+
// A federal number routes ris_search_gazette to the same era tier by its year.
|
|
1073
|
+
const recipe = [
|
|
1074
|
+
`number: ${JSON.stringify(parsed.number)}`,
|
|
1075
|
+
...(parsed.part !== undefined ? [`part: ${parsed.part}`] : []),
|
|
1076
|
+
...(stateScope !== undefined ? [stateScope] : []),
|
|
1077
|
+
...(resolved.application === 'Lgbl' ? ['state_era: legacy'] : []),
|
|
1078
|
+
];
|
|
634
1079
|
ctx.log.info('Citation resolved', {
|
|
635
1080
|
application: resolved.application,
|
|
636
1081
|
kind: route,
|
|
@@ -639,27 +1084,66 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
639
1084
|
return {
|
|
640
1085
|
found: true,
|
|
641
1086
|
kind: 'gazette',
|
|
642
|
-
record: toGazetteRecord(
|
|
643
|
-
resolution_note: `Resolved via ris_search_gazette (${resolved.application}) — number "${parsed.number}"${partPhrase}${scopePhrase}${eraPhrase}.${
|
|
1087
|
+
record: toGazetteRecord(hit, resolved.application),
|
|
1088
|
+
resolution_note: `Resolved via ris_search_gazette (${resolved.application}) — number "${parsed.number}"${partPhrase}${scopePhrase}${eraPhrase}.${othersPhrase}${alternativesRecipe(alternatives, 'ris_search_gazette', recipe)}`,
|
|
644
1089
|
...(alternatives !== undefined && { alternatives_count: alternatives }),
|
|
645
1090
|
};
|
|
646
1091
|
}
|
|
647
1092
|
// route === 'case_number'
|
|
648
|
-
const
|
|
1093
|
+
const ecli = parseEcli(citation);
|
|
1094
|
+
if (ecli !== null) {
|
|
1095
|
+
/*
|
|
1096
|
+
* RIS has no ECLI filter, so the quoted ECLI is searched as full text across both
|
|
1097
|
+
* document types. A mapped court segment is authoritative; the hint only stands in for
|
|
1098
|
+
* a segment this lookup does not map.
|
|
1099
|
+
*/
|
|
1100
|
+
const court = ecli.status === 'austrian' ? (ecli.court ?? courtHint) : undefined;
|
|
1101
|
+
if (court === undefined) {
|
|
1102
|
+
return {
|
|
1103
|
+
found: false,
|
|
1104
|
+
kind: 'case_number',
|
|
1105
|
+
guidance: ecliParseGuidance(citation, ecli),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
const query = `"${citation}"`;
|
|
1109
|
+
const result = await runSearch(() => getRisService().searchCaseLaw({ court, pageSize: RANKED_PAGE_SIZE, query }, ctx));
|
|
1110
|
+
const hit = result ? pickRanked(result.hits, ecliRank(citation)) : undefined;
|
|
1111
|
+
if (!result || hit === undefined) {
|
|
1112
|
+
return { found: false, kind: 'case_number', guidance: ecliGuidance(citation, court) };
|
|
1113
|
+
}
|
|
1114
|
+
const application = courtApplication(court);
|
|
1115
|
+
const alternatives = result.total > 1 ? result.total - 1 : undefined;
|
|
1116
|
+
ctx.log.info('Citation resolved', { application, kind: route, total: result.total });
|
|
1117
|
+
return {
|
|
1118
|
+
found: true,
|
|
1119
|
+
kind: 'case_number',
|
|
1120
|
+
record: toCaseLawRecord(hit, application),
|
|
1121
|
+
resolution_note: `Resolved via ris_search_case_law (${application}) — ECLI "${citation}".${alternativesRecipe(alternatives, 'ris_search_case_law', [`court: ${court}`, `query: ${JSON.stringify(query)}`])}`,
|
|
1122
|
+
...(alternatives !== undefined && { alternatives_count: alternatives }),
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const prefixed = stripCourtPrefix(citation);
|
|
1126
|
+
const caseNumber = prefixed?.caseNumber ?? citation;
|
|
1127
|
+
const candidates = courtHint !== undefined
|
|
1128
|
+
? [courtHint]
|
|
1129
|
+
: prefixed !== null
|
|
1130
|
+
? [prefixed.court]
|
|
1131
|
+
: matchCaseCourts(caseNumber);
|
|
649
1132
|
if (candidates.length === 0) {
|
|
650
|
-
return { found: false, kind: 'case_number', guidance:
|
|
1133
|
+
return { found: false, kind: 'case_number', guidance: undetectedCourtGuidance(caseNumber) };
|
|
651
1134
|
}
|
|
652
1135
|
let resolved;
|
|
653
1136
|
for (const court of candidates) {
|
|
654
|
-
const
|
|
655
|
-
const
|
|
1137
|
+
const stored = storedCaseNumber(court, caseNumber);
|
|
1138
|
+
const result = await runSearch(() => getRisService().searchCaseLaw({ caseNumber: stored, court, pageSize: RANKED_PAGE_SIZE }, ctx));
|
|
1139
|
+
const hit = result ? pickRanked(result.hits, caseLawRank(stored)) : undefined;
|
|
656
1140
|
if (result && hit !== undefined) {
|
|
657
|
-
resolved = { court, hit, total: result.total };
|
|
1141
|
+
resolved = { caseNumber: stored, court, hit, total: result.total };
|
|
658
1142
|
break;
|
|
659
1143
|
}
|
|
660
1144
|
}
|
|
661
1145
|
if (resolved === undefined) {
|
|
662
|
-
return { found: false, kind: 'case_number', guidance: caseGuidance(
|
|
1146
|
+
return { found: false, kind: 'case_number', guidance: caseGuidance(caseNumber, candidates) };
|
|
663
1147
|
}
|
|
664
1148
|
const application = courtApplication(resolved.court);
|
|
665
1149
|
const alternatives = resolved.total > 1 ? resolved.total - 1 : undefined;
|
|
@@ -668,7 +1152,7 @@ export const risLookupCitation = tool('ris_lookup_citation', {
|
|
|
668
1152
|
found: true,
|
|
669
1153
|
kind: 'case_number',
|
|
670
1154
|
record: toCaseLawRecord(resolved.hit, application),
|
|
671
|
-
resolution_note: `Resolved via ris_search_case_law (${application}) — Geschäftszahl "${
|
|
1155
|
+
resolution_note: `Resolved via ris_search_case_law (${application}) — Geschäftszahl "${resolved.caseNumber}".${alternativesRecipe(alternatives, 'ris_search_case_law', [`court: ${resolved.court}`, `case_number: ${JSON.stringify(resolved.caseNumber)}`])}`,
|
|
672
1156
|
...(alternatives !== undefined && { alternatives_count: alternatives }),
|
|
673
1157
|
};
|
|
674
1158
|
},
|