korean-law-mcp 4.5.2 → 4.6.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/build/lib/citation-content-matcher.d.ts +39 -0
- package/build/lib/citation-content-matcher.js +159 -0
- package/build/lib/fetch-with-retry.js +12 -1
- package/build/lib/law-antibot.d.ts +26 -0
- package/build/lib/law-antibot.js +89 -0
- package/build/tools/verify-citations.js +32 -2
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 법령 조문 인용 내용 일치 검증
|
|
3
|
+
*
|
|
4
|
+
* LLM이 "제5조(목적)"로 인용한 내용이 실제 제5조의 본문/제목과 의미적으로
|
|
5
|
+
* 일치하는가를 다층 매칭으로 판정한다. 존재 검증만으로는 "존재하는 조문에
|
|
6
|
+
* 엉뚱한 내용 환각"을 잡지 못하기 때문.
|
|
7
|
+
*
|
|
8
|
+
* 레이어:
|
|
9
|
+
* L1 (exact) — 정규화 후 연속 30자+ 공통 substring
|
|
10
|
+
* L2 (jaccard) — 문자 bigram Jaccard ≥ 0.25 (조사/어미 차이에 robust)
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ LexDiff(lib/citation-content-matcher.ts)에서 이식한 순수함수. 수정 시
|
|
13
|
+
* 원본 확인 필수 (CLAUDE.md 규칙 1: LexDiff 이식 코드 무단 수정 금지).
|
|
14
|
+
* 단, normalizeLegalText의 zero-width/NBSP 제거는 유니코드 이스케이프가
|
|
15
|
+
* 소스에 실제 제어문자로 박히는 것을 피하려 코드포인트 비교로 재구현함
|
|
16
|
+
* (동작 동일).
|
|
17
|
+
*/
|
|
18
|
+
export type MatchMethod = 'exact' | 'token-jaccard' | 'semantic' | 'mismatch';
|
|
19
|
+
export interface ContentMatchResult {
|
|
20
|
+
matched: boolean;
|
|
21
|
+
method: MatchMethod;
|
|
22
|
+
score: number;
|
|
23
|
+
normalizedLenClaim: number;
|
|
24
|
+
normalizedLenActual: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 법률 텍스트 정규화:
|
|
28
|
+
* - 원문자 ①②③ → (1)(2)(3)
|
|
29
|
+
* - 「」, 『』 괄호 제거
|
|
30
|
+
* - 중점/구분자(·•) → 공백
|
|
31
|
+
* - whitespace 단일 공백
|
|
32
|
+
* - NBSP(U+00A0)/zero-width 제거·치환
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeLegalText(s: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* LLM claim 텍스트와 실제 조문 본문/제목을 비교해 일치 여부 판정.
|
|
37
|
+
* claim 또는 actual이 비어있으면 matched=false.
|
|
38
|
+
*/
|
|
39
|
+
export declare function matchCitationContent(claim: string, actual: string): ContentMatchResult;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 법령 조문 인용 내용 일치 검증
|
|
3
|
+
*
|
|
4
|
+
* LLM이 "제5조(목적)"로 인용한 내용이 실제 제5조의 본문/제목과 의미적으로
|
|
5
|
+
* 일치하는가를 다층 매칭으로 판정한다. 존재 검증만으로는 "존재하는 조문에
|
|
6
|
+
* 엉뚱한 내용 환각"을 잡지 못하기 때문.
|
|
7
|
+
*
|
|
8
|
+
* 레이어:
|
|
9
|
+
* L1 (exact) — 정규화 후 연속 30자+ 공통 substring
|
|
10
|
+
* L2 (jaccard) — 문자 bigram Jaccard ≥ 0.25 (조사/어미 차이에 robust)
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ LexDiff(lib/citation-content-matcher.ts)에서 이식한 순수함수. 수정 시
|
|
13
|
+
* 원본 확인 필수 (CLAUDE.md 규칙 1: LexDiff 이식 코드 무단 수정 금지).
|
|
14
|
+
* 단, normalizeLegalText의 zero-width/NBSP 제거는 유니코드 이스케이프가
|
|
15
|
+
* 소스에 실제 제어문자로 박히는 것을 피하려 코드포인트 비교로 재구현함
|
|
16
|
+
* (동작 동일).
|
|
17
|
+
*/
|
|
18
|
+
const MIN_EXACT_LEN = 30;
|
|
19
|
+
// 문자 bigram jaccard의 실전 threshold. 완전히 관련 없는 법령 조문 간 값은
|
|
20
|
+
// 0.05~0.10 수준. 어순/조사 paraphrase로 공통 의미를 유지하면 0.25~0.50.
|
|
21
|
+
const JACCARD_THRESHOLD = 0.25;
|
|
22
|
+
const CIRCLED = '①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮';
|
|
23
|
+
// zero-width: U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+FEFF BOM
|
|
24
|
+
function isZeroWidth(cp) {
|
|
25
|
+
return cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfeff;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 법률 텍스트 정규화:
|
|
29
|
+
* - 원문자 ①②③ → (1)(2)(3)
|
|
30
|
+
* - 「」, 『』 괄호 제거
|
|
31
|
+
* - 중점/구분자(·•) → 공백
|
|
32
|
+
* - whitespace 단일 공백
|
|
33
|
+
* - NBSP(U+00A0)/zero-width 제거·치환
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeLegalText(s) {
|
|
36
|
+
if (!s)
|
|
37
|
+
return '';
|
|
38
|
+
// NBSP→공백, zero-width 제거 (코드포인트 비교 — 소스에 제어문자 미포함)
|
|
39
|
+
let cleaned = '';
|
|
40
|
+
for (const ch of s) {
|
|
41
|
+
const cp = ch.codePointAt(0);
|
|
42
|
+
if (isZeroWidth(cp))
|
|
43
|
+
continue;
|
|
44
|
+
cleaned += cp === 0x00a0 ? ' ' : ch;
|
|
45
|
+
}
|
|
46
|
+
return cleaned
|
|
47
|
+
.replace(/[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]/g, (m) => `(${CIRCLED.indexOf(m) + 1})`)
|
|
48
|
+
.replace(/[「『]/g, '')
|
|
49
|
+
.replace(/[」』]/g, '')
|
|
50
|
+
.replace(/[·•]/g, ' ')
|
|
51
|
+
.replace(/\s+/g, ' ')
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 한국어는 교착어라 조사/어미가 단어에 붙어 word-level jaccard 신뢰도 낮음.
|
|
56
|
+
* 문자 bigram(연속 두 글자)을 토큰으로 사용하면 "관세를" vs "관세" 같은
|
|
57
|
+
* 미세한 어미 차이에도 overlap이 발생해 robust.
|
|
58
|
+
* 구두점/공백은 bigram 생성 전에 제거.
|
|
59
|
+
*/
|
|
60
|
+
function tokenize(s) {
|
|
61
|
+
const compact = normalizeLegalText(s)
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[\s,.?!;:()[\]{}"'`~<>/\\|+=*&^%$#@!-]+/gu, '');
|
|
64
|
+
if (compact.length < 2)
|
|
65
|
+
return compact ? [compact] : [];
|
|
66
|
+
const grams = [];
|
|
67
|
+
for (let i = 0; i < compact.length - 1; i++) {
|
|
68
|
+
grams.push(compact.slice(i, i + 2));
|
|
69
|
+
}
|
|
70
|
+
return grams;
|
|
71
|
+
}
|
|
72
|
+
/** Rolling-hash 없이도 실용적으로 충분한 최장 공통 substring 길이 (small inputs) */
|
|
73
|
+
function longestCommonSubstringLen(a, b) {
|
|
74
|
+
if (!a || !b)
|
|
75
|
+
return 0;
|
|
76
|
+
// DP O(n*m). 조문 본문은 일반적으로 수천자 이내 → 실사용에서 문제없음.
|
|
77
|
+
// 메모리 절감 위해 이전 행만 보관.
|
|
78
|
+
const n = a.length, m = b.length;
|
|
79
|
+
if (n === 0 || m === 0)
|
|
80
|
+
return 0;
|
|
81
|
+
let prev = new Uint16Array(m + 1);
|
|
82
|
+
let curr = new Uint16Array(m + 1);
|
|
83
|
+
let best = 0;
|
|
84
|
+
for (let i = 1; i <= n; i++) {
|
|
85
|
+
for (let j = 1; j <= m; j++) {
|
|
86
|
+
if (a.charCodeAt(i - 1) === b.charCodeAt(j - 1)) {
|
|
87
|
+
const v = prev[j - 1] + 1;
|
|
88
|
+
curr[j] = v;
|
|
89
|
+
if (v > best)
|
|
90
|
+
best = v;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
curr[j] = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
;
|
|
97
|
+
[prev, curr] = [curr, prev];
|
|
98
|
+
curr.fill(0);
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
function jaccard(a, b) {
|
|
103
|
+
if (a.size === 0 && b.size === 0)
|
|
104
|
+
return 0;
|
|
105
|
+
let inter = 0;
|
|
106
|
+
for (const t of a)
|
|
107
|
+
if (b.has(t))
|
|
108
|
+
inter++;
|
|
109
|
+
const union = a.size + b.size - inter;
|
|
110
|
+
return union === 0 ? 0 : inter / union;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* LLM claim 텍스트와 실제 조문 본문/제목을 비교해 일치 여부 판정.
|
|
114
|
+
* claim 또는 actual이 비어있으면 matched=false.
|
|
115
|
+
*/
|
|
116
|
+
export function matchCitationContent(claim, actual) {
|
|
117
|
+
const c = normalizeLegalText(claim);
|
|
118
|
+
const a = normalizeLegalText(actual);
|
|
119
|
+
if (!c || !a) {
|
|
120
|
+
return { matched: false, method: 'mismatch', score: 0, normalizedLenClaim: c.length, normalizedLenActual: a.length };
|
|
121
|
+
}
|
|
122
|
+
// 과도하게 짧은 claim은 exact만으로 판정 곤란 — jaccard도 low. actual에 포함만 되면 통과.
|
|
123
|
+
if (c.length < MIN_EXACT_LEN) {
|
|
124
|
+
if (a.includes(c)) {
|
|
125
|
+
return { matched: true, method: 'exact', score: 1, normalizedLenClaim: c.length, normalizedLenActual: a.length };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// L1: 연속 substring
|
|
129
|
+
const lcs = longestCommonSubstringLen(c, a);
|
|
130
|
+
if (lcs >= MIN_EXACT_LEN) {
|
|
131
|
+
return {
|
|
132
|
+
matched: true,
|
|
133
|
+
method: 'exact',
|
|
134
|
+
score: Math.min(1, lcs / Math.max(1, c.length)),
|
|
135
|
+
normalizedLenClaim: c.length,
|
|
136
|
+
normalizedLenActual: a.length,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// L2: token jaccard
|
|
140
|
+
const tc = new Set(tokenize(c));
|
|
141
|
+
const ta = new Set(tokenize(a));
|
|
142
|
+
const jscore = jaccard(tc, ta);
|
|
143
|
+
if (jscore >= JACCARD_THRESHOLD) {
|
|
144
|
+
return {
|
|
145
|
+
matched: true,
|
|
146
|
+
method: 'token-jaccard',
|
|
147
|
+
score: jscore,
|
|
148
|
+
normalizedLenClaim: c.length,
|
|
149
|
+
normalizedLenActual: a.length,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
matched: false,
|
|
154
|
+
method: 'mismatch',
|
|
155
|
+
score: Math.max(lcs / Math.max(1, c.length), jscore),
|
|
156
|
+
normalizedLenClaim: c.length,
|
|
157
|
+
normalizedLenActual: a.length,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* - Exponential backoff for 429, 503, 504
|
|
4
4
|
* - AbortController for timeout
|
|
5
5
|
*/
|
|
6
|
+
import { followLawAntibot } from "./law-antibot.js";
|
|
6
7
|
/**
|
|
7
8
|
* URL에서 민감 정보(API 키) 마스킹 — 에러 메시지/로그 노출 방지.
|
|
8
9
|
* 법제처 API는 ?OC=KEY 쿼리 파라미터로 키를 받으므로 해당 값만 *** 처리.
|
|
@@ -62,7 +63,7 @@ export async function fetchWithRetry(url, options = {}) {
|
|
|
62
63
|
if (!headers.has("referer") && isLawGoKrHost(url))
|
|
63
64
|
headers.set("referer", DEFAULT_REFERER);
|
|
64
65
|
try {
|
|
65
|
-
|
|
66
|
+
let response = await fetch(url, {
|
|
66
67
|
...fetchOptions,
|
|
67
68
|
headers,
|
|
68
69
|
signal: controller.signal,
|
|
@@ -70,6 +71,16 @@ export async function fetchWithRetry(url, options = {}) {
|
|
|
70
71
|
clearTimeout(timeoutId);
|
|
71
72
|
// Success or non-retryable error
|
|
72
73
|
if (response.ok || !retryOn.includes(response.status)) {
|
|
74
|
+
// law.go.kr JS 안티봇 페이지(클라우드 IP에서 location.assign 리다이렉트) 우회.
|
|
75
|
+
// 로컬/등록 IP에서는 no-op. Fly 등 클라우드 배포에서 UA/Referer로 안 뚫릴 때의 방어층.
|
|
76
|
+
if (response.ok && isLawGoKrHost(url)) {
|
|
77
|
+
try {
|
|
78
|
+
const bypassed = await followLawAntibot(response, url, headers, timeout);
|
|
79
|
+
if (bypassed)
|
|
80
|
+
response = bypassed;
|
|
81
|
+
}
|
|
82
|
+
catch { /* 우회 실패 시 원본 응답으로 진행 */ }
|
|
83
|
+
}
|
|
73
84
|
// 200인데 빈 본문/HTML(법제처 점검·과부하 페이지)이면 일시 장애로 보고 재시도.
|
|
74
85
|
// 이를 막지 않으면 XML 파서가 "missing root element"로 터진다.
|
|
75
86
|
if (response.ok && attempt < retries) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* law.go.kr JS 안티봇 우회 (클라우드 IP 대응)
|
|
3
|
+
*
|
|
4
|
+
* 법제처는 클라우드 IP(GCP/AWS/Fly 등)에서 온 요청에 API 데이터 대신
|
|
5
|
+
* `location.assign(...)` JS 리다이렉트 페이지를 반환할 때가 있다. 이 페이지의
|
|
6
|
+
* 난독화된 URL을 파싱해 토큰 URL로 재요청하면 우회된다.
|
|
7
|
+
* (LexLink-ko-mcp client.py의 `_follow_antibot` 접근을 이식)
|
|
8
|
+
*
|
|
9
|
+
* 로컬/등록 IP에서는 이 페이지가 오지 않으므로 평상시엔 no-op이다. Fly 등
|
|
10
|
+
* 클라우드 배포에서 UA/Referer만으로 뚫리지 않는 경우의 방어층.
|
|
11
|
+
*
|
|
12
|
+
* 한계: Node fetch(undici)는 쿠키 jar가 없어 "안티봇 홉이 심은 세션 쿠키로
|
|
13
|
+
* 원본 재시도 성공" 경로는 제한적이다. 주 경로는 토큰 URL 직접 파싱이다.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* 안티봇 JS의 두 난독화 패턴에서 리다이렉트 경로를 복원한다.
|
|
17
|
+
*
|
|
18
|
+
* - 패턴 A(concat): `x={t:'..',h:'..',o:'..'}; return x.t+x.h+x.o`
|
|
19
|
+
* - 패턴 B(substr): `x={o:'..',c:N},z=M; return o.substr(0,c)+o.substr(c+z)`
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseAntibotUrl(html: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* 응답이 JS 안티봇 페이지면 우회한 새 Response를, 아니면 null(원본 유지)을 반환.
|
|
24
|
+
* 최대 maxHops까지 location.assign 리다이렉트를 추적한다.
|
|
25
|
+
*/
|
|
26
|
+
export declare function followLawAntibot(response: Response, originalUrl: string, headers: Headers, timeout: number, maxHops?: number): Promise<Response | null>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* law.go.kr JS 안티봇 우회 (클라우드 IP 대응)
|
|
3
|
+
*
|
|
4
|
+
* 법제처는 클라우드 IP(GCP/AWS/Fly 등)에서 온 요청에 API 데이터 대신
|
|
5
|
+
* `location.assign(...)` JS 리다이렉트 페이지를 반환할 때가 있다. 이 페이지의
|
|
6
|
+
* 난독화된 URL을 파싱해 토큰 URL로 재요청하면 우회된다.
|
|
7
|
+
* (LexLink-ko-mcp client.py의 `_follow_antibot` 접근을 이식)
|
|
8
|
+
*
|
|
9
|
+
* 로컬/등록 IP에서는 이 페이지가 오지 않으므로 평상시엔 no-op이다. Fly 등
|
|
10
|
+
* 클라우드 배포에서 UA/Referer만으로 뚫리지 않는 경우의 방어층.
|
|
11
|
+
*
|
|
12
|
+
* 한계: Node fetch(undici)는 쿠키 jar가 없어 "안티봇 홉이 심은 세션 쿠키로
|
|
13
|
+
* 원본 재시도 성공" 경로는 제한적이다. 주 경로는 토큰 URL 직접 파싱이다.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* 안티봇 JS의 두 난독화 패턴에서 리다이렉트 경로를 복원한다.
|
|
17
|
+
*
|
|
18
|
+
* - 패턴 A(concat): `x={t:'..',h:'..',o:'..'}; return x.t+x.h+x.o`
|
|
19
|
+
* - 패턴 B(substr): `x={o:'..',c:N},z=M; return o.substr(0,c)+o.substr(c+z)`
|
|
20
|
+
*/
|
|
21
|
+
export function parseAntibotUrl(html) {
|
|
22
|
+
// 패턴 A: 3분할 concat (t + h + o)
|
|
23
|
+
const a = html.match(/t:'([^']*)',h:'([^']*)'/);
|
|
24
|
+
if (a) {
|
|
25
|
+
const o = html.match(/o:'([^']*)'/);
|
|
26
|
+
if (o)
|
|
27
|
+
return a[1] + a[2] + o[1];
|
|
28
|
+
}
|
|
29
|
+
// 패턴 B: substring 슬라이싱
|
|
30
|
+
const b = html.match(/o:'([^']*)',c:(\d+)},z=(\d+)/);
|
|
31
|
+
if (b) {
|
|
32
|
+
const o = b[1];
|
|
33
|
+
const c = Number(b[2]);
|
|
34
|
+
const z = Number(b[3]);
|
|
35
|
+
return o.slice(0, c) + o.slice(c + z);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** timeout이 걸린 단발 fetch */
|
|
40
|
+
async function fetchOnce(url, headers, timeout) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
43
|
+
try {
|
|
44
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timeoutId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 응답이 JS 안티봇 페이지면 우회한 새 Response를, 아니면 null(원본 유지)을 반환.
|
|
52
|
+
* 최대 maxHops까지 location.assign 리다이렉트를 추적한다.
|
|
53
|
+
*/
|
|
54
|
+
export async function followLawAntibot(response, originalUrl, headers, timeout, maxHops = 3) {
|
|
55
|
+
let current = response;
|
|
56
|
+
let hopped = false;
|
|
57
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = await current.clone().text();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return hopped ? current : null;
|
|
64
|
+
}
|
|
65
|
+
// 안티봇 페이지가 아니면 종료 (첫 홉이면 변경 없음 = null)
|
|
66
|
+
if (!text.includes("location.assign")) {
|
|
67
|
+
return hopped ? current : null;
|
|
68
|
+
}
|
|
69
|
+
const path = parseAntibotUrl(text);
|
|
70
|
+
if (!path)
|
|
71
|
+
return hopped ? current : null;
|
|
72
|
+
// path는 보통 절대경로(/DRF/...). 원본 URL의 origin에 붙인다.
|
|
73
|
+
let nextUrl;
|
|
74
|
+
try {
|
|
75
|
+
nextUrl = new URL(path, originalUrl).toString();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return hopped ? current : null;
|
|
79
|
+
}
|
|
80
|
+
const next = await fetchOnce(nextUrl, headers, timeout);
|
|
81
|
+
hopped = true;
|
|
82
|
+
// 토큰 URL이 404면 원본을 한 번 더 시도 (홉이 세션을 세팅했을 수 있음)
|
|
83
|
+
if (next.status === 404) {
|
|
84
|
+
return await fetchOnce(originalUrl, headers, timeout);
|
|
85
|
+
}
|
|
86
|
+
current = next;
|
|
87
|
+
}
|
|
88
|
+
return current;
|
|
89
|
+
}
|
|
@@ -21,6 +21,7 @@ import { parseHangNumber } from "../lib/article-parser.js";
|
|
|
21
21
|
import { truncateResponse } from "../lib/schemas.js";
|
|
22
22
|
import { formatToolError } from "../lib/errors.js";
|
|
23
23
|
import { toArray } from "../lib/xml-parser.js";
|
|
24
|
+
import { matchCitationContent } from "../lib/citation-content-matcher.js";
|
|
24
25
|
export const VerifyCitationsSchema = z.object({
|
|
25
26
|
text: z.string().min(1).describe("검증할 법률 텍스트 (LLM 답변/계약서/판결문 등). 조문 인용이 포함된 문자열"),
|
|
26
27
|
maxCitations: z.number().min(1).max(30).optional().default(15).describe("검증할 최대 인용 개수 (기본 15, 많을수록 느림)"),
|
|
@@ -32,6 +33,23 @@ const ARTICLE_REGEX = /제\s*(\d+)\s*조(?:\s*의\s*(\d+))?(?:\s*제\s*(\d+)\s*
|
|
|
32
33
|
const LAW_NAME_REGEX = /([가-힣][가-힣·ㆍ\s]{0,30}?(?:법률|법|시행령|시행규칙|규칙|규정|조례))$/;
|
|
33
34
|
// 법령명 앞에 붙는 한국어 접속사·부사·수식어 제거 — "또한 상법" → "상법"
|
|
34
35
|
const LAW_NAME_STOPWORDS = /^(또한|그리고|하며|따라서|따라|위해|위하여|의한|의하여|따른|해당|관련|이에|아울러|본|이|저|그|또|및|또는|혹은|한편|더불어|이어|이는|즉|결국|결과적으로|실제로|특히)\s+/u;
|
|
36
|
+
// 인용 바로 뒤 "(제목)"에서 조문 제목 claim 추출 — 내용검증용.
|
|
37
|
+
// 개정이력·날짜·항호 참조 괄호는 조문 제목이 아니므로 제외.
|
|
38
|
+
function extractClaimTitle(after) {
|
|
39
|
+
const m = after.match(/^\s*\(([^)]{1,40})\)/);
|
|
40
|
+
if (!m)
|
|
41
|
+
return undefined;
|
|
42
|
+
const t = m[1].trim();
|
|
43
|
+
if (!/[가-힣]/.test(t))
|
|
44
|
+
return undefined; // 한글 없으면 제목 아님
|
|
45
|
+
if (/(개정|신설|삭제|전문개정|본조신설)/.test(t))
|
|
46
|
+
return undefined; // 개정이력 괄호
|
|
47
|
+
if (/\d{4}\s*\.\s*\d/.test(t))
|
|
48
|
+
return undefined; // 날짜(2020. 1. …)
|
|
49
|
+
if (/^제?\s*\d+\s*(항|호|목|조)/.test(t))
|
|
50
|
+
return undefined; // 항/호/목 참조
|
|
51
|
+
return t;
|
|
52
|
+
}
|
|
35
53
|
function parseCitations(text, maxCitations) {
|
|
36
54
|
const citations = [];
|
|
37
55
|
const seen = new Set();
|
|
@@ -64,6 +82,9 @@ function parseCitations(text, maxCitations) {
|
|
|
64
82
|
if (seen.has(key))
|
|
65
83
|
continue;
|
|
66
84
|
seen.add(key);
|
|
85
|
+
// 조문 번호 바로 뒤 "(제목)" 캡처 — 내용검증(matchCitationContent)용
|
|
86
|
+
const afterIdx = m.index + raw.length;
|
|
87
|
+
const claimTitle = extractClaimTitle(text.slice(afterIdx, afterIdx + 45));
|
|
67
88
|
citations.push({
|
|
68
89
|
raw: raw.trim(),
|
|
69
90
|
lawName,
|
|
@@ -73,6 +94,7 @@ function parseCitations(text, maxCitations) {
|
|
|
73
94
|
ho: hoStr ? parseInt(hoStr, 10) : undefined,
|
|
74
95
|
joCode,
|
|
75
96
|
displayArticle,
|
|
97
|
+
claimTitle,
|
|
76
98
|
});
|
|
77
99
|
}
|
|
78
100
|
return citations;
|
|
@@ -144,6 +166,14 @@ async function verifyOne(apiClient, cite, apiKey) {
|
|
|
144
166
|
// 3단계: 항 검증 (명시된 경우)
|
|
145
167
|
const joTitle = found.조문제목 ? `(${found.조문제목})` : "";
|
|
146
168
|
const officialLabel = `${chosen.lawName} ${cite.displayArticle}`;
|
|
169
|
+
// 내용검증: 인용한 조문 제목이 실제 조문제목과 일치하는지 (lexdiff matchCitationContent 이식).
|
|
170
|
+
// "존재하는 조문에 엉뚱한 제목 붙인 환각"(예: 제750조(계약해제) ← 실제 불법행위) 탐지.
|
|
171
|
+
if (cite.claimTitle && found.조문제목) {
|
|
172
|
+
const cm = matchCitationContent(cite.claimTitle, String(found.조문제목));
|
|
173
|
+
if (!cm.matched) {
|
|
174
|
+
return `✗ ${officialLabel} — [CONTENT_MISMATCH] 인용 제목 '${cite.claimTitle}' ≠ 실제 조문제목 '${found.조문제목}' (단순 표기차이 아니면 인용 오류)`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
147
177
|
if (cite.hang) {
|
|
148
178
|
const rawHang = found.항;
|
|
149
179
|
const hangs = toArray(rawHang);
|
|
@@ -160,7 +190,7 @@ async function verifyOne(apiClient, cite, apiKey) {
|
|
|
160
190
|
}
|
|
161
191
|
return `✗ ${officialLabel}${joTitle} — [NOT_FOUND] 제${cite.hang}항 없음 (최대 제${maxHang}항)`;
|
|
162
192
|
}
|
|
163
|
-
return `✓ ${officialLabel}${joTitle}
|
|
193
|
+
return `✓ ${officialLabel}${joTitle} 실존${cite.claimTitle ? " · 제목 일치" : ""}`;
|
|
164
194
|
}
|
|
165
195
|
catch (e) {
|
|
166
196
|
return `⚠ ${formatCitationLabel(cite, chosen.lawName)} — 조문 조회 실패: ${e instanceof Error ? e.message : String(e)}`;
|
|
@@ -192,7 +222,7 @@ export async function verifyCitations(apiClient, input) {
|
|
|
192
222
|
output += `${line}\n`;
|
|
193
223
|
}
|
|
194
224
|
if (hasHallucination) {
|
|
195
|
-
output += `\n⚠️ [HALLUCINATION_DETECTED] ${failCount}건 인용이 법제처 DB에 실존하지
|
|
225
|
+
output += `\n⚠️ [HALLUCINATION_DETECTED] ${failCount}건 인용이 법제처 DB에 실존하지 않거나 인용 내용(조문 제목)이 실제와 불일치합니다.\n`;
|
|
196
226
|
output += ` LLM이 지어낸 인용일 가능성이 높습니다. 원문을 수정하거나 사용자에게 '인용 오류'를 명시 보고하세요.\n`;
|
|
197
227
|
output += ` 절대로 "검증 완료"로 답변하지 마세요.\n`;
|
|
198
228
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "korean-law-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "법제처 42개 API → 9개 MCP 도구. 법령·판례·조례·조약 + 다단계 리서치(legal_research, 8 task) + 정밀분석(legal_analysis: 인용검증·판례생사·행위시법·영향그래프) + 시점 비교(time_travel) + 상황별 5단계 안내(action_plan) + 국세청 해석례(nts)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|