@jurislm/judicial-mcp 1.6.3 → 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 +8 -4
- package/package.json +1 -1
- package/src/article-effectiveness.ts +258 -0
- package/src/regulation-source.ts +29 -4
- package/src/tools.ts +1 -1
- package/types/article-effectiveness.d.ts +21 -0
- package/types/regulation-source.d.ts +3 -0
- package/types/tools.d.ts +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/regulation-source.ts
CHANGED
|
@@ -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' &&
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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: {
|