@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.
Files changed (70) hide show
  1. package/AGENTS.md +10 -5
  2. package/CLAUDE.md +10 -5
  3. package/Dockerfile +3 -3
  4. package/README.md +12 -7
  5. package/changelog/0.5.x/0.5.2.md +46 -0
  6. package/dist/index.js +7 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/_shared.d.ts +35 -4
  9. package/dist/mcp-server/tools/definitions/_shared.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/_shared.js +47 -6
  11. package/dist/mcp-server/tools/definitions/_shared.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts +24 -1
  13. package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/ris-get-document.tool.js +132 -15
  15. package/dist/mcp-server/tools/definitions/ris-get-document.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.d.ts +2 -2
  17. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.js +548 -64
  19. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.d.ts +16 -3
  21. package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.js +111 -8
  23. package/dist/mcp-server/tools/definitions/ris-search-announcements.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.d.ts +6 -5
  25. package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.d.ts.map +1 -1
  26. package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.js +22 -18
  27. package/dist/mcp-server/tools/definitions/ris-search-case-law.tool.js.map +1 -1
  28. package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.d.ts +12 -2
  29. package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.js +45 -8
  31. package/dist/mcp-server/tools/definitions/ris-search-gazette.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.d.ts +4 -2
  33. package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.js +10 -8
  35. package/dist/mcp-server/tools/definitions/ris-search-legislation.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/ris-track-changes.tool.d.ts +4 -0
  37. package/dist/mcp-server/tools/definitions/ris-track-changes.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/ris-track-changes.tool.js +49 -2
  39. package/dist/mcp-server/tools/definitions/ris-track-changes.tool.js.map +1 -1
  40. package/dist/services/ris/normalizer.d.ts.map +1 -1
  41. package/dist/services/ris/normalizer.js +100 -19
  42. package/dist/services/ris/normalizer.js.map +1 -1
  43. package/dist/services/ris/reference/citation-formats.d.ts +7 -7
  44. package/dist/services/ris/reference/citation-formats.d.ts.map +1 -1
  45. package/dist/services/ris/reference/citation-formats.js +18 -6
  46. package/dist/services/ris/reference/citation-formats.js.map +1 -1
  47. package/dist/services/ris/reference/courts.d.ts +1 -1
  48. package/dist/services/ris/reference/courts.d.ts.map +1 -1
  49. package/dist/services/ris/reference/courts.js +1 -1
  50. package/dist/services/ris/reference/courts.js.map +1 -1
  51. package/dist/services/ris/reference/decision-types.d.ts +1 -1
  52. package/dist/services/ris/reference/decision-types.d.ts.map +1 -1
  53. package/dist/services/ris/reference/decision-types.js +1 -1
  54. package/dist/services/ris/reference/decision-types.js.map +1 -1
  55. package/dist/services/ris/reference/issuing-bodies.d.ts +5 -0
  56. package/dist/services/ris/reference/issuing-bodies.d.ts.map +1 -1
  57. package/dist/services/ris/reference/issuing-bodies.js +6 -0
  58. package/dist/services/ris/reference/issuing-bodies.js.map +1 -1
  59. package/dist/services/ris/reference/states.d.ts +12 -1
  60. package/dist/services/ris/reference/states.d.ts.map +1 -1
  61. package/dist/services/ris/reference/states.js +39 -1
  62. package/dist/services/ris/reference/states.js.map +1 -1
  63. package/dist/services/ris/request-builder.d.ts +2 -4
  64. package/dist/services/ris/request-builder.d.ts.map +1 -1
  65. package/dist/services/ris/request-builder.js +61 -25
  66. package/dist/services/ris/request-builder.js.map +1 -1
  67. package/dist/services/ris/types.d.ts +28 -5
  68. package/dist/services/ris/types.d.ts.map +1 -1
  69. package/package.json +6 -6
  70. 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ÄÖÜäöüß].*?)\s+art(?:ikel)?\.?\s*(\d+\s?[a-zA-Z]?)(?:\s+\S.*)?$/i.exec(s);
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 = /§+\s*(\d+\s?[a-zA-Z]?)\s+(\S.*)$/.exec(s);
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), imperial RGBl./StGBl./GBlÖ to
180
- * BgblAlt, and LGBl. to the state Landesgesetzblatt (LgblAuth) when a state hint is present —
181
- * carrying that state's legacy series alongside, since a pre-switch citation lives there.
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 isImperial = /\bRGBl\b|\bStGBl\b/i.test(s) || /GBl[ÖO]/i.test(s);
187
- const isFederal = /\bBGBl\b/i.test(s);
188
- if (!isState && !isImperial && !isFederal)
261
+ const series = isState ? undefined : citedSeries(s);
262
+ if (!isState && series === undefined)
189
263
  return null;
190
- const numYear = /(\d+)\s*\/\s*(\d{4})/.exec(s);
264
+ const numYear = readGazetteNumber(s);
191
265
  if (!numYear)
192
266
  return null;
193
- const [, numberPart, yearPart] = numYear;
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 (isImperial) {
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 patterns = [
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
- const probed = candidates.length > 0 ? candidates.map(courtApplication).join(', ') : 'no matching court';
330
- return `No decision for '${gz}' in ${probed}. 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.`;
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 "${parsed.filter}".`;
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, 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 — pass court to skip detection, and ambiguous formats probe up to two courts; 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. 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.',
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?.hits[0];
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 !== undefined ? ` ${alternatives} more matched — list them with ris_search_case_law court: ${parsed.court}.` : ''}`,
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?.hits[0];
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 !== undefined ? ` ${alternatives} more matched — list them with ris_search_legislation.` : ''}`,
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
- const hit = result?.hits[0];
614
- if (result && hit !== undefined) {
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 alternatives = resolved.total > 1 ? resolved.total - 1 : undefined;
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 scopePhrase = stateHint !== undefined &&
1066
+ const stateScope = stateHint !== undefined &&
630
1067
  (resolved.application === 'LgblAuth' || resolved.application === 'Lgbl')
631
- ? `, scope: ${stateHint}`
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(resolved.hit, resolved.application),
643
- resolution_note: `Resolved via ris_search_gazette (${resolved.application}) — number "${parsed.number}"${partPhrase}${scopePhrase}${eraPhrase}.${alternatives !== undefined ? ` ${alternatives} more matched — list them with ris_search_gazette.` : ''}`,
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 candidates = courtHint !== undefined ? [courtHint] : matchCaseCourts(citation);
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: caseGuidance(citation, candidates) };
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 result = await runSearch(() => getRisService().searchCaseLaw({ caseNumber: citation, court }, ctx));
655
- const hit = result?.hits[0];
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(citation, candidates) };
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 "${citation}".${alternatives !== undefined ? ` ${alternatives} more matched — list them with ris_search_case_law court: ${resolved.court}.` : ''}`,
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
  },