@jurislm/judicial-mcp 1.6.2 → 1.7.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jurislm/judicial-mcp
2
2
 
3
- MCP (Model Context Protocol) server for Taiwan's Judicial Yuan public judgments and open data, plus current regulations from the Ministry of Justice National Laws and Regulations Database. It provides 10 tools.
3
+ MCP (Model Context Protocol) server for Taiwan's Judicial Yuan public judgments and open data, plus latest promulgated regulations from the Ministry of Justice National Laws and Regulations Database. It provides 10 tools.
4
4
 
5
5
  ## Public Judgment Search
6
6
 
@@ -37,9 +37,13 @@ Only an explicit no-matches response from the official site is returned as an em
37
37
 
38
38
  The tool resolves only a unique exact law-name match after normalizing whitespace and the common `台` / `臺` spelling. It returns candidates with `status: "ambiguous"` when the name does not resolve uniquely; it never chooses a fuzzy match. Article numbers use Arabic numerals and may include a hyphen, such as `1` or `1-1`.
39
39
 
40
- Successful results include the regulation name and official identifier, one article's text, the official page URL, the lookup URL, retrieval time, and version metadata. The official page supplies consolidated current text; this tool does not provide historical versions. Amendment, promulgation, and effective dates are returned as ISO dates when the official page states them. Missing dates are `null` and mark the result `partial`; a pending or mixed effective date is identified in `version.effective_status`. `source_version` is the published amendment date, falling back to the promulgation date.
40
+ Successful results include the latest promulgated article text (`article_text_version: "latest_promulgated"`), official URLs, and law-level `version` metadata. Latest promulgated text can include amendments that have not taken effect. `status: "current"` identifies retrieval from the latest consolidated page, not a finding that the returned article is effective.
41
41
 
42
- Responses cap article text at 30,000 characters. `truncated` and `partial` are `true` when text is capped or required version details are missing. `cache.status` is `miss`, `hit`, `refreshed`, `revalidated`, or `not_cached`. A process-local least-recently-used cache holds at most 100 exact law/article lookups for 15 minutes. Set `refresh: true` to bypass the cache. Expired entries are looked up and scraped again through Firecrawl; if an upstream refresh fails, the tool returns an error and does not serve stale text as current.
42
+ `article_effectiveness` separately evaluates the returned article version at `checked_at` (Taiwan date). Its `status` is `effective`, `not_yet_effective`, or `unknown`; `effective_date` is nullable. `reason` explains the decision and `basis` contains official source URLs and text excerpts. Decisions use the official law history and complete effective-status notes. Relative commencement dates and “effective from promulgation” clauses remain unknown unless the source provides an explicit supported calendar date. A current explicit pending notice can establish that the latest version is not yet effective without establishing its exact effective date. Unsupported conditions, partial-article commencement, conflicting or unavailable sources return `unknown`. An article absent from a pending warning is not presumed effective. A historical page containing the same text is not evidence of commencement. Pending latest text is not replaced with an unverified historical version; this tool does not supply a verified older effective text or decide which version applies to a case.
43
+
44
+ Law-level amendment, promulgation, and effective dates remain ISO dates or `null`. Missing dates keep `partial: true`, even when article-level commencement is verified. Law-level pending warnings do not establish that the queried article is pending. `source_version` remains the law amendment date, falling back to promulgation date.
45
+
46
+ Responses cap article text at 30,000 characters. `truncated` and `partial` are `true` when text is capped or required version details are missing. `cache.status` is `miss`, `hit`, `refreshed`, `revalidated`, or `not_cached`. A process-local least-recently-used cache holds at most 100 exact law/article lookups for 15 minutes. Set `refresh: true` to bypass the cache. Article decisions are revalidated across Taiwan midnight even before the 15-minute TTL expires. The additional official history request can increase latency; configure the MCP client with `tool_timeout_sec = 120`. Expired entries are looked up and scraped again through Firecrawl; if an upstream refresh fails, the tool returns an error and does not serve stale text as current.
43
47
 
44
48
  Upstream failures and unrecognized page changes are MCP errors with `error.code`, `error.message`, `error.source_url`, and `error.retrieved_at`. Confirmed no-match, abolished-law, and missing-article responses have distinct codes.
45
49
 
@@ -54,7 +58,7 @@ Upstream failures and unrecognized page changes are MCP errors with `error.code`
54
58
  - `fetch_judgment_text` — Get a judgment's full text by `case_id`, without a token
55
59
 
56
60
  ### Regulations (1 tool)
57
- - `query_regulation` — Get one current regulation article and official version metadata, without a token
61
+ - `query_regulation` — Get one latest promulgated regulation article, its `article_effectiveness` result, and official version metadata, without a token
58
62
 
59
63
  ### Token-based Judgments (2 tools)
60
64
  - `list_judgments` — Get the judgment change list (requires `auth_token`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jurislm/judicial-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "MCP server for accessing Taiwan Judicial Yuan open data and judgments.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -0,0 +1,258 @@
1
+ import { load, type CheerioAPI } from 'cheerio'
2
+
3
+ export interface ArticleEffectiveness {
4
+ status: 'effective' | 'not_yet_effective' | 'unknown'
5
+ effective_date: string | null
6
+ reason: string
7
+ basis: Array<{ source_url: string; text: string }>
8
+ checked_at: string
9
+ }
10
+
11
+ interface ResolverInput {
12
+ historyHtml: string
13
+ articleHtml: string
14
+ articleText: string
15
+ lawName: string
16
+ articleNumber: string
17
+ checkedAt: string
18
+ sourceUrl: string
19
+ }
20
+
21
+ interface HistoryRecord {
22
+ text: string
23
+ date: string | null
24
+ }
25
+
26
+ const NUMERALS = '〇零一二三四五六七八九十百千兩0-9'
27
+ const DATE_PATTERN = `(?:中華民國|民國)?([${NUMERALS}]+)年([${NUMERALS}]+)月([${NUMERALS}]+)日`
28
+ const ARTICLE_NUMBER = '[0-9]+(?:-[0-9]+)?'
29
+ const ARTICLE_RANGE = `${ARTICLE_NUMBER}(?:[~~至]${ARTICLE_NUMBER})?`
30
+ const ARTICLE_LIST = `(${ARTICLE_RANGE}(?:[、,,]${ARTICLE_RANGE})*)`
31
+ const COMPLETE_LIST = new RegExp(`^${ARTICLE_LIST}$`)
32
+
33
+ function compact(text: string): string {
34
+ return text.replace(/\s+/g, '')
35
+ }
36
+
37
+ function chineseNumber(text: string): number | null {
38
+ if (/^\d+$/.test(text)) return Number(text)
39
+ const digits: Record<string, number> = { '〇': 0, '零': 0, '一': 1, '二': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 }
40
+ let result = 0
41
+ let digit = 0
42
+ let lastUnit = Infinity
43
+ for (const char of text) {
44
+ if (char in digits) {
45
+ digit = digits[char] ?? 0
46
+ continue
47
+ }
48
+ const unit = char === '十' ? 10 : char === '百' ? 100 : char === '千' ? 1000 : 0
49
+ if (!unit || unit >= lastUnit) return null
50
+ result += (digit || 1) * unit
51
+ digit = 0
52
+ lastUnit = unit
53
+ }
54
+ const number = result + digit
55
+ const canonical = chineseNumeral(number)
56
+ const normalized = text.replaceAll('〇', '零').replaceAll('兩', '二')
57
+ return normalized === canonical || normalized === canonical.replace('百一十', '百十') ? number : null
58
+ }
59
+
60
+ function chineseNumeral(number: number): string {
61
+ if (number === 0) return '零'
62
+ const names = '零一二三四五六七八九'
63
+ let result = ''
64
+ let remainder = number
65
+ let zero = false
66
+ for (const [unit, label] of [[1000, '千'], [100, '百'], [10, '十'], [1, '']] satisfies Array<[number, string]>) {
67
+ const digit = Math.floor(remainder / unit)
68
+ remainder %= unit
69
+ if (digit) {
70
+ result += (zero ? '零' : '') + names[digit] + label
71
+ zero = false
72
+ } else if (result && remainder) zero = true
73
+ }
74
+ return result.replace(/^一十/, '十')
75
+ }
76
+
77
+ function parseDate(text: string): string | null {
78
+ const match = compact(text).match(new RegExp(DATE_PATTERN))
79
+ if (!match) return null
80
+ const year = chineseNumber(match[1] ?? '')
81
+ const month = chineseNumber(match[2] ?? '')
82
+ const day = chineseNumber(match[3] ?? '')
83
+ if (year === null || month === null || day === null || year < 1 || month < 1 || day < 1) return null
84
+ const date = new Date(Date.UTC(year + 1911, month - 1, day))
85
+ if (date.getUTCFullYear() !== year + 1911 || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null
86
+ return date.toISOString().slice(0, 10)
87
+ }
88
+
89
+ function metadata($: CheerioAPI): { name: string; version: string | null } {
90
+ let name = compact($('#hlLawName').first().text())
91
+ let amendment = $('#trLNNDate td').first().text()
92
+ let promulgation = $('#trLNODate td').first().text()
93
+ $('tr').each((_index, row) => {
94
+ const label = compact($(row).find('th').first().text())
95
+ const value = $(row).find('td').first()
96
+ if (!name && label === '法規名稱:') name = compact(value.find('a').first().text() || value.text())
97
+ if (!amendment && label === '修正日期:') amendment = value.text()
98
+ if (!promulgation && label === '公布日期:') promulgation = value.text()
99
+ })
100
+ return { name, version: parseDate(amendment || promulgation) }
101
+ }
102
+
103
+ function articleTuple(value: string): [number, number] {
104
+ const [base, suffix] = value.split('-')
105
+ return [Number(base), Number(suffix ?? 0)]
106
+ }
107
+
108
+ function compareArticle(left: string, right: string): number {
109
+ const [leftBase, leftSuffix] = articleTuple(left)
110
+ const [rightBase, rightSuffix] = articleTuple(right)
111
+ return leftBase - rightBase || leftSuffix - rightSuffix
112
+ }
113
+
114
+ function includesArticle(list: string, article: string): boolean {
115
+ return list.split(/[、,,]/).some(item => {
116
+ const range = item.split(/[~~至]/)
117
+ const start = range[0]
118
+ const end = range[1]
119
+ if (!start) return false
120
+ if (!end) return compareArticle(start, article) === 0
121
+ return compareArticle(start, article) <= 0 && compareArticle(article, end) <= 0
122
+ })
123
+ }
124
+
125
+ function referencesArticle(text: string, article: string): boolean {
126
+ return Array.from(text.matchAll(new RegExp(`第${ARTICLE_LIST}條`, 'g')))
127
+ .some(match => includesArticle(match[1] ?? '', article))
128
+ }
129
+
130
+ function revisionScopes(text: string): { complete: boolean; fullText: boolean; actions: Array<{ action: string; list: string; span: string }> } {
131
+ const actions: Array<{ action: string; list: string; span: string }> = []
132
+ let complete = true
133
+ let fullText = false
134
+ for (const match of text.matchAll(/(修正|增訂|刪除|制定)(?!條文|之條文)/g)) {
135
+ const segment = text.slice(match.index + match[0].length).split(/[;。]/)[0] ?? ''
136
+ if (/全文/.test(segment)) fullText = true
137
+ const scope = segment.match(/^(?:公布|發布)?第([^條;。]+)條/)
138
+ if (!scope || !COMPLETE_LIST.test(scope[1] ?? '') || /第[^;。]*條/.test(segment.slice(scope[0].length))) {
139
+ complete = false
140
+ continue
141
+ }
142
+ actions.push({ action: match[1] ?? '', list: scope[1] ?? '', span: match[0] + scope[0] })
143
+ }
144
+ if (actions.length === 0 && text.includes('條修正條文')) {
145
+ const decree = text.match(new RegExp(`^${DATE_PATTERN}總統(?:華總一義字第[0-9]+號)?令公布第(?<articles>${ARTICLE_RANGE}(?:[、,,]${ARTICLE_RANGE})*)條修正條文定自${DATE_PATTERN}施行[。;]?$`))
146
+ const list = decree?.groups?.articles
147
+ if (!decree || !list) complete = false
148
+ else actions.push({ action: '修正', list, span: `公布第${list}條修正條文` })
149
+ }
150
+ return { complete: complete && actions.length > 0, fullText, actions }
151
+ }
152
+
153
+ function revisionAction(text: string, article: string): string | null {
154
+ const scope = revisionScopes(text)
155
+ if (!scope.complete) return null
156
+ return scope.actions.find(action => includesArticle(action.list, article))?.action ?? null
157
+ }
158
+
159
+ function completeNoteScope(note: string): boolean {
160
+ const scopes = Array.from(note.matchAll(/第([^;。]*?)條/g))
161
+ return scopes.length > 0 && scopes.every(scope => COMPLETE_LIST.test(scope[1] ?? '')) && !/其他|全部|部分|其餘|分別|但|(?<!刪)除/.test(note)
162
+ }
163
+
164
+ function historyRecords($: CheerioAPI): HistoryRecord[] {
165
+ const records: HistoryRecord[] = []
166
+ $('.law-history > .row > .col-data').each((_index, node) => {
167
+ const text = compact($(node).text())
168
+ const starts = Array.from(text.matchAll(new RegExp(`中華民國[${NUMERALS}]+年[${NUMERALS}]+月[${NUMERALS}]+日(?=總統|國民政府|行政院|司法院)`, 'g')))
169
+ if (!starts.length) {
170
+ records.push({ text, date: null })
171
+ return
172
+ }
173
+ starts.forEach((start, index) => {
174
+ const record = text.slice(start.index, starts[index + 1]?.index)
175
+ records.push({ text: record, date: parseDate(record) })
176
+ })
177
+ })
178
+ return records
179
+ }
180
+
181
+ function warningNotes($: CheerioAPI): { pending: boolean; wholeLaw: boolean; notes: string[] } {
182
+ const block = $('#VaildState')
183
+ const notes: string[] = []
184
+ block.find('.law-vaildMemo').each((_index, node) => {
185
+ const html = $(node).html() ?? ''
186
+ notes.push(...html.split(/<br\s*\/?\s*>/i).flatMap(part => compact(load(part).text()).split('。')).filter(Boolean))
187
+ })
188
+ return { pending: /尚未(?:生效|施行)/.test(block.text()), wholeLaw: /(?<!或)全部條文尚未(?:生效|施行)/.test(compact(block.text())), notes }
189
+ }
190
+
191
+ function withoutOtherExceptions(text: string): string | null {
192
+ const parts = text.split(';但')
193
+ const main = parts.shift() ?? ''
194
+ for (const exception of parts) {
195
+ if (!new RegExp(`^第${ARTICLE_LIST}條施行日期,由行政院會同司法院另定之$`).test(exception)) return null
196
+ }
197
+ return main
198
+ }
199
+
200
+ function explicitEffectiveDate(text: string): string | null {
201
+ const dates = Array.from(text.matchAll(new RegExp(`(?:並)?自(${DATE_PATTERN})施行`, 'g')))
202
+ if (dates.length !== 1 || !new RegExp(`自${DATE_PATTERN}施行[。;]?$`).test(text)) return null
203
+ return parseDate(dates[0]?.[1] ?? '')
204
+ }
205
+
206
+ export function resolveArticleEffectiveness(input: ResolverInput): ArticleEffectiveness {
207
+ const basis: ArticleEffectiveness['basis'] = []
208
+ const result = (status: ArticleEffectiveness['status'], date: string | null, reason: string): ArticleEffectiveness => ({
209
+ status, effective_date: date, reason, basis, checked_at: input.checkedAt,
210
+ })
211
+ const unknown = (reason: string) => result('unknown', null, reason)
212
+ const checked = new Date(input.checkedAt)
213
+ if (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(input.checkedAt) || !Number.isFinite(checked.getTime())) return unknown('查核時間無效,無法判斷臺灣生效日期。')
214
+ if (!/^\d+(?:-\d+)?$/.test(input.articleNumber)) return unknown('條號格式無法辨識。')
215
+ const article = load(input.articleHtml)
216
+ const history = load(input.historyHtml)
217
+ if (/^[((]?刪除[))]?$/.test(compact(input.articleText))) return unknown('官方條文內容標示刪除,無法視為有效條文。')
218
+ const articleMetadata = metadata(article)
219
+ const historyMetadata = metadata(history)
220
+ const lawName = compact(input.lawName)
221
+ if (articleMetadata.name !== lawName || historyMetadata.name !== lawName) return unknown('官方條文頁與沿革頁法規名稱不一致或缺漏。')
222
+ if (!articleMetadata.version || articleMetadata.version !== historyMetadata.version) return unknown('官方條文頁與沿革頁版本日期不一致或缺漏。')
223
+ const records = historyRecords(history)
224
+ if (records.some(record => record.date === null)) return unknown('官方沿革有無法辨識日期的紀錄,無法確認最新條文版本。')
225
+ records.sort((left, right) => (right.date ?? '').localeCompare(left.date ?? ''))
226
+ const candidate = records.find(record => revisionAction(record.text, input.articleNumber) !== null)
227
+ if (!candidate) return unknown('官方沿革未提供可確認此條最新修正版本及施行日期的紀錄。')
228
+ basis.push({ source_url: input.sourceUrl, text: candidate.text })
229
+ if (records.some(record => record !== candidate && record.date === candidate.date && revisionAction(record.text, input.articleNumber) !== null && record.text !== candidate.text)) return unknown('同日有多筆涉及此條的修正紀錄,無法確認唯一施行依據。')
230
+ if (records.some(record => (record.date ?? '') >= (candidate.date ?? '') && (!revisionScopes(record.text).complete || revisionScopes(record.text).fullText))) return unknown('較新或同日修正的完整條文範圍無法確認,不能沿用較舊施行證據。')
231
+ if (revisionAction(candidate.text, input.articleNumber) === '刪除') return unknown('最新沿革刪除此條,不能將刪除日期視為條文生效日期。')
232
+ const scoped = withoutOtherExceptions(candidate.text)
233
+ if (!scoped || /(?:第[一二三四五六七八九十百\d]+(?:項|款|目)|但|(?<!刪)除|其餘|分別|涉及|部分|條件)/.test(scoped)) return unknown('沿革含分項施行、條件或未能解析的例外,無法確認全條生效狀態。')
234
+ const remainingScope = revisionScopes(scoped).actions.reduce((remaining, action) => remaining.replace(action.span, ''), scoped)
235
+ if (/第[〇零一二三四五六七八九十百千兩\d、,,~~至及-]+條/.test(remainingScope)) return unknown('施行說明另限定條號範圍,無法確認是否適用於所查全條。')
236
+ const warning = warningNotes(article)
237
+ if (warning.wholeLaw || (warning.pending && (!warning.notes.length || warning.notes.some(note => !completeNoteScope(note))))) return unknown('目前官方生效警示未提供可完整辨識的條號範圍,不能確認所查條文已排除於警示之外。')
238
+ const targeted = warning.notes.filter(note => referencesArticle(note, input.articleNumber))
239
+ if (targeted.length > 1) return unknown('目前生效警示有多筆涉及此條的說明,無法排除矛盾。')
240
+ const note = targeted[0]
241
+ if (note) {
242
+ basis.push({ source_url: input.sourceUrl.replace('/LawHistory.aspx', '/LawAll.aspx'), text: note })
243
+ if (parseDate(note) !== candidate.date) return unknown('目前生效警示與最新修正紀錄日期不一致。')
244
+ if (/(?:第[一二三四五六七八九十百\d]+(?:項|款|目)|但|(?<!刪)除|其餘|分別|涉及|部分|條件)/.test(note)) return unknown('目前生效警示含分項或條件,無法確認全條生效狀態。')
245
+ const undetermined = /施行日期,由行政院會同司法院另定之/.test(note)
246
+ const delayed = /自公布(?:後[一二三四五六七八九十百\d]+個月|[一二三四五六七八九十百\d]+個月後)施行/.test(note)
247
+ const targetedException = candidate.text.split(';但').slice(1).some(exception => referencesArticle(exception, input.articleNumber))
248
+ if (explicitEffectiveDate(scoped) && !targetedException) return unknown('目前待施行警示與沿革全條明確施行日期矛盾。')
249
+ if (warning.pending && (undetermined || delayed)) return result('not_yet_effective', null, undetermined ? '目前官方警示明確指出此條尚待另定施行日期。' : '目前官方警示明確列此條為延後施行;相對月數尚未換算為確定生效日。')
250
+ return unknown('目前官方生效警示無法完整解析,不能據此判斷全條。')
251
+ }
252
+ if (candidate.text.split(';但').slice(1).some(exception => referencesArticle(exception, input.articleNumber))) return unknown('此條施行日期另定,且缺少目前官方待施行警示。')
253
+ if (/另定|命令定之|公布(?:日|後|[一二三四五六七八九十百\d]+個月)|公告/.test(scoped)) return unknown('施行條件或相對日期未支援;不得直接將公布日視為生效日。')
254
+ const effectiveDate = explicitEffectiveDate(scoped)
255
+ if (!effectiveDate) return unknown('最新修正紀錄未提供唯一且有效的全條明確施行日期。')
256
+ const taiwanDay = new Date(checked.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10)
257
+ return result(effectiveDate <= taiwanDay ? 'effective' : 'not_yet_effective', effectiveDate, `最新涉及本條的官方修正紀錄明定自${effectiveDate}施行;依臺灣查核日期${taiwanDay}判斷。`)
258
+ }
@@ -7,6 +7,7 @@ import {
7
7
  type RegulationCacheEntry,
8
8
  } from './regulation-cache.js'
9
9
  import { RegulationSourceError } from './source-error.js'
10
+ import { resolveArticleEffectiveness, type ArticleEffectiveness } from './article-effectiveness.js'
10
11
 
11
12
  const BASE_URL = 'https://law.moj.gov.tw'
12
13
  const TERM_URL = new URL('/controls/GetTermList.ashx', BASE_URL)
@@ -35,6 +36,8 @@ interface RegulationResult {
35
36
  regulation_id?: string
36
37
  article_number?: string
37
38
  article_text?: string
39
+ article_text_version?: 'latest_promulgated'
40
+ article_effectiveness?: ArticleEffectiveness
38
41
  version?: RegulationVersion
39
42
  candidates?: Array<{ regulation_name: string; regulation_id: string }>
40
43
  source_url: string
@@ -73,6 +76,7 @@ function isRegulationResult(value: unknown): value is RegulationResult {
73
76
  if (!isRecord(value.cache) || !isRecord(value.version)) return false
74
77
  const cacheStatus = value.cache.status
75
78
  const effectiveStatus = value.version.effective_status
79
+ if (!isRecord(value.article_effectiveness) || typeof value.article_effectiveness.checked_at !== 'string') return false
76
80
  return typeof value.regulation_name === 'string' &&
77
81
  typeof value.regulation_id === 'string' &&
78
82
  typeof value.article_number === 'string' &&
@@ -363,14 +367,14 @@ function parseArticle(html: string, url: URL, lawName: string, articleNumber: st
363
367
  if (!articleText) {
364
368
  throw new RegulationSourceError('regulation_article_not_found', url.href)
365
369
  }
370
+ const effectiveDate = dateForLabel(metadataText, ['施行日期', '生效日期'])
366
371
  const version: RegulationVersion = {
367
372
  amendment_date: dateForLabel(metadataText, ['修正日期', '修正日']),
368
373
  promulgation_date: dateForLabel(metadataText, ['公布日期', '發布日期']),
369
- effective_date: dateForLabel(metadataText, ['施行日期', '生效日期']),
370
- effective_status: /尚未生效|尚未施行|最後生效日期:未定/.test(metadataText) ? 'pending_or_mixed' : 'as_stated',
371
- }
372
- if (/最後生效日期:未定/.test(metadataText) && version.effective_date === null) {
373
- version.effective_status = 'unknown'
374
+ effective_date: effectiveDate,
375
+ effective_status: /尚未生效|尚未施行/.test(metadataText)
376
+ ? 'pending_or_mixed'
377
+ : effectiveDate === null ? 'unknown' : 'as_stated',
374
378
  }
375
379
  return { text: articleText, version }
376
380
  }
@@ -383,6 +387,7 @@ function makeResult(
383
387
  searchSourceUrl: URL,
384
388
  retrievedAt: string,
385
389
  cacheStatus: RegulationResult['cache']['status'],
390
+ articleEffectiveness: ArticleEffectiveness,
386
391
  ): RegulationResult {
387
392
  const truncated = article.text.length > MAX_ARTICLE_LENGTH
388
393
  const missingDates = article.version.amendment_date === null ||
@@ -394,6 +399,8 @@ function makeResult(
394
399
  regulation_id: candidate.identifier,
395
400
  article_number: articleNumber,
396
401
  article_text: text,
402
+ article_text_version: 'latest_promulgated',
403
+ article_effectiveness: articleEffectiveness,
397
404
  version: article.version,
398
405
  source_url: sourceUrl.href,
399
406
  search_source_url: searchSourceUrl.href,
@@ -481,6 +488,21 @@ async function lookupRegulation(lawName: string): Promise<{ resolution: Candidat
481
488
  return { resolution: resolveCandidate(directCandidates, lawName, directURL), lookupURL: directURL }
482
489
  }
483
490
 
491
+ function taiwanDate(value: number | string): string {
492
+ return new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10)
493
+ }
494
+
495
+ async function articleEffectiveness(html: string, articleText: string, candidate: RegulationCandidate, articleNumber: string, checkedAt: string): Promise<ArticleEffectiveness> {
496
+ const url = detailUrl(candidate.identifier)
497
+ url.pathname = '/LawClass/LawHistory.aspx'
498
+ try {
499
+ const historyHtml = await fetchHtml(url)
500
+ return resolveArticleEffectiveness({ historyHtml, articleHtml: html, articleText, lawName: candidate.name, articleNumber, checkedAt, sourceUrl: url.href })
501
+ } catch {
502
+ return { status: 'unknown', effective_date: null, reason: '無法取得或驗證官方沿革,無法確認本條文版本是否生效。', basis: [], checked_at: checkedAt }
503
+ }
504
+ }
505
+
484
506
  export async function queryRegulation(args: Record<string, unknown>): Promise<RegulationResult> {
485
507
  const lawName = validateName(args.lawName)
486
508
  const articleNumber = validateArticleNumber(args.articleNumber)
@@ -494,12 +516,13 @@ export async function queryRegulation(args: Record<string, unknown>): Promise<Re
494
516
  else deleteRegulationCacheEntry(key)
495
517
  }
496
518
 
497
- if (cached && cached.expiresAt > now && !refresh) {
519
+ const sameDate = cached?.value.article_effectiveness && taiwanDate(cached.value.article_effectiveness.checked_at) === taiwanDate(now)
520
+ if (cached && cached.expiresAt > now && sameDate && !refresh) {
498
521
  return resultWithCacheStatus(cached.value, 'hit')
499
522
  }
500
523
 
501
524
  if (refresh) deleteRegulationCacheEntry(key)
502
- if (cached && cached.expiresAt <= now && !refresh) {
525
+ if (cached && (cached.expiresAt <= now || !sameDate) && !refresh) {
503
526
  const checkedAt = new Date().toISOString()
504
527
  try {
505
528
  const lookup = await lookupRegulation(lawName)
@@ -512,7 +535,8 @@ export async function queryRegulation(args: Record<string, unknown>): Promise<Re
512
535
  const detailURL = detailUrl(candidate.identifier)
513
536
  const html = await fetchHtml(detailURL)
514
537
  const article = parseArticle(html, detailURL, candidate.name, articleNumber)
515
- const result = makeResult(candidate, articleNumber, article, detailURL, lookupURL, checkedAt, 'revalidated')
538
+ const effectiveness = await articleEffectiveness(html, article.text, candidate, articleNumber, checkedAt)
539
+ const result = makeResult(candidate, articleNumber, article, detailURL, lookupURL, checkedAt, 'revalidated', effectiveness)
516
540
  setRegulationCacheEntry(key, { value: result }, now)
517
541
  return result
518
542
  } catch (error: unknown) {
@@ -530,7 +554,8 @@ export async function queryRegulation(args: Record<string, unknown>): Promise<Re
530
554
  const html = await fetchHtml(articleURL)
531
555
  const article = parseArticle(html, articleURL, candidate.name, articleNumber)
532
556
  const retrievedAt = new Date().toISOString()
533
- const result = makeResult(candidate, articleNumber, article, articleURL, lookupURL, retrievedAt, refresh ? 'refreshed' : 'miss')
557
+ const effectiveness = await articleEffectiveness(html, article.text, candidate, articleNumber, retrievedAt)
558
+ const result = makeResult(candidate, articleNumber, article, articleURL, lookupURL, retrievedAt, refresh ? 'refreshed' : 'miss', effectiveness)
534
559
  setRegulationCacheEntry(key, { value: result }, now)
535
560
  return result
536
561
  }
package/src/tools.ts CHANGED
@@ -250,7 +250,7 @@ export const TOOLS_CONFIG = {
250
250
 
251
251
  query_regulation: {
252
252
  name: 'query_regulation',
253
- description: '依官方法規名稱與條號查詢全國法規資料庫現行條文,回傳版本日期與官方來源;不提供歷史版本。',
253
+ description: '依官方法規名稱與條號查詢最新公布條文,另以 article_effectiveness 回傳該條文版本的生效判斷與官方依據;資料不足回傳 unknown,不代表現行有效或個案適用版本。',
254
254
  inputSchema: {
255
255
  type: 'object',
256
256
  properties: {
@@ -0,0 +1,21 @@
1
+ export interface ArticleEffectiveness {
2
+ status: 'effective' | 'not_yet_effective' | 'unknown';
3
+ effective_date: string | null;
4
+ reason: string;
5
+ basis: Array<{
6
+ source_url: string;
7
+ text: string;
8
+ }>;
9
+ checked_at: string;
10
+ }
11
+ interface ResolverInput {
12
+ historyHtml: string;
13
+ articleHtml: string;
14
+ articleText: string;
15
+ lawName: string;
16
+ articleNumber: string;
17
+ checkedAt: string;
18
+ sourceUrl: string;
19
+ }
20
+ export declare function resolveArticleEffectiveness(input: ResolverInput): ArticleEffectiveness;
21
+ export {};
@@ -1,3 +1,4 @@
1
+ import { type ArticleEffectiveness } from './article-effectiveness.js';
1
2
  interface RegulationVersion {
2
3
  amendment_date: string | null;
3
4
  promulgation_date: string | null;
@@ -10,6 +11,8 @@ interface RegulationResult {
10
11
  regulation_id?: string;
11
12
  article_number?: string;
12
13
  article_text?: string;
14
+ article_text_version?: 'latest_promulgated';
15
+ article_effectiveness?: ArticleEffectiveness;
13
16
  version?: RegulationVersion;
14
17
  candidates?: Array<{
15
18
  regulation_name: string;
package/types/tools.d.ts CHANGED
@@ -211,7 +211,7 @@ export declare const TOOLS_CONFIG: {
211
211
  };
212
212
  readonly query_regulation: {
213
213
  readonly name: "query_regulation";
214
- readonly description: "依官方法規名稱與條號查詢全國法規資料庫現行條文,回傳版本日期與官方來源;不提供歷史版本。";
214
+ readonly description: "依官方法規名稱與條號查詢最新公布條文,另以 article_effectiveness 回傳該條文版本的生效判斷與官方依據;資料不足回傳 unknown,不代表現行有效或個案適用版本。";
215
215
  readonly inputSchema: {
216
216
  readonly type: "object";
217
217
  readonly properties: {