korean-law-mcp 4.0.7 → 4.2.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 +16 -0
- package/build/lib/api-client.d.ts +6 -0
- package/build/lib/api-client.js +14 -1
- package/build/lib/article-warnings.d.ts +17 -0
- package/build/lib/article-warnings.js +30 -0
- package/build/lib/fetch-with-retry.js +45 -0
- package/build/tool-registry.js +1 -1
- package/build/tools/article-with-precedents.js +7 -4
- package/build/tools/chains.js +149 -124
- package/build/tools/compact-query-planner.d.ts +6 -4
- package/build/tools/compact-query-planner.js +355 -47
- package/build/tools/impact-map.js +16 -2
- package/build/tools/law-text.js +20 -0
- package/build/tools/precedent-evidence.d.ts +22 -0
- package/build/tools/precedent-evidence.js +149 -0
- package/build/tools/precedent-search-core.d.ts +62 -0
- package/build/tools/precedent-search-core.js +261 -0
- package/build/tools/precedents.d.ts +2 -0
- package/build/tools/precedents.js +65 -74
- package/build/tools/search.js +17 -1
- package/build/tools/unified-decisions.js +36 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,22 @@ graph LR
|
|
|
49
49
|
|
|
50
50
|
→ STEP 1 상황진단(주택임대차보호법 자동 식별) → STEP 2 권리/구제수단(판례) → STEP 3 신청기관/기한(행정규칙+해석) → STEP 4 필요서류/양식(별표) → STEP 5 함정/주의(시효·법률구조공단). 평소 말투 그대로 → 실행 가능한 단계로 변환.
|
|
51
51
|
|
|
52
|
+
### + v4.2.0 — 법령 현행성 가드 (개정 전 법령 오답 방지)
|
|
53
|
+
|
|
54
|
+
`search_law` 결과에 `[현행]` / `⚠️[연혁-과거버전]` 라벨 + 시행일 표기(현행 우선 정렬), `get_law_text` 본문 헤더에 조회기준일 vs 시행일 비교 라벨(시행 예정·efYd 과거 조회 경고)과 **구 법령명**("(구 법령명: 화재예방, 소방시설 설치ㆍ유지 및 안전관리에 관한 법률…)") 표기. LLM이 분법·개정된 법령을 학습데이터 속 옛 버전과 혼동하지 않도록 도구 출력 단계에서 차단.
|
|
55
|
+
|
|
56
|
+
### + v4.1.0 — 판례 검색 구조화 + 상세 증거 자동 연결
|
|
57
|
+
|
|
58
|
+
판례 검색을 공통 구조화 core(`searchPrecedentsStructured`)로 통합. 긴 자연어/개념형 질의를 compact query로 보정하고, 사건번호→제목→본문검색 순으로 폴백. 상위 판례를 `get_precedent_text`에 자동 연결(기본 2건/최대 5건)해 근거 본문을 함께 제공하며, `search_decisions(domain="precedent", options.includeText=true)`로 opt-in. 다건 상세조회 합산 시 뒷 판례가 잘리던 문제도 건당 본문 예산 배분으로 해결. (외부 PR #46 + 후속 최적화)
|
|
59
|
+
|
|
60
|
+
### + v4.0.9 — 법제처 API `Referer` 헤더 자동 주입
|
|
61
|
+
|
|
62
|
+
법제처 OPEN API가 **`Referer` 헤더 없는 요청을 OC 키 유효 여부와 무관하게 거부**("사용자 정보 검증 실패")하는 문제 대응. `law.go.kr` 계열 호스트 호출 시 기본 `Referer`를 자동 주입한다(`LAW_REFERER`로 override). IP/도메인 등록 문제로 오인되기 쉬운 증상의 실제 근본 원인이었음 — IP 등록을 했는데도 모든 검색이 실패하던 케이스를 해결. (외부 PR #45)
|
|
63
|
+
|
|
64
|
+
### + v4.0.8 — 법제처 빈/HTML 응답 자동 재시도
|
|
65
|
+
|
|
66
|
+
법제처 OPEN API가 간헐적으로 200 상태에 **빈 본문이나 HTML 점검 페이지**를 반환하던 문제 대응. 이 경우 XML 파서가 `missing root element`로 터지며 "됐다 안 됐다" 증상이 발생했음. `fetchWithRetry`가 빈/HTML 응답을 일시 장애로 간주해 자동 재시도(exponential backoff)하고, 재시도 소진 후에도 빈 응답이면 `search_law`가 `missing root element` 대신 명확한 안내 메시지를 반환하도록 수정. (IP 등록·OC 키와 무관한 외부 응답 불안정 이슈)
|
|
67
|
+
|
|
52
68
|
### + v4.0.7 — 국세청 판례 본문 fallback
|
|
53
69
|
|
|
54
70
|
법제처 JSON API에 본문이 비어 오는 판례를 국세청 `taxlaw.nts.go.kr`에서 HTML로 자동 보강. JSON 실패·파싱 실패·본문 누락 세 경우 모두 fallback으로 진입하며 안전하게 회수됨. 사내망/SSL inspection 환경용 `LAW_EXTERNAL_HTTPS_PROXY`(선택)·`LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED`(진단용) 지원 — 자세한 설정은 아래 "국세청 판례 서버 TLS/프록시 설정" 섹션 참조. (외부 PR #44)
|
|
@@ -20,6 +20,12 @@ export declare class LawApiClient {
|
|
|
20
20
|
private getResponseType;
|
|
21
21
|
/** 응답 본문이 HTML 에러 페이지인지 확인 */
|
|
22
22
|
private checkHtmlError;
|
|
23
|
+
/**
|
|
24
|
+
* 빈 응답 감지 — 법제처가 간헐 장애 시 200으로 빈 본문을 반환하는 케이스.
|
|
25
|
+
* 그대로 XML 파서에 넘기면 "missing root element"로 터지므로 명확한 메시지로 전환.
|
|
26
|
+
* (fetchWithRetry가 빈/HTML 응답을 재시도하지만, 재시도 소진 후에도 빈 응답이면 여기서 처리)
|
|
27
|
+
*/
|
|
28
|
+
private checkEmptyResponse;
|
|
23
29
|
/**
|
|
24
30
|
* 법령 검색
|
|
25
31
|
* @param display 결과 개수 (기본값 법제처 API default, 짧은 법령명("상법" 등) 정확 매칭 찾으려면 큰 값 권장)
|
package/build/lib/api-client.js
CHANGED
|
@@ -55,6 +55,16 @@ export class LawApiClient {
|
|
|
55
55
|
throw new Error(`${context} - API가 HTML 에러 페이지를 반환했습니다. 파라미터를 확인해주세요.${hint}`);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* 빈 응답 감지 — 법제처가 간헐 장애 시 200으로 빈 본문을 반환하는 케이스.
|
|
60
|
+
* 그대로 XML 파서에 넘기면 "missing root element"로 터지므로 명확한 메시지로 전환.
|
|
61
|
+
* (fetchWithRetry가 빈/HTML 응답을 재시도하지만, 재시도 소진 후에도 빈 응답이면 여기서 처리)
|
|
62
|
+
*/
|
|
63
|
+
checkEmptyResponse(text, context) {
|
|
64
|
+
if (!text || !text.trim()) {
|
|
65
|
+
throw new Error(`${context} - 법제처 API가 빈 응답을 반환했습니다. 일시적 장애일 수 있으니 잠시 후 다시 시도하세요.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
58
68
|
/**
|
|
59
69
|
* 법령 검색
|
|
60
70
|
* @param display 결과 개수 (기본값 법제처 API default, 짧은 법령명("상법" 등) 정확 매칭 찾으려면 큰 값 권장)
|
|
@@ -74,7 +84,10 @@ export class LawApiClient {
|
|
|
74
84
|
const url = `${LAW_API_BASE}/lawSearch.do?${params.toString()}`;
|
|
75
85
|
const response = await fetchWithRetry(url);
|
|
76
86
|
await this.throwIfError(response, "searchLaw");
|
|
77
|
-
|
|
87
|
+
const text = await response.text();
|
|
88
|
+
this.checkEmptyResponse(text, "법령 검색");
|
|
89
|
+
this.checkHtmlError(text, "법령 검색 결과를 받지 못했습니다");
|
|
90
|
+
return text;
|
|
78
91
|
}
|
|
79
92
|
/**
|
|
80
93
|
* 현행법령 조회
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 특정 조문 조회 시 전략적 주의(위험신호) 경고 주입
|
|
3
|
+
*
|
|
4
|
+
* 민법 제107~110조(의사표시의 하자)는 무효·취소를 주장하는 측에 입증책임이 있는 항변이다.
|
|
5
|
+
* 계약서·차용증 등 객관적 서면이 명백한 사건에서 이 조문을 1순위 항변으로 삼으면
|
|
6
|
+
* 법원 인용률이 낮으므로, 조회 시 변호사 상담을 권고하는 경고를 함께 노출한다.
|
|
7
|
+
*
|
|
8
|
+
* 주의: 107~110조는 거의 모든 법(형법/상법 등)에 존재하므로 반드시 법령명이 "민법"일 때만 적용한다.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* 법령명·조문 정보로 전략 경고를 반환. 해당 없으면 null.
|
|
12
|
+
*
|
|
13
|
+
* @param lawName 법령명 (정확히 "민법"일 때만 적용 — "민법 시행령" 등 제외)
|
|
14
|
+
* @param joNum 조문번호 문자열 (예: "108")
|
|
15
|
+
* @param joBranch 조문가지번호 (제107조의2 등 가지조문은 제외하기 위해 "0"/"" 만 허용)
|
|
16
|
+
*/
|
|
17
|
+
export declare function getStrategyWarning(lawName: string, joNum: string, joBranch: string): string | null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 특정 조문 조회 시 전략적 주의(위험신호) 경고 주입
|
|
3
|
+
*
|
|
4
|
+
* 민법 제107~110조(의사표시의 하자)는 무효·취소를 주장하는 측에 입증책임이 있는 항변이다.
|
|
5
|
+
* 계약서·차용증 등 객관적 서면이 명백한 사건에서 이 조문을 1순위 항변으로 삼으면
|
|
6
|
+
* 법원 인용률이 낮으므로, 조회 시 변호사 상담을 권고하는 경고를 함께 노출한다.
|
|
7
|
+
*
|
|
8
|
+
* 주의: 107~110조는 거의 모든 법(형법/상법 등)에 존재하므로 반드시 법령명이 "민법"일 때만 적용한다.
|
|
9
|
+
*/
|
|
10
|
+
// 절충 톤: 입증책임·인용률 관점의 정성적 경고 (단정적 패소확률 수치는 미포함)
|
|
11
|
+
const 의사표시하자경고 = {
|
|
12
|
+
"107": "⚠️ [위험신호] 민법 제107조(진의 아닌 의사표시)는 의사표시 하자 항변으로, 비진의 표시는 원칙적으로 유효하고 상대방의 악의·과실을 입증해야 무효가 되므로 인용률이 낮은 조문입니다. 객관적 증거 없이 이 항변에 의존할 경우 패소 위험이 매우 큽니다. 반드시 변호사 상담을 받으세요.",
|
|
13
|
+
"108": "⚠️ [위험신호] 민법 제108조(통정한 허위의 의사표시)는 의사표시 하자 항변으로, 상대방과 짜고 한 허위표시(통정)를 주장하는 측이 입증해야 하므로 인용률이 낮은 조문입니다. 객관적 증거 없이 이 항변에 의존할 경우 패소 위험이 매우 큽니다. 반드시 변호사 상담을 받으세요.",
|
|
14
|
+
"109": "⚠️ [위험신호] 민법 제109조(착오로 인한 의사표시)는 의사표시 하자 항변으로, 법률행위의 중요부분 착오이면서 본인에게 중대한 과실이 없음을 입증해야 취소할 수 있어 인용률이 낮은 조문입니다. 객관적 증거 없이 이 항변에 의존할 경우 패소 위험이 매우 큽니다. 반드시 변호사 상담을 받으세요.",
|
|
15
|
+
"110": "⚠️ [위험신호] 민법 제110조(사기, 강박에 의한 의사표시)는 의사표시 하자 항변으로, 기망 또는 강박 행위와 그로 인한 의사표시의 인과관계를 주장하는 측이 입증해야 하므로 인용률이 낮은 조문입니다. 객관적 증거 없이 이 항변에 의존할 경우 패소 위험이 큽니다. 다만 납득할 만한 증거가 있으면 결과가 달라질 수 있습니다. 반드시 변호사 상담을 받으세요."
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* 법령명·조문 정보로 전략 경고를 반환. 해당 없으면 null.
|
|
19
|
+
*
|
|
20
|
+
* @param lawName 법령명 (정확히 "민법"일 때만 적용 — "민법 시행령" 등 제외)
|
|
21
|
+
* @param joNum 조문번호 문자열 (예: "108")
|
|
22
|
+
* @param joBranch 조문가지번호 (제107조의2 등 가지조문은 제외하기 위해 "0"/"" 만 허용)
|
|
23
|
+
*/
|
|
24
|
+
export function getStrategyWarning(lawName, joNum, joBranch) {
|
|
25
|
+
if (lawName.trim() !== "민법")
|
|
26
|
+
return null;
|
|
27
|
+
if (joBranch && joBranch !== "0")
|
|
28
|
+
return null;
|
|
29
|
+
return 의사표시하자경고[joNum] ?? null;
|
|
30
|
+
}
|
|
@@ -17,10 +17,36 @@ const DEFAULT_TIMEOUT = 30000;
|
|
|
17
17
|
const DEFAULT_RETRIES = 3;
|
|
18
18
|
const DEFAULT_RETRY_DELAY = 1000;
|
|
19
19
|
const DEFAULT_RETRY_ON = [429, 503, 504];
|
|
20
|
+
/**
|
|
21
|
+
* 법제처 API가 200으로 빈 본문/HTML(점검·과부하 페이지)을 반환하는 간헐 장애 감지.
|
|
22
|
+
* 정상 응답은 XML(`<`) 또는 JSON(`{`/`[`)으로 시작하므로 빈 본문과 HTML 페이지만 걸러낸다.
|
|
23
|
+
*/
|
|
24
|
+
function detectBadBody(text) {
|
|
25
|
+
const t = text.trim();
|
|
26
|
+
if (!t)
|
|
27
|
+
return "empty";
|
|
28
|
+
if (/^<!doctype html/i.test(t) || /^<html[\s>]/i.test(t))
|
|
29
|
+
return "html";
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
20
32
|
// 법제처 OPEN API가 Node 기본 UA(undici)를 봇으로 분류해 거부하므로
|
|
21
33
|
// 일반 브라우저 UA로 호출. LAW_USER_AGENT 환경변수로 override 가능.
|
|
22
34
|
const DEFAULT_USER_AGENT = process.env.LAW_USER_AGENT ||
|
|
23
35
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
36
|
+
// 법제처 OPEN API는 Referer 헤더가 없으면 OC 키가 유효해도
|
|
37
|
+
// "사용자 정보 검증에 실패하였습니다 (정확한 서버장비의 IP주소 및 도메인주소를 등록해 주세요)"
|
|
38
|
+
// XML을 반환한다. 메시지는 IP/도메인 등록 문제로 오인되기 쉬우나 실제 원인은 Referer 누락이다.
|
|
39
|
+
// (브라우저 UA만으로는 통과하지 못하고 Referer가 결정적). LAW_REFERER 환경변수로 override 가능.
|
|
40
|
+
const DEFAULT_REFERER = process.env.LAW_REFERER || "https://www.law.go.kr/";
|
|
41
|
+
// Referer를 붙일 법제처 계열 호스트 판별 (그 외 호스트엔 주입하지 않음).
|
|
42
|
+
function isLawGoKrHost(targetUrl) {
|
|
43
|
+
try {
|
|
44
|
+
return /(^|\.)law\.go\.kr$/i.test(new URL(targetUrl).hostname);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
24
50
|
/**
|
|
25
51
|
* Fetch with automatic retry and timeout
|
|
26
52
|
*/
|
|
@@ -33,6 +59,8 @@ export async function fetchWithRetry(url, options = {}) {
|
|
|
33
59
|
const headers = new Headers(fetchOptions.headers);
|
|
34
60
|
if (!headers.has("user-agent"))
|
|
35
61
|
headers.set("user-agent", DEFAULT_USER_AGENT);
|
|
62
|
+
if (!headers.has("referer") && isLawGoKrHost(url))
|
|
63
|
+
headers.set("referer", DEFAULT_REFERER);
|
|
36
64
|
try {
|
|
37
65
|
const response = await fetch(url, {
|
|
38
66
|
...fetchOptions,
|
|
@@ -42,6 +70,23 @@ export async function fetchWithRetry(url, options = {}) {
|
|
|
42
70
|
clearTimeout(timeoutId);
|
|
43
71
|
// Success or non-retryable error
|
|
44
72
|
if (response.ok || !retryOn.includes(response.status)) {
|
|
73
|
+
// 200인데 빈 본문/HTML(법제처 점검·과부하 페이지)이면 일시 장애로 보고 재시도.
|
|
74
|
+
// 이를 막지 않으면 XML 파서가 "missing root element"로 터진다.
|
|
75
|
+
if (response.ok && attempt < retries) {
|
|
76
|
+
let bodyText = null;
|
|
77
|
+
try {
|
|
78
|
+
bodyText = await response.clone().text();
|
|
79
|
+
}
|
|
80
|
+
catch { /* clone 실패 시 정상 처리 */ }
|
|
81
|
+
if (bodyText !== null) {
|
|
82
|
+
const bad = detectBadBody(bodyText);
|
|
83
|
+
if (bad) {
|
|
84
|
+
lastError = new Error(`법제처 API 비정상 응답(${bad === "empty" ? "빈 본문" : "HTML 페이지"}) - ${maskSensitiveUrl(url)}`);
|
|
85
|
+
await sleep(getRetryDelay(response, retryDelay, attempt));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
45
90
|
return response;
|
|
46
91
|
}
|
|
47
92
|
// Retryable error - check if we have retries left
|
package/build/tool-registry.js
CHANGED
|
@@ -634,7 +634,7 @@ export const allTools = [
|
|
|
634
634
|
// === 통합 도구 (v3) ===
|
|
635
635
|
{
|
|
636
636
|
name: "search_decisions",
|
|
637
|
-
description: "[통합검색] 18개 도메인(판례·해석례·헌재·행심·조세심판·관세·국세청·공정위·개인정보위·노동위·권익위·소청심사·학칙·공사공단·공공기관·조약·영문법령) 통합 검색. domain으로 선택. 세무 관련 국세청 직접 회신 해석은 domain='nts'.",
|
|
637
|
+
description: "[통합검색] 18개 도메인(판례·해석례·헌재·행심·조세심판·관세·국세청·공정위·개인정보위·노동위·권익위·소청심사·학칙·공사공단·공공기관·조약·영문법령) 통합 검색. domain으로 선택. 판례 본문까지 필요하면 domain='precedent', options.includeText=true, options.detailLimit=N. 세무 관련 국세청 직접 회신 해석은 domain='nts'.",
|
|
638
638
|
schema: SearchDecisionsSchema,
|
|
639
639
|
handler: searchDecisions
|
|
640
640
|
},
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { getLawText } from "./law-text.js";
|
|
6
|
-
import {
|
|
6
|
+
import { renderPrecedentSearchResult } from "./precedents.js";
|
|
7
|
+
import { searchPrecedentsStructured } from "./precedent-search-core.js";
|
|
7
8
|
import { truncateResponse } from "../lib/schemas.js";
|
|
8
9
|
import { formatToolError } from "../lib/errors.js";
|
|
9
10
|
export const GetArticleWithPrecedentsSchema = z.object({
|
|
@@ -39,14 +40,16 @@ export async function getArticleWithPrecedents(apiClient, input) {
|
|
|
39
40
|
// 3. 관련 판례 검색
|
|
40
41
|
const precedentQuery = `${lawName} ${input.jo}`;
|
|
41
42
|
try {
|
|
42
|
-
const precedentResult = await
|
|
43
|
+
const precedentResult = await searchPrecedentsStructured(apiClient, {
|
|
43
44
|
query: precedentQuery,
|
|
44
45
|
display: 5,
|
|
45
46
|
page: 1,
|
|
46
47
|
apiKey: input.apiKey
|
|
48
|
+
}, {
|
|
49
|
+
fallbackPolicy: "none",
|
|
47
50
|
});
|
|
48
|
-
if (
|
|
49
|
-
const precedentText = precedentResult
|
|
51
|
+
if (precedentResult.hits.length > 0) {
|
|
52
|
+
const precedentText = renderPrecedentSearchResult(precedentResult);
|
|
50
53
|
// 판례 결과가 있으면 추가
|
|
51
54
|
if (precedentText && !precedentText.includes("검색 결과가 없습니다")) {
|
|
52
55
|
resultText += `\n${"=".repeat(60)}\n`;
|
package/build/tools/chains.js
CHANGED
|
@@ -12,7 +12,7 @@ import { runScenario, detectScenario, formatSections, formatSuggestedActions } f
|
|
|
12
12
|
import { analyzeDocument } from "./document-analysis.js";
|
|
13
13
|
import { getThreeTier } from "./three-tier.js";
|
|
14
14
|
import { getBatchArticles } from "./batch-articles.js";
|
|
15
|
-
import {
|
|
15
|
+
import { renderPrecedentSearchResult, searchPrecedents } from "./precedents.js";
|
|
16
16
|
import { searchInterpretations } from "./interpretations.js";
|
|
17
17
|
import { searchAdminAppeals } from "./admin-appeals.js";
|
|
18
18
|
import { compareOldNew } from "./comparison.js";
|
|
@@ -24,12 +24,43 @@ import { searchAiLaw, searchAiLawStructured } from "./life-law.js";
|
|
|
24
24
|
import { getLawText } from "./law-text.js";
|
|
25
25
|
import { searchTaxTribunalDecisions } from "./tax-tribunal-decisions.js";
|
|
26
26
|
import { searchNlrcDecisions, searchPipcDecisions } from "./committee-decisions.js";
|
|
27
|
-
import { fetchSearchDetailChain
|
|
28
|
-
import {
|
|
27
|
+
import { fetchSearchDetailChain } from "./search-detail-chain.js";
|
|
28
|
+
import { searchPrecedentsStructured, } from "./precedent-search-core.js";
|
|
29
|
+
import { fetchPrecedentEvidence, validatePrecedentSearchResult } from "./precedent-evidence.js";
|
|
29
30
|
// ========================================
|
|
30
31
|
// Helpers
|
|
31
32
|
// ========================================
|
|
32
|
-
const
|
|
33
|
+
const PRECEDENT_FALLBACK_LIMIT = 5;
|
|
34
|
+
function emptyStructuredPrecedentResult(args) {
|
|
35
|
+
return {
|
|
36
|
+
originalArgs: args,
|
|
37
|
+
totalCount: 0,
|
|
38
|
+
page: args.page || 1,
|
|
39
|
+
hits: [],
|
|
40
|
+
attempts: [],
|
|
41
|
+
fallbackUsed: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function errorCallResult(error, toolName) {
|
|
45
|
+
const response = formatToolError(error, toolName);
|
|
46
|
+
return {
|
|
47
|
+
text: response.content?.[0]?.text || (error instanceof Error ? error.message : String(error)),
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function safeSearchPrecedentsStructured(apiClient, args, context = {}) {
|
|
52
|
+
try {
|
|
53
|
+
return {
|
|
54
|
+
result: await searchPrecedentsStructured(apiClient, args, context),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
result: emptyStructuredPrecedentResult(args),
|
|
60
|
+
error: errorCallResult(error, "search_precedents"),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
33
64
|
async function callTool(
|
|
34
65
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
66
|
handler, apiClient, input) {
|
|
@@ -142,93 +173,60 @@ function selectLawTextSource(laws, query) {
|
|
|
142
173
|
}
|
|
143
174
|
return { reliableLaws, textLaw: laws[0], lowConfidence: laws.length > 0 };
|
|
144
175
|
}
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
.
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.filter(line => /^\[\d+\]\s+/.test(line.trim()));
|
|
164
|
-
}
|
|
165
|
-
function extractFirstPrecedentId(searchText) {
|
|
166
|
-
for (const line of searchText.split("\n")) {
|
|
167
|
-
const match = line.trim().match(/^\[(\d+)\]\s+/);
|
|
168
|
-
if (match?.[1])
|
|
169
|
-
return match[1];
|
|
170
|
-
}
|
|
171
|
-
return undefined;
|
|
172
|
-
}
|
|
173
|
-
async function validateRetryPrecedentResult(apiClient, candidate, result, apiKey) {
|
|
174
|
-
const caseLines = extractPrecedentCaseLines(result.text);
|
|
175
|
-
if (caseLines.some(line => textContainsCandidateTerm(line, candidate))) {
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
178
|
-
if (candidate.search !== 2 && !candidate.requiresResultValidation) {
|
|
179
|
-
return false;
|
|
176
|
+
async function searchPrecedentsForChain(apiClient, input, context = {}, detailLimit = 2) {
|
|
177
|
+
const args = {
|
|
178
|
+
query: input.query,
|
|
179
|
+
display: input.display,
|
|
180
|
+
page: 1,
|
|
181
|
+
apiKey: input.apiKey,
|
|
182
|
+
};
|
|
183
|
+
const { result: search, error } = await safeSearchPrecedentsStructured(apiClient, args, {
|
|
184
|
+
...context,
|
|
185
|
+
maxFallbackAttempts: context.maxFallbackAttempts ?? PRECEDENT_FALLBACK_LIMIT,
|
|
186
|
+
validateResult: validation => validatePrecedentSearchResult(apiClient, validation, { apiKey: input.apiKey }),
|
|
187
|
+
});
|
|
188
|
+
if (error) {
|
|
189
|
+
return {
|
|
190
|
+
structuredResult: search,
|
|
191
|
+
searchResult: error,
|
|
192
|
+
detailResult: null,
|
|
193
|
+
};
|
|
180
194
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
195
|
+
const searchResult = {
|
|
196
|
+
text: renderPrecedentSearchResult(search),
|
|
197
|
+
isError: search.hits.length === 0,
|
|
198
|
+
};
|
|
199
|
+
const evidence = await fetchPrecedentEvidence(apiClient, search, {
|
|
200
|
+
apiKey: input.apiKey,
|
|
201
|
+
detailLimit,
|
|
202
|
+
full: false,
|
|
188
203
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return candidate.search === 2 ? `"${candidate.query}"(본문검색)` : `"${candidate.query}"`;
|
|
204
|
+
return {
|
|
205
|
+
structuredResult: search,
|
|
206
|
+
searchResult,
|
|
207
|
+
detailResult: evidence ? { text: evidence.text, isError: evidence.isError } : null,
|
|
208
|
+
};
|
|
195
209
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
210
|
+
function combineStructuredPrecedentResults(results) {
|
|
211
|
+
const nonEmpty = results.filter(result => result.hits.length > 0);
|
|
212
|
+
if (nonEmpty.length === 0)
|
|
213
|
+
return null;
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
const hits = nonEmpty.flatMap(result => result.hits)
|
|
216
|
+
.filter(hit => {
|
|
217
|
+
if (seen.has(hit.id))
|
|
218
|
+
return false;
|
|
219
|
+
seen.add(hit.id);
|
|
220
|
+
return true;
|
|
207
221
|
});
|
|
208
|
-
if (candidates.length === 0)
|
|
209
|
-
return precedentResult;
|
|
210
|
-
for (const candidate of candidates) {
|
|
211
|
-
const retried = await callTool(searchPrecedents, apiClient, {
|
|
212
|
-
query: candidate.query,
|
|
213
|
-
search: candidate.search,
|
|
214
|
-
display: 5,
|
|
215
|
-
apiKey: input.apiKey,
|
|
216
|
-
});
|
|
217
|
-
if (!isSearchFailure(retried) && retried.text.trim() && await validateRetryPrecedentResult(apiClient, candidate, retried, input.apiKey)) {
|
|
218
|
-
return {
|
|
219
|
-
text: `판례 1차 검색 실패 후 재검색어 "${candidate.query}"로 조회했습니다.\n\n${retried.text}`,
|
|
220
|
-
isError: false,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
222
|
return {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
223
|
+
originalArgs: nonEmpty[0].originalArgs,
|
|
224
|
+
totalCount: hits.length,
|
|
225
|
+
page: 1,
|
|
226
|
+
hits,
|
|
227
|
+
attempts: results.flatMap(result => result.attempts),
|
|
228
|
+
fallbackUsed: results.some(result => result.fallbackUsed),
|
|
229
|
+
successfulAttempt: nonEmpty[0].successfulAttempt,
|
|
232
230
|
};
|
|
233
231
|
}
|
|
234
232
|
function wrapResult(text) {
|
|
@@ -366,9 +364,9 @@ export const chainDisputePrepSchema = z.object({
|
|
|
366
364
|
export async function chainDisputePrep(apiClient, input) {
|
|
367
365
|
try {
|
|
368
366
|
const parts = [`═══ 쟁송 대비: ${input.query} ═══`];
|
|
369
|
-
// Step 1: 판례 + 행정심판 (병렬)
|
|
367
|
+
// Step 1: 판례 + 행정심판 (병렬). 판례는 구조화 hit 기반 상세조회까지 같은 경로에서 처리한다.
|
|
368
|
+
const precedentPromise = searchPrecedentsForChain(apiClient, { query: input.query, display: 8, apiKey: input.apiKey }, { route: routeQuery(input.query) });
|
|
370
369
|
const parallel = [
|
|
371
|
-
callTool(searchPrecedents, apiClient, { query: input.query, display: 8, apiKey: input.apiKey }),
|
|
372
370
|
callTool(searchAdminAppeals, apiClient, { query: input.query, display: 8, apiKey: input.apiKey }),
|
|
373
371
|
];
|
|
374
372
|
// Step 2: 도메인별 전문 결정례 추가
|
|
@@ -382,24 +380,26 @@ export async function chainDisputePrep(apiClient, input) {
|
|
|
382
380
|
else if (domain === "privacy") {
|
|
383
381
|
parallel.push(callTool(searchPipcDecisions, apiClient, { query: input.query, display: 5, apiKey: input.apiKey }));
|
|
384
382
|
}
|
|
385
|
-
const results = await Promise.all(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const [precDetail, appealDetail] = await Promise.all([
|
|
389
|
-
fetchSearchDetailChain(apiClient, "search_precedents", results[0], { apiKey: input.apiKey }),
|
|
390
|
-
fetchSearchDetailChain(apiClient, "search_admin_appeals", results[1], { apiKey: input.apiKey }),
|
|
383
|
+
const [precedentBundle, results] = await Promise.all([
|
|
384
|
+
precedentPromise,
|
|
385
|
+
Promise.all(parallel),
|
|
391
386
|
]);
|
|
392
|
-
|
|
393
|
-
|
|
387
|
+
parts.push(secOrSkip("대법원 판례", precedentBundle.searchResult));
|
|
388
|
+
parts.push(secOrSkip("행정심판례", results[0]));
|
|
389
|
+
const [appealDetail] = await Promise.all([
|
|
390
|
+
fetchSearchDetailChain(apiClient, "search_admin_appeals", results[0], { apiKey: input.apiKey }),
|
|
391
|
+
]);
|
|
392
|
+
if (precedentBundle.detailResult)
|
|
393
|
+
parts.push(secOrSkip("대법원 판례 상세", precedentBundle.detailResult));
|
|
394
394
|
if (appealDetail)
|
|
395
395
|
parts.push(secOrSkip("행정심판례 상세", appealDetail));
|
|
396
|
-
if (results[
|
|
396
|
+
if (results[1]) {
|
|
397
397
|
const domainNames = {
|
|
398
398
|
tax: "조세심판원 결정",
|
|
399
399
|
labor: "중앙노동위 결정",
|
|
400
400
|
privacy: "개인정보위 결정",
|
|
401
401
|
};
|
|
402
|
-
parts.push(secOrSkip(domainNames[domain] || "전문 결정례", results[
|
|
402
|
+
parts.push(secOrSkip(domainNames[domain] || "전문 결정례", results[1]));
|
|
403
403
|
const domainSearchTools = {
|
|
404
404
|
tax: "search_tax_tribunal_decisions",
|
|
405
405
|
labor: "search_nlrc_decisions",
|
|
@@ -407,7 +407,7 @@ export async function chainDisputePrep(apiClient, input) {
|
|
|
407
407
|
};
|
|
408
408
|
const domainSearchTool = domainSearchTools[domain];
|
|
409
409
|
if (domainSearchTool) {
|
|
410
|
-
const domainDetail = await fetchSearchDetailChain(apiClient, domainSearchTool, results[
|
|
410
|
+
const domainDetail = await fetchSearchDetailChain(apiClient, domainSearchTool, results[1], { apiKey: input.apiKey });
|
|
411
411
|
if (domainDetail)
|
|
412
412
|
parts.push(secOrSkip(`${domainNames[domain] || "전문 결정례"} 상세`, domainDetail));
|
|
413
413
|
}
|
|
@@ -555,7 +555,7 @@ export const chainFullResearchSchema = z.object({
|
|
|
555
555
|
export async function chainFullResearch(apiClient, input) {
|
|
556
556
|
try {
|
|
557
557
|
const parts = [`═══ 종합 리서치: ${input.query} ═══`];
|
|
558
|
-
// Step 1: AI 검색 + 법령 검색 +
|
|
558
|
+
// Step 1: AI 검색 + 법령 검색 + 해석례를 병렬 실행하고, 판례는 AI 구조화 신호를 받은 뒤 공통 core로 검색한다.
|
|
559
559
|
// findLaws를 안전하게 래핑 (throw 시 Promise.all 전체 reject 방지)
|
|
560
560
|
const safeFindLaws = async () => {
|
|
561
561
|
try {
|
|
@@ -565,14 +565,17 @@ export async function chainFullResearch(apiClient, input) {
|
|
|
565
565
|
return [];
|
|
566
566
|
}
|
|
567
567
|
};
|
|
568
|
-
const [aiResult, rawLawsResult,
|
|
568
|
+
const [aiResult, rawLawsResult, interpResult] = await Promise.all([
|
|
569
569
|
callAiLaw(apiClient, { query: input.query, search: "0", display: 10, page: 1, apiKey: input.apiKey }),
|
|
570
570
|
safeFindLaws(),
|
|
571
|
-
callTool(searchPrecedents, apiClient, { query: input.query, display: 5, apiKey: input.apiKey }),
|
|
572
571
|
callTool(searchInterpretations, apiClient, { query: input.query, display: 5, apiKey: input.apiKey }),
|
|
573
572
|
]);
|
|
574
573
|
const { reliableLaws: lawsResult, textLaw, lowConfidence } = selectLawTextSource(rawLawsResult, input.query);
|
|
575
|
-
const
|
|
574
|
+
const precedentBundle = await searchPrecedentsForChain(apiClient, { query: input.query, display: 5, apiKey: input.apiKey }, {
|
|
575
|
+
aiLawArticles: aiResult.aiLawArticles,
|
|
576
|
+
route: routeQuery(input.query),
|
|
577
|
+
maxFallbackAttempts: PRECEDENT_FALLBACK_LIMIT,
|
|
578
|
+
});
|
|
576
579
|
parts.push(secOrSkip("AI 법령검색 결과", aiResult));
|
|
577
580
|
// 법령 본문 (첫 번째 결과)
|
|
578
581
|
if (textLaw) {
|
|
@@ -580,14 +583,13 @@ export async function chainFullResearch(apiClient, input) {
|
|
|
580
583
|
const confidenceSuffix = lowConfidence ? " (관련도 낮음)" : "";
|
|
581
584
|
parts.push(secOrSkip(`${textLaw.lawName} 본문${confidenceSuffix}`, lawText));
|
|
582
585
|
}
|
|
583
|
-
parts.push(secOrSkip("관련 판례",
|
|
586
|
+
parts.push(secOrSkip("관련 판례", precedentBundle.searchResult));
|
|
584
587
|
parts.push(secOrSkip("법령 해석례", interpResult));
|
|
585
|
-
const [
|
|
586
|
-
fetchSearchDetailChain(apiClient, "search_precedents", finalPrecResult, { apiKey: input.apiKey }),
|
|
588
|
+
const [interpDetail] = await Promise.all([
|
|
587
589
|
fetchSearchDetailChain(apiClient, "search_interpretations", interpResult, { apiKey: input.apiKey }),
|
|
588
590
|
]);
|
|
589
|
-
if (
|
|
590
|
-
parts.push(secOrSkip("관련 판례 상세",
|
|
591
|
+
if (precedentBundle.detailResult)
|
|
592
|
+
parts.push(secOrSkip("관련 판례 상세", precedentBundle.detailResult));
|
|
591
593
|
if (interpDetail)
|
|
592
594
|
parts.push(secOrSkip("법령 해석례 상세", interpDetail));
|
|
593
595
|
// 키워드 확장
|
|
@@ -700,35 +702,58 @@ export async function chainDocumentReview(apiClient, input) {
|
|
|
700
702
|
}
|
|
701
703
|
// 중복 제거 후 최대 5개 힌트로 제한
|
|
702
704
|
const uniqueHints = [...new Set(searchHints)].slice(0, 5);
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
705
|
+
const precedentSearches = await Promise.all(uniqueHints.map(hint => safeSearchPrecedentsStructured(apiClient, {
|
|
706
|
+
query: hint,
|
|
707
|
+
display: 3,
|
|
708
|
+
page: 1,
|
|
709
|
+
apiKey: input.apiKey,
|
|
710
|
+
}, {
|
|
711
|
+
documentHints: [hint],
|
|
712
|
+
maxFallbackAttempts: 3,
|
|
713
|
+
validateResult: validation => validatePrecedentSearchResult(apiClient, validation, { apiKey: input.apiKey }),
|
|
714
|
+
})));
|
|
715
|
+
const precedentResults = precedentSearches.map(search => search.result);
|
|
716
|
+
// AI 법령 검색은 상위 3개 힌트로 병렬 실행
|
|
708
717
|
const lawHints = uniqueHints.slice(0, 3);
|
|
709
|
-
|
|
710
|
-
searchPromises.push(callTool(searchAiLaw, apiClient, { query: hint, display: 3, apiKey: input.apiKey }));
|
|
711
|
-
}
|
|
712
|
-
const searchResults = await Promise.all(searchPromises);
|
|
718
|
+
const lawResults = await Promise.all(lawHints.map(hint => callTool(searchAiLaw, apiClient, { query: hint, display: 3, apiKey: input.apiKey })));
|
|
713
719
|
// 판례 결과 합산
|
|
714
720
|
const precTexts = [];
|
|
715
721
|
for (let i = 0; i < uniqueHints.length; i++) {
|
|
716
|
-
const r =
|
|
717
|
-
if (
|
|
718
|
-
precTexts.push(`[${uniqueHints[i]}]\n${r
|
|
722
|
+
const r = precedentResults[i];
|
|
723
|
+
if (r.hits.length > 0) {
|
|
724
|
+
precTexts.push(`[${uniqueHints[i]}]\n${renderPrecedentSearchResult(r)}`);
|
|
719
725
|
}
|
|
720
726
|
}
|
|
721
727
|
if (precTexts.length > 0) {
|
|
722
728
|
parts.push(sec("관련 판례", precTexts.join("\n\n")));
|
|
723
729
|
}
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
730
|
+
const precedentErrors = precedentSearches
|
|
731
|
+
.map((search, index) => search.error ? `[${uniqueHints[index]}]\n${search.error.text}` : "")
|
|
732
|
+
.filter(text => text.trim());
|
|
733
|
+
if (precedentErrors.length > 0) {
|
|
734
|
+
parts.push(secOrSkip("판례 검색 실패", {
|
|
735
|
+
text: precedentErrors.join("\n\n"),
|
|
736
|
+
isError: true,
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
const combinedPrecedents = combineStructuredPrecedentResults(precedentResults);
|
|
740
|
+
if (combinedPrecedents) {
|
|
741
|
+
const precedentEvidence = await fetchPrecedentEvidence(apiClient, combinedPrecedents, {
|
|
742
|
+
apiKey: input.apiKey,
|
|
743
|
+
detailLimit: 2,
|
|
744
|
+
full: false,
|
|
745
|
+
});
|
|
746
|
+
if (precedentEvidence) {
|
|
747
|
+
parts.push(secOrSkip("관련 판례 상세", {
|
|
748
|
+
text: precedentEvidence.text,
|
|
749
|
+
isError: precedentEvidence.isError,
|
|
750
|
+
}));
|
|
751
|
+
}
|
|
727
752
|
}
|
|
728
753
|
// 법령 결과 합산
|
|
729
754
|
const lawTexts = [];
|
|
730
755
|
for (let i = 0; i < lawHints.length; i++) {
|
|
731
|
-
const r =
|
|
756
|
+
const r = lawResults[i];
|
|
732
757
|
if (!r.isError && r.text.trim()) {
|
|
733
758
|
lawTexts.push(`[${lawHints[i]}]\n${r.text}`);
|
|
734
759
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AiLawArticleSignal } from "./life-law.js";
|
|
2
|
-
export type CompactQuerySource = "
|
|
2
|
+
export type CompactQuerySource = "case_number" | "original_query" | "original_keyword" | "document_hint" | "ai_law_article_title" | "ai_law_law_article_title" | "router";
|
|
3
3
|
export type PrecedentSearchScope = 1 | 2;
|
|
4
|
-
export type CompactQueryVariantKind = "
|
|
4
|
+
export type CompactQueryVariantKind = "case_number" | "original_query" | "original_keyword" | "document_hint" | "raw" | "terminal_function_word_removed" | "terminal_function_word_spaced" | "law_title" | "router";
|
|
5
5
|
export interface CompactQueryCandidate {
|
|
6
6
|
query: string;
|
|
7
7
|
source: CompactQuerySource;
|
|
@@ -9,6 +9,7 @@ export interface CompactQueryCandidate {
|
|
|
9
9
|
reason: string;
|
|
10
10
|
search: PrecedentSearchScope;
|
|
11
11
|
semanticAnchor?: string;
|
|
12
|
+
validationTermGroups?: string[][];
|
|
12
13
|
variantKind: CompactQueryVariantKind;
|
|
13
14
|
requiresResultValidation: boolean;
|
|
14
15
|
}
|
|
@@ -20,10 +21,11 @@ interface RouteLike {
|
|
|
20
21
|
}
|
|
21
22
|
export interface CompactQueryInput {
|
|
22
23
|
originalQuery: string;
|
|
24
|
+
includeOriginal?: boolean;
|
|
25
|
+
caseNumber?: string;
|
|
26
|
+
documentHints?: string[];
|
|
23
27
|
aiLawArticles?: AiLawArticleSignal[];
|
|
24
|
-
aiLawText?: string;
|
|
25
28
|
route?: RouteLike;
|
|
26
|
-
failedSearchText?: string;
|
|
27
29
|
max?: number;
|
|
28
30
|
}
|
|
29
31
|
export declare function buildCompactLegalQueries(input: CompactQueryInput): CompactQueryCandidate[];
|