korean-law-mcp 4.2.1 → 4.3.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
  # Korean Law MCP
2
2
 
3
- **법제처 42개 API를 17개 도구로.** 법령, 판례, 행정규칙, 자치법규, 조약, 해석례(국세청 포함) + **LLM 환각 방지 인용 검증** + **조문 영향 그래프** + **시점 비교 자동 diff** + **이럴 땐 이렇게 — 5단계 안내**를 AI 어시스턴트나 터미널에서 바로 사용.
3
+ **법제처 42개 API를 19개 도구로.** 법령, 판례, 행정규칙, 자치법규, 조약, 해석례(국세청 포함) + **LLM 환각 방지 인용 검증** + **조문 영향 그래프** + **시점 비교 자동 diff** + **이럴 땐 이렇게 — 5단계 안내** + **판례 생사 확인(Citator)** + **행위시법 판단**을 AI 어시스턴트나 터미널에서 바로 사용.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/korean-law-mcp.svg)](https://www.npmjs.com/package/korean-law-mcp)
6
6
  [![MCP 1.27](https://img.shields.io/badge/MCP-1.27-blue)](https://modelcontextprotocol.io)
@@ -14,6 +14,35 @@
14
14
 
15
15
  ---
16
16
 
17
+ ## v4.3 — 판례 생사 확인 + 행위시법 판단
18
+
19
+ **"이 판례 아직 유효한가?" + "사건 시점엔 어떤 법이 적용되나?"** — 법률 실무에서 가장 위험한 두 실수를 잡는다.
20
+
21
+ ### 1. `cite_check` — 판례 생사 확인 (한국형 Shepard's Citator)
22
+
23
+ ```
24
+ "2007다27670 아직 유효해?"
25
+ ```
26
+
27
+ → 그 사건번호를 **인용한 후속 판례를 본문검색으로 역추적** + 전원합의체 후속 판결 본문 정밀 스캔 → 변경·폐기 선언 감지:
28
+
29
+ ```
30
+ 📊 판정: ❌ 변경·폐기 신호 감지 — 2018다248626(판례 변경 선언, 저촉 범위 변경)
31
+ 맥락: "…2008년 전원합의체 판결은 이 판결의 견해와 배치되는 범위에서 변경하기로 한다…"
32
+ ```
33
+
34
+ 판결문이 사건번호 대신 "(이하 '2008년 전원합의체 판결'이라 한다)" 별칭으로 변경 선언하는 관행까지 추적. 변경된 판례를 살아있는 것처럼 인용하는 사고를 차단한다. 무료 도구 중 유일.
35
+
36
+ ### 2. `applicable_law` — 행위시법 판단 + 부칙 경과규정
37
+
38
+ ```
39
+ "2023.5.10 당시 도로교통법 제44조"
40
+ ```
41
+
42
+ → 기준일에 **시행 중이던 버전(MST) 특정** → 그 시점 조문 본문 → 현행과 비교 → **이후 개정 부칙의 적용례·경과조치 자동 발췌** + 행위시법(형법 §1)·제재처분 위반행위시법(행정기본법 §14③) 법리 안내. LLM이 현행법으로 오답하는 것을 구조적으로 방지.
43
+
44
+ ---
45
+
17
46
  ## v4.0 — 3개 킬러 기능 동시 추가
18
47
 
19
48
  **조문 영향 그래프 + 시점 비교 + 단계별 안내.** 법무팀·연구자·실수요자가 매뉴얼로 며칠 걸리던 작업이 한 번에.
@@ -328,7 +357,7 @@ lexdiff에서 "산안기준규칙" 질의가 법제처 aiSearch의 키워드 부
328
357
  **v3.0.2** — Unified Architecture + Setup Wizard
329
358
 
330
359
  법제처 41개 API를 89개 MCP 도구로 구조화했던 v2.
331
- v3는 같은 41개 API를 **14개 도구**로 재압축했습니다 (v3.2.2 이후 15개, v4.0 현재 17개).
360
+ v3는 같은 41개 API를 **14개 도구**로 재압축했습니다 (v3.2.2 이후 15개, v4.3 현재 19개).
332
361
 
333
362
  | | 법제처 원본 | v2 | v3 |
334
363
  |---|:---:|:---:|:---:|
@@ -392,7 +421,7 @@ MCP 도구 설계에서 **도구 수 ≠ 기능 수**입니다.
392
421
 
393
422
  대한민국에는 **1,600개 이상의 현행 법률**, **10,000개 이상의 행정규칙**, 그리고 대법원·헌법재판소·조세심판원·관세청까지 이어지는 방대한 판례 체계가 있습니다. 이 모든 게 [법제처](https://www.law.go.kr)라는 하나의 사이트에 있지만, 개발자 경험은 최악입니다.
394
423
 
395
- 이 프로젝트는 그 전체 법령 시스템을 **17개 도구**로 감싸서, AI 어시스턴트나 스크립트에서 바로 호출할 수 있게 만듭니다. 법제처를 수백 번 수동 검색하다 지친 공무원이 만들었습니다.
424
+ 이 프로젝트는 그 전체 법령 시스템을 **19개 도구**로 감싸서, AI 어시스턴트나 스크립트에서 바로 호출할 수 있게 만듭니다. 법제처를 수백 번 수동 검색하다 지친 공무원이 만들었습니다.
396
425
 
397
426
  ---
398
427
 
@@ -497,7 +526,7 @@ https://korean-law-mcp.fly.dev/mcp?oc=honggildong
497
526
 
498
527
  > **참고**: 커넥터 URL을 수정하려면 삭제 후 다시 추가해야 합니다.
499
528
 
500
- > v3부터 프로필 선택이 필요 없습니다. 17개 도구가 42개 API 전체를 커버합니다.
529
+ > v3부터 프로필 선택이 필요 없습니다. 19개 도구가 42개 API 전체를 커버합니다.
501
530
  > 기존에 `?profile=lite&oc=...` 주소를 넣으셨다면 **그대로 두셔도 됩니다** — 동일하게 작동합니다.
502
531
 
503
532
  ---
@@ -719,9 +748,9 @@ reg delete "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /
719
748
 
720
749
  ---
721
750
 
722
- ## 도구 구조 (17개)
751
+ ## 도구 구조 (19개)
723
752
 
724
- v4는 17개 도구만 노출합니다. 나머지 전문 도구는 `discover_tools` → `execute_tool`로 접근.
753
+ v4는 19개 도구만 노출합니다. 나머지 전문 도구는 `discover_tools` → `execute_tool`로 접근.
725
754
 
726
755
  | 구분 | 도구 | 설명 | 시나리오 확장 |
727
756
  |------|------|------|-------------|
@@ -738,8 +767,10 @@ v4는 17개 도구만 노출합니다. 나머지 전문 도구는 `discover_tool
738
767
  | | `get_annexes` | 별표/서식 조회 (금액표·요율표·별지서식) |
739
768
  | **통합** (2) | `search_decisions` | **17개 도메인** 통합 검색 (판례·헌재·조세심판·공정위·노동위·관세·해석례·행심·개인정보위·권익위·소청심사·학칙·공사공단·공공기관·조약·영문법령) |
740
769
  | | `get_decision_text` | **17개 도메인** 전문 조회 |
741
- | **킬러** (2) | `verify_citations` | LLM 환각 방지 — 인용 조문 실존 여부 일괄 검증 (v3.5) |
770
+ | **킬러** (4) | `verify_citations` | LLM 환각 방지 — 인용 조문 실존 여부 일괄 검증 (v3.5) |
742
771
  | | `impact_map` | 조문 영향 그래프 — 인용 판례·해석·자치법규 역방향 탐색 + mermaid (v4.0) |
772
+ | | `cite_check` | 판례 생사 확인 — 후속 인용 역추적 + 변경·폐기 감지, 한국형 Citator (v4.3) |
773
+ | | `applicable_law` | 행위시법 판단 — 시점 적용 버전 + 부칙 경과규정 발췌 (v4.3) |
743
774
  | **메타** (2) | `discover_tools` | 전문 도구 검색 (용어·별표·이력·비교 등) |
744
775
  | | `execute_tool` | 전문 도구 프록시 실행 |
745
776
 
@@ -749,7 +780,7 @@ v4는 17개 도구만 노출합니다. 나머지 전문 도구는 `discover_tool
749
780
 
750
781
  ## 주요 특징
751
782
 
752
- - **42개 API → 17개 도구** — 법령, 판례, 행정규칙, 자치법규, 헌재결정, 조세심판, 관세해석, 국세청 해석례, 조약, 학칙/공단/공공기관 규정, 법령용어
783
+ - **42개 API → 19개 도구** — 법령, 판례, 행정규칙, 자치법규, 헌재결정, 조세심판, 관세해석, 국세청 해석례, 조약, 학칙/공단/공공기관 규정, 법령용어
753
784
  - **MCP + CLI** — Claude Desktop에서도, 터미널에서도 같은 도구 사용
754
785
  - **법률 도메인 특화** — 약칭 자동 인식(`화관법` → `화학물질관리법`), 조문번호 변환(`제38조` ↔ `003800`), 3단 위임 구조 시각화
755
786
  - **별표/별지서식 본문 추출** — HWPX·HWP·PDF·XLSX·DOCX 자동 변환 ([kordoc](https://github.com/chrisryugj/kordoc) 엔진)
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 통일된 에러 처리 모듈
3
3
  */
4
+ import { maskSensitiveUrl } from "./fetch-with-retry.js";
4
5
  /**
5
6
  * 에러 코드
6
7
  */
@@ -114,7 +115,8 @@ export function formatToolError(error, context) {
114
115
  suggestions = [];
115
116
  }
116
117
  const lines = [];
117
- lines.push(`[${code}] ${msg}`);
118
+ // 최종 방어선 — 도구 코드가 URL 포함 에러를 직접 만들어도 API 키가 클라이언트로 새지 않게
119
+ lines.push(`[${code}] ${maskSensitiveUrl(msg)}`);
118
120
  if (context) {
119
121
  lines.push(`도구: ${context}`);
120
122
  }
@@ -64,45 +64,43 @@ export async function findLaws(apiClient, query, apiKey, max = 3, searchDisplay
64
64
  if (cached)
65
65
  return cached.slice(0, max);
66
66
  const effectiveMax = Math.max(max, searchDisplay); // 정렬 대상 전체 수집
67
+ // 인프라 에러(타임아웃·5xx·파싱 실패)는 "법령 없음"과 구분해야 한다.
68
+ // 삼키면 법제처 장애 중 verify_citations가 실존 조문을 NOT_FOUND로 오판한다.
69
+ let lastInfraError;
70
+ const trySearch = async (q) => {
71
+ try {
72
+ const xmlText = await apiClient.searchLaw(q, apiKey, searchDisplay);
73
+ return parseLawXml(xmlText, effectiveMax);
74
+ }
75
+ catch (e) {
76
+ if (e instanceof Error && /429|401|403|API 키/.test(e.message))
77
+ throw e;
78
+ lastInfraError = e;
79
+ return [];
80
+ }
81
+ };
67
82
  // 1차: 원본 쿼리
68
- let results = [];
69
- try {
70
- const xmlText = await apiClient.searchLaw(query, apiKey, searchDisplay);
71
- results = parseLawXml(xmlText, effectiveMax);
72
- }
73
- catch (e) {
74
- if (e instanceof Error && /429|401|403|API 키/.test(e.message))
75
- throw e;
76
- }
83
+ let results = await trySearch(query);
77
84
  // 2차: 부가 키워드 제거
78
85
  if (results.length === 0) {
79
86
  const stripped = stripNonLawKeywords(query);
80
87
  if (stripped && stripped !== query) {
81
- try {
82
- const xmlText = await apiClient.searchLaw(stripped, apiKey, searchDisplay);
83
- results = parseLawXml(xmlText, effectiveMax);
84
- }
85
- catch (e) {
86
- if (e instanceof Error && /429|401|403|API 키/.test(e.message))
87
- throw e;
88
- }
88
+ results = await trySearch(stripped);
89
89
  }
90
90
  }
91
91
  // 3차: 법령명 패턴 직접 추출
92
92
  if (results.length === 0) {
93
93
  const lawNameMatch = query.match(/[가-힣]+(법|시행령|시행규칙|규칙|규정|령)(?:\s|$)/);
94
94
  if (lawNameMatch) {
95
- const extracted = lawNameMatch[0].trim();
96
- try {
97
- const xmlText = await apiClient.searchLaw(extracted, apiKey, searchDisplay);
98
- results = parseLawXml(xmlText, effectiveMax);
99
- }
100
- catch (e) {
101
- if (e instanceof Error && /429|401|403|API 키/.test(e.message))
102
- throw e;
103
- }
95
+ results = await trySearch(lawNameMatch[0].trim());
104
96
  }
105
97
  }
98
+ // 전 단계가 인프라 에러로만 끝났으면 "없음"이 아니라 "실패"로 전파
99
+ if (results.length === 0 && lastInfraError !== undefined) {
100
+ throw lastInfraError instanceof Error
101
+ ? new Error(`법령 검색 실패 (법제처 API 오류 — 법령이 없다는 뜻이 아님): ${lastInfraError.message}`)
102
+ : lastInfraError;
103
+ }
106
104
  // 관련도 정렬
107
105
  if (results.length > 1) {
108
106
  const queryWords = query.replace(NON_LAW_NAME_RE, " ")
@@ -29,15 +29,16 @@ function extractLawName(query) {
29
29
  // 수식어: 단독 키워드만 제거 (법령명 일부인 경우 보존)
30
30
  // "별표 1", "별표" 등 독립적 사용만 제거
31
31
  .replace(/별표\s*\d*/g, "")
32
- .replace(/(?:^|\s)(판례|판결|사례|대법원|헌재|행정심판)(?:\s|$)/g, " ")
33
- .replace(/(?:^|\s)(해석례?|유권해석|질의회신)(?:\s|$)/g, " ")
34
- .replace(/(?:^|\s)(개정|이력|변경|연혁|신구대조)(?:\s|$)/g, " ")
35
- .replace(/(?:^|\s)(3단비교|위임|인용|체계)(?:\s|$)/g, " ")
36
- .replace(/(?:^|\s)(영문|영어|English)(?:\s|$)/gi, " ")
37
- .replace(/(?:^|\s)(서식|양식|별지|신청서)(?:\s|$)/g, " ")
32
+ // 뒤 경계는 lookahead(소비 안 함) — 소비하면 "개정 연혁"처럼 연속 키워드의 두 번째가 살아남음
33
+ .replace(/(?:^|\s)(판례|판결|사례|대법원|헌재|행정심판)(?=\s|$)/g, " ")
34
+ .replace(/(?:^|\s)(해석례?|유권해석|질의회신)(?=\s|$)/g, " ")
35
+ .replace(/(?:^|\s)(개정|이력|변경|연혁|신구대조)(?=\s|$)/g, " ")
36
+ .replace(/(?:^|\s)(3단비교|위임|인용|체계)(?=\s|$)/g, " ")
37
+ .replace(/(?:^|\s)(영문|영어|English)(?=\s|$)/gi, " ")
38
+ .replace(/(?:^|\s)(서식|양식|별지|신청서)(?=\s|$)/g, " ")
38
39
  // 조례/규칙은 법령명 일부이므로 유지
39
40
  // 동사형 수식어 제거
40
- .replace(/(?:^|\s)(검색|조회|확인|알려줘|찾아줘|보여줘)(?:\s|$)/g, " ")
41
+ .replace(/(?:^|\s)(검색|조회|확인|알려줘|찾아줘|보여줘)(?=\s|$)/g, " ")
41
42
  // 정리
42
43
  .replace(/\s+/g, " ")
43
44
  .trim();
@@ -70,6 +71,11 @@ const routePatterns = [
70
71
  if (/(?:파급|영향\s*그래프|impact|인용한\s*(?:모든|판례|판결|어디))/i.test(query)) {
71
72
  return { _skip: true };
72
73
  }
74
+ // applicable_law 양보: 기준일 + 시점 키워드 (예: "2023.5.10 당시 도로교통법 제44조")
75
+ if (/행위시법/.test(query) ||
76
+ (/\d{4}\s*[년.\-/]\s*\d{1,2}/.test(query) && /당시|시점|기준|에\s*적용/.test(query))) {
77
+ return { _skip: true };
78
+ }
73
79
  const jo = extractArticleNumber(query);
74
80
  const lawName = extractLawName(query);
75
81
  return { _searchQuery: lawName, jo, _needsMst: true };
@@ -592,6 +598,55 @@ const routePatterns = [
592
598
  reason: "시민 상황 키워드 → chain_full_research (action_plan 5단계 가이드)",
593
599
  priority: 7,
594
600
  },
601
+ // ── 29-0-3. 판례 생사 확인 (cite_check, v4.3) ──
602
+ // "2013다61381 아직 유효해?", "이 판례 변경됐어?", "2018두42559 인용 추적"
603
+ {
604
+ name: "cite_check",
605
+ patterns: [
606
+ /\d{2,4}\s*[가-힣]{1,5}\s*\d{1,7}.*?(?:유효|살아|변경|폐기|뒤집|생사|추적|아직|citator)/i,
607
+ /판례\s*(?:생사|유효성|변경\s*여부|폐기\s*여부|인용\s*추적)/,
608
+ /(?:변경|폐기)된?\s*판례(?:인지|냐|인가요?|\s*확인)/,
609
+ ],
610
+ tool: "cite_check",
611
+ extract: (query) => {
612
+ const m = query.match(/(\d{2,4})\s*([가-힣]{1,5})\s*(\d{1,7})/);
613
+ if (!m)
614
+ return { _fallback: true, query };
615
+ return { caseNumber: `${m[1]}${m[2]}${m[3]}` };
616
+ },
617
+ reason: "사건번호 + 유효성 키워드 → cite_check (후속 인용 역추적 + 변경·폐기 감지)",
618
+ priority: 2,
619
+ },
620
+ // ── 29-0-4. 행위시법 판단 (applicable_law, v4.3) ──
621
+ // "2023년 5월 당시 도로교통법 제44조", "사건 시점에 적용되는 법", "행위시법"
622
+ {
623
+ name: "applicable_law",
624
+ patterns: [
625
+ /(\d{4})\s*[년\.\-\/]\s*(\d{1,2})\s*[월\.\-\/]?\s*(\d{1,2})?\s*일?\s*(?:당시|시점|기준|에\s*적용)/,
626
+ /(?:행위\s*시|사건\s*당시|계약\s*당시|위반\s*당시)\s*(?:의\s*)?(?:법|적용)/,
627
+ /행위시법|적용\s*법령\s*판단|당시\s*시행/,
628
+ ],
629
+ tool: "applicable_law",
630
+ extract: (query) => {
631
+ const dm = query.match(/(\d{4})\s*[년\.\-\/]\s*(\d{1,2})(?:\s*[월\.\-\/]\s*(\d{1,2}))?/);
632
+ if (!dm)
633
+ return { _fallback: true, query };
634
+ const date = `${dm[1]}${dm[2].padStart(2, "0")}${(dm[3] || "1").padStart(2, "0")}`;
635
+ const jo = extractArticleNumber(query);
636
+ // 키워드 strip은 단어 경계 필수 — "근로기준법"의 "기준"을 떼면 법령명 파괴
637
+ const lawName = extractLawName(query.replace(/(\d{4})\s*[년\.\-\/]\s*\d{1,2}\s*[월\.\-\/]?\s*\d{0,2}\s*일?/g, " ")
638
+ .replace(/(?:^|\s)(당시|시점|기준일?|행위시법|사건|적용|시행)(?=\s|$)/g, " ")
639
+ .replace(/에\s*적용되?는?\s*법령?/g, " "));
640
+ if (!lawName)
641
+ return { _fallback: true, query };
642
+ const params = { lawName, date };
643
+ if (jo)
644
+ params.jo = jo;
645
+ return params;
646
+ },
647
+ reason: "기준일 + 법령명 → applicable_law (시점 적용 버전 + 부칙 경과규정)",
648
+ priority: 2,
649
+ },
595
650
  // ── 29-1. 인용 검증 (citation validator) ──
596
651
  {
597
652
  name: "verify_citations",
@@ -709,8 +764,10 @@ export function routeQuery(query) {
709
764
  // 자연어 날짜 조건 추출 (검색어에서 시간 표현 분리)
710
765
  const dateParsed = parseDateRange(q);
711
766
  const dateRange = dateParsed.range;
767
+ // 행위시법(applicable_law) 의도는 날짜 자체가 파라미터 — 날짜 제거 전 원문으로 매칭해야 함
768
+ const applicableLawHint = /행위시법|\d{4}\s*[년.\-/].{0,14}(?:당시|시점|기준|에\s*적용)/.test(q);
712
769
  // 날짜 표현이 제거된 순수 검색어로 패턴 매칭
713
- const routeInput = dateParsed.cleanQuery || q;
770
+ const routeInput = applicableLawHint ? q : (dateParsed.cleanQuery || q);
714
771
  const result = _matchRoute(routeInput);
715
772
  // 날짜 범위가 있으면 결과에 첨부
716
773
  if (dateRange) {
@@ -35,6 +35,8 @@ export const TOOL_ALIASES = {
35
35
  "해석례": ["법제처 해석", "유권해석", "질의회신"],
36
36
  // 도구 의도 별칭
37
37
  "인용검증": ["verify_citations", "조문 실존 확인", "환각 검증"],
38
+ "판례생사": ["cite_check", "판례 유효성", "판례 변경 여부", "인용 추적", "citator"],
39
+ "행위시법": ["applicable_law", "당시 법령", "적용 법령 판단", "경과조치", "부칙"],
38
40
  "문서검토": ["analyze_document", "chain_document_review", "계약서 검토", "약관 검토"],
39
41
  "처분기준": ["chain_action_basis", "과태료 기준", "과징금 기준", "영업정지 기간"],
40
42
  "절차매뉴얼": ["chain_procedure_detail", "처리 절차", "신청 방법", "수수료"],
@@ -70,5 +72,7 @@ export const TOOL_CATEGORIES = {
70
72
  "영문법령": ["search_english_law", "get_english_law_text"],
71
73
  "용어": ["search_legal_terms", "get_legal_term_kb", "get_legal_term_detail", "get_daily_term", "get_daily_to_legal", "get_legal_to_daily", "get_term_articles", "get_related_laws"],
72
74
  "문서분석": ["analyze_document"],
75
+ "판례생사": ["cite_check"],
76
+ "행위시법": ["applicable_law"],
73
77
  "유틸리티": ["parse_jo_code", "get_law_abbreviations"],
74
78
  };
@@ -7,6 +7,11 @@
7
7
  * 예: <strong class="tbl_tx_type">지방</strong>자치법 → 지방자치법
8
8
  */
9
9
  export declare function stripHtml(text: string): string;
10
+ /**
11
+ * 단일 객체 정규화 (Critical Rule 6) — API 응답의 배열 필드가 단일 객체로 올 수 있음.
12
+ * 기존 수동 패턴 `Array.isArray(x) ? x : x ? [x] : []`과 의미 동일 (falsy → []).
13
+ */
14
+ export declare function toArray<T>(x: T | T[] | null | undefined): T[];
10
15
  /**
11
16
  * XML 태그에서 텍스트 추출 (CDATA 지원)
12
17
  */
@@ -9,6 +9,13 @@
9
9
  export function stripHtml(text) {
10
10
  return text.replace(/<[^>]+>/g, "");
11
11
  }
12
+ /**
13
+ * 단일 객체 정규화 (Critical Rule 6) — API 응답의 배열 필드가 단일 객체로 올 수 있음.
14
+ * 기존 수동 패턴 `Array.isArray(x) ? x : x ? [x] : []`과 의미 동일 (falsy → []).
15
+ */
16
+ export function toArray(x) {
17
+ return Array.isArray(x) ? x : x ? [x] : [];
18
+ }
12
19
  /**
13
20
  * XML 태그에서 텍스트 추출 (CDATA 지원)
14
21
  */
@@ -9,6 +9,7 @@ import express from "express";
9
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
10
  import { requestContext } from "../lib/session-state.js";
11
11
  import { maskSensitiveUrl } from "../lib/fetch-with-retry.js";
12
+ import { TOOL_COUNTS } from "../tool-registry.js";
12
13
  import { VERSION } from "../version.js";
13
14
  /**
14
15
  * 에러 메시지에서 민감 정보(API 키 포함 URL) scrub.
@@ -97,26 +98,49 @@ export async function startHTTPServer(createServer, port) {
97
98
  health: "/health",
98
99
  },
99
100
  tools: {
100
- exposed: 16,
101
- total: 92,
102
- description: "V3_EXPOSED 16개 직노출, 나머지 76개는 execute_tool 경유",
101
+ exposed: TOOL_COUNTS.exposed,
102
+ total: TOOL_COUNTS.total,
103
+ description: `V3_EXPOSED ${TOOL_COUNTS.exposed}개 직노출, 나머지 ${TOOL_COUNTS.total - TOOL_COUNTS.exposed}개는 execute_tool 경유`,
103
104
  },
104
105
  });
105
106
  });
106
107
  app.get("/health", (req, res) => {
107
108
  res.json({ status: "ok", timestamp: new Date().toISOString() });
108
109
  });
110
+ // 서버 LAW_OC 폴백 사용량 전역 상한 — 키 없는 분산 요청이 서버 키의 법제처 quota를
111
+ // 소진시키는 것 방지 (IP당 limit만으로는 막을 수 없음). 0이면 폴백 비활성.
112
+ const fallbackRpm = parseInt(process.env.FALLBACK_RATE_LIMIT_RPM || "120", 10);
113
+ const fallbackBucket = { count: 0, resetAt: 0 };
114
+ function fallbackAllowed() {
115
+ if (fallbackRpm <= 0)
116
+ return false;
117
+ const now = Date.now();
118
+ if (now >= fallbackBucket.resetAt) {
119
+ fallbackBucket.count = 0;
120
+ fallbackBucket.resetAt = now + 60000;
121
+ }
122
+ return ++fallbackBucket.count <= fallbackRpm;
123
+ }
109
124
  // POST /mcp - stateless 요청 처리
110
125
  app.post("/mcp", async (req, res) => {
111
- // Extract API key: URL query > header
112
- const apiKeyFromQuery = req.query.oc;
113
- const apiKey = apiKeyFromQuery ||
114
- req.headers["apikey"] ||
126
+ // Extract API key: header > URL query
127
+ // 쿼리스트링 키는 프록시/엣지 액세스 로그에 평문으로 남으므로 헤더 사용 권장 (하위호환용 유지)
128
+ const apiKey = req.headers["apikey"] ||
115
129
  req.headers["law_oc"] ||
116
130
  req.headers["law-oc"] ||
117
131
  req.headers["x-api-key"] ||
118
132
  req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
119
- req.headers["x-law-oc"];
133
+ req.headers["x-law-oc"] ||
134
+ req.query.oc;
135
+ // 자체 키 없는 요청은 서버 LAW_OC로 폴백 — 전역 상한 적용
136
+ if (!apiKey && !fallbackAllowed()) {
137
+ res.status(429).json({
138
+ jsonrpc: "2.0",
139
+ error: { code: -32000, message: "Shared API quota exceeded. Provide your own key via 'apiKey' header (free: https://open.law.go.kr)." },
140
+ id: null,
141
+ });
142
+ return;
143
+ }
120
144
  let server;
121
145
  let transport;
122
146
  try {
@@ -180,12 +204,19 @@ export async function startHTTPServer(createServer, port) {
180
204
  console.error(`✓ MCP endpoint: http://0.0.0.0:${port}/mcp`);
181
205
  console.error(`✓ Health check: http://0.0.0.0:${port}/health`);
182
206
  });
183
- // 종료 처리
184
- async function gracefulShutdown(signal) {
207
+ // 종료 처리 — in-flight 요청 완료 대기 (최대 10초), 이후 강제 종료
208
+ function gracefulShutdown(signal) {
185
209
  console.error(`${signal} received, shutting down server...`);
186
- expressServer.close();
187
- console.error("Server shutdown complete");
188
- process.exit(0);
210
+ const forceExit = setTimeout(() => {
211
+ console.error("Shutdown timeout (10s) — forcing exit");
212
+ process.exit(1);
213
+ }, 10000);
214
+ forceExit.unref();
215
+ expressServer.close(() => {
216
+ clearTimeout(forceExit);
217
+ console.error("Server shutdown complete");
218
+ process.exit(0);
219
+ });
189
220
  }
190
221
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
191
222
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
@@ -9,4 +9,9 @@ import type { McpTool } from "./lib/types.js";
9
9
  * 모든 MCP 도구 정의
10
10
  */
11
11
  export declare const allTools: McpTool[];
12
+ /** 노출/전체 도구 수 — 헬스체크 등 표기용 파생값 (하드코딩 금지) */
13
+ export declare const TOOL_COUNTS: {
14
+ exposed: number;
15
+ total: number;
16
+ };
12
17
  export declare function registerTools(server: Server, apiClient: LawApiClient): void;
@@ -53,6 +53,8 @@ import { getLinkedOrdinances, LinkedOrdinancesSchema, getLinkedOrdinanceArticles
53
53
  import { analyzeDocument, AnalyzeDocumentSchema } from "./tools/document-analysis.js";
54
54
  import { verifyCitations, VerifyCitationsSchema } from "./tools/verify-citations.js";
55
55
  import { impactMap, ImpactMapSchema } from "./tools/impact-map.js";
56
+ import { citeCheck, CiteCheckSchema } from "./tools/cite-check.js";
57
+ import { applicableLaw, ApplicableLawSchema } from "./tools/applicable-law.js";
56
58
  // Chain tool imports
57
59
  import { chainLawSystem, chainLawSystemSchema, chainActionBasis, chainActionBasisSchema, chainDisputePrep, chainDisputePrepSchema, chainAmendmentTrack, chainAmendmentTrackSchema, chainOrdinanceCompare, chainOrdinanceCompareSchema, chainFullResearch, chainFullResearchSchema, chainProcedureDetail, chainProcedureDetailSchema, chainDocumentReview, chainDocumentReviewSchema, } from "./tools/chains.js";
58
60
  /**
@@ -618,6 +620,20 @@ export const allTools = [
618
620
  schema: ImpactMapSchema,
619
621
  handler: impactMap
620
622
  },
623
+ // === 판례 인용 추적 (v4.3 killer feature) ===
624
+ {
625
+ name: "cite_check",
626
+ description: "[판례생사] 한국형 Shepard's Citator — 사건번호(예: 2013다61381)로 ① 그 판례를 인용한 후속 판례 역추적(본문검색) ② 전원합의체 후속 판결의 변경·폐기 문구 정밀 스캔 ③ 계속인용/변경가능성 판정. '이 판례 아직 유효한가' 확인용. 변경·폐기된 판례 인용 사고 방지.",
627
+ schema: CiteCheckSchema,
628
+ handler: citeCheck
629
+ },
630
+ // === 행위시법 판단 (v4.3 killer feature) ===
631
+ {
632
+ name: "applicable_law",
633
+ description: "[행위시법] '사건 시점(예: 2023.5.10)에 적용되는 법은?' — 기준일에 시행 중이던 법령 버전(MST) 특정 + 그 시점 조문 본문 + 현행과 비교 + 이후 개정 부칙의 적용례·경과조치 자동 발췌 + 행위시법/처분시법 법리 안내. lawName + date 필수, jo 선택. LLM이 현행법으로 오답하는 것 방지.",
634
+ schema: ApplicableLawSchema,
635
+ handler: applicableLaw
636
+ },
621
637
  // === 메타 도구 (lite 프로필용) ===
622
638
  {
623
639
  name: "discover_tools",
@@ -687,18 +703,19 @@ const V3_EXPOSED = new Set([
687
703
  "discover_tools", "execute_tool",
688
704
  "verify_citations", // v3.5: LLM 환각 방지 인용 검증
689
705
  "impact_map", // v4.0: 조문 영향 그래프 (역방향 탐색 + mermaid)
706
+ "cite_check", // v4.3: 판례 생사 확인 (한국형 Shepard's Citator)
707
+ "applicable_law", // v4.3: 행위시법 판단 (시점 적용 버전 + 부칙 경과규정)
690
708
  ]);
691
709
  // 이름 기반 O(1) 조회용 Map
692
- const toolMap = new Map();
710
+ // allTools는 정적 모듈 로드 시 1회만 구성 (HTTP 모드에서 요청마다 재구성 방지)
711
+ const toolMap = new Map(allTools.map(tool => [tool.name, tool]));
712
+ // 메타 도구가 전체 도구 목록 참조할 수 있도록 주입
713
+ setAllToolsRef(allTools);
714
+ // V3_EXPOSED만 노출 (나머지는 execute_tool 경유)
715
+ const exposedTools = allTools.filter(t => V3_EXPOSED.has(t.name));
716
+ /** 노출/전체 도구 수 — 헬스체크 등 표기용 파생값 (하드코딩 금지) */
717
+ export const TOOL_COUNTS = { exposed: exposedTools.length, total: allTools.length };
693
718
  export function registerTools(server, apiClient) {
694
- // Map 초기화
695
- toolMap.clear();
696
- for (const tool of allTools)
697
- toolMap.set(tool.name, tool);
698
- // 메타 도구가 전체 도구 목록 참조할 수 있도록 주입
699
- setAllToolsRef(allTools);
700
- // V3_EXPOSED 16개만 노출 (나머지는 execute_tool 경유)
701
- const exposedTools = allTools.filter(t => V3_EXPOSED.has(t.name));
702
719
  // ListTools 핸들러
703
720
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
704
721
  tools: exposedTools.map(tool => ({
@@ -0,0 +1,30 @@
1
+ /**
2
+ * applicable_law — 행위시법 판단 (v4.3 killer feature)
3
+ *
4
+ * 문제: "사건 발생 시점(예: 2023.5.10)에 어떤 법이 적용되나"는 법률 실무 최빈 질문인데,
5
+ * LLM은 항상 현행법으로 답해서 오답을 낸다. 결론은 부칙 경과조치가 뒤집을 수 있다.
6
+ *
7
+ * 입력: 법령명 + 기준일(행위·계약·처분 시점) + 조문(선택)
8
+ * 처리:
9
+ * 1. lsHistory 연혁으로 기준일에 시행 중이던 버전(MST) 특정
10
+ * 2. 그 버전의 해당 조문 본문 조회 (eflaw는 MST가 버전 고유)
11
+ * 3. 현행 조문과 동일/변경 비교
12
+ * 4. 이후 개정 부칙에서 적용례·경과조치 자동 발췌 (공포번호 매칭)
13
+ * 5. 행위시법/재판시법/제재처분 법리 주의 문구
14
+ *
15
+ * 차별점: time_travel은 두 시점 diff. 이 도구는 "특정 시점의 적용 법령 버전 특정 + 경과규정".
16
+ * 한계: 경과조치 '해석'은 하지 않음 — 발췌와 경고까지만 (해석은 사람/LLM 몫).
17
+ */
18
+ import { z } from "zod";
19
+ import type { LawApiClient } from "../lib/api-client.js";
20
+ import type { ToolResponse } from "../lib/types.js";
21
+ export declare const ApplicableLawSchema: z.ZodObject<{
22
+ lawName: z.ZodString;
23
+ date: z.ZodString;
24
+ jo: z.ZodOptional<z.ZodString>;
25
+ apiKey: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strip>;
27
+ export type ApplicableLawInput = z.infer<typeof ApplicableLawSchema>;
28
+ /** 다양한 날짜 표기 → YYYYMMDD. 실패 시 null */
29
+ export declare function normalizeDate(input: string): string | null;
30
+ export declare function applicableLaw(apiClient: LawApiClient, input: ApplicableLawInput): Promise<ToolResponse>;
@@ -0,0 +1,248 @@
1
+ /**
2
+ * applicable_law — 행위시법 판단 (v4.3 killer feature)
3
+ *
4
+ * 문제: "사건 발생 시점(예: 2023.5.10)에 어떤 법이 적용되나"는 법률 실무 최빈 질문인데,
5
+ * LLM은 항상 현행법으로 답해서 오답을 낸다. 결론은 부칙 경과조치가 뒤집을 수 있다.
6
+ *
7
+ * 입력: 법령명 + 기준일(행위·계약·처분 시점) + 조문(선택)
8
+ * 처리:
9
+ * 1. lsHistory 연혁으로 기준일에 시행 중이던 버전(MST) 특정
10
+ * 2. 그 버전의 해당 조문 본문 조회 (eflaw는 MST가 버전 고유)
11
+ * 3. 현행 조문과 동일/변경 비교
12
+ * 4. 이후 개정 부칙에서 적용례·경과조치 자동 발췌 (공포번호 매칭)
13
+ * 5. 행위시법/재판시법/제재처분 법리 주의 문구
14
+ *
15
+ * 차별점: time_travel은 두 시점 diff. 이 도구는 "특정 시점의 적용 법령 버전 특정 + 경과규정".
16
+ * 한계: 경과조치 '해석'은 하지 않음 — 발췌와 경고까지만 (해석은 사람/LLM 몫).
17
+ */
18
+ import { z } from "zod";
19
+ import { truncateResponse } from "../lib/schemas.js";
20
+ import { formatToolError, notFoundResponse } from "../lib/errors.js";
21
+ import { findLaws } from "../lib/law-search.js";
22
+ import { fetchHistoricalVersionsFull } from "../lib/historical-utils.js";
23
+ import { buildJO } from "../lib/law-parser.js";
24
+ import { cleanHtml } from "../lib/article-parser.js";
25
+ import { toArray } from "../lib/xml-parser.js";
26
+ export const ApplicableLawSchema = z.object({
27
+ lawName: z.string().describe("법령명 (예: '도로교통법', '근로기준법')"),
28
+ date: z.string().describe("기준일 — 행위·계약·처분 시점 (예: '2023-05-10', '2023.5.10', '20230510')"),
29
+ jo: z.string().optional().describe("조문 번호 (예: '제44조', '제10조의2'). 지정 시 해당 시점 조문 본문 + 현행 비교 제공"),
30
+ apiKey: z.string().optional().describe("법제처 Open API 인증키(OC). 사용자가 제공한 경우 전달"),
31
+ });
32
+ /** 다양한 날짜 표기 → YYYYMMDD. 실패 시 null */
33
+ export function normalizeDate(input) {
34
+ const m = input.trim().match(/(\d{4})\s*[.\-/년\s]?\s*(\d{1,2})\s*[.\-/월\s]?\s*(\d{1,2})\s*일?/);
35
+ if (!m) {
36
+ const digits = input.replace(/\D/g, "");
37
+ return /^\d{8}$/.test(digits) ? digits : null;
38
+ }
39
+ const y = parseInt(m[1], 10);
40
+ const mo = parseInt(m[2], 10);
41
+ const d = parseInt(m[3], 10);
42
+ if (y < 1900 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31)
43
+ return null;
44
+ return `${y}${String(mo).padStart(2, "0")}${String(d).padStart(2, "0")}`;
45
+ }
46
+ function fmtYmd(ymd) {
47
+ if (!/^\d{8}$/.test(ymd))
48
+ return ymd;
49
+ return `${ymd.slice(0, 4)}.${parseInt(ymd.slice(4, 6), 10)}.${parseInt(ymd.slice(6, 8), 10)}.`;
50
+ }
51
+ /** eflaw JSON에서 조문 본문 추출 (항·호 평탄화) */
52
+ function extractJoText(jsonText) {
53
+ try {
54
+ const json = JSON.parse(jsonText);
55
+ const units = toArray(json?.법령?.조문?.조문단위);
56
+ const found = units.find((u) => u?.조문여부 === "조문");
57
+ if (!found)
58
+ return "";
59
+ const parts = [];
60
+ parts.push(cleanHtml(String(found.조문내용 || "")));
61
+ for (const h of toArray(found.항)) {
62
+ if (h?.항내용)
63
+ parts.push(cleanHtml(String(h.항내용)));
64
+ for (const ho of toArray(h?.호)) {
65
+ if (ho?.호내용)
66
+ parts.push(cleanHtml(String(ho.호내용)));
67
+ }
68
+ }
69
+ return parts.join("\n").trim();
70
+ }
71
+ catch {
72
+ return "";
73
+ }
74
+ }
75
+ /** 부칙내용(중첩 배열/문자열)을 라인 배열로 평탄화 */
76
+ function flattenAddendum(content) {
77
+ if (content === null || content === undefined)
78
+ return [];
79
+ if (typeof content === "string") {
80
+ return content.split(/\n/).map(s => cleanHtml(s).trim()).filter(Boolean);
81
+ }
82
+ if (Array.isArray(content))
83
+ return content.flatMap(flattenAddendum);
84
+ return [cleanHtml(String(content)).trim()].filter(Boolean);
85
+ }
86
+ /** 경과규정·적용례 신호 패턴 */
87
+ const TRANSITION_RE = /적용례|경과조치|종전의\s*규정|시행\s*전에?\s*|행위에\s*대하여|예에\s*따른다|불구하고.*적용/;
88
+ function extractTransitionExcerpts(units, relevantAncNos, joDisplay, maxAddenda = 6, maxLinesPer = 3) {
89
+ const out = [];
90
+ // 최신 부칙부터 (공포일자 내림차순)
91
+ const sorted = [...units].sort((a, b) => String(b?.부칙공포일자 || "").localeCompare(String(a?.부칙공포일자 || "")));
92
+ for (const unit of sorted) {
93
+ if (out.length >= maxAddenda)
94
+ break;
95
+ const ancNo = String(parseInt(String(unit?.부칙공포번호 || "0"), 10));
96
+ if (relevantAncNos.size > 0 && !relevantAncNos.has(ancNo))
97
+ continue;
98
+ const lines = flattenAddendum(unit?.부칙내용);
99
+ if (lines.length === 0)
100
+ continue;
101
+ // 원문 첫 줄이 "부칙 <제N호,...>" 형식이면 사용, "부칙"만 있으면 공포번호·일자로 구성
102
+ const header = lines[0].startsWith("부칙") && /제\s*\d+\s*호/.test(lines[0]) ? lines[0]
103
+ : `부칙 <제${unit?.부칙공포번호}호, ${fmtYmd(String(unit?.부칙공포일자 || ""))}>`;
104
+ // 조문 지정 시: 해당 조문 언급 라인 최우선 → 경과규정 신호 라인
105
+ const joHits = joDisplay ? lines.filter(l => l.includes(joDisplay)) : [];
106
+ const transitionHits = lines.filter(l => TRANSITION_RE.test(l) && !joHits.includes(l));
107
+ const picked = [...joHits, ...transitionHits].slice(0, maxLinesPer);
108
+ if (picked.length === 0)
109
+ continue;
110
+ out.push({ header, lines: picked.map(l => l.length > 250 ? l.slice(0, 250) + "…" : l) });
111
+ }
112
+ return out;
113
+ }
114
+ export async function applicableLaw(apiClient, input) {
115
+ try {
116
+ const date = normalizeDate(input.date);
117
+ if (!date) {
118
+ return notFoundResponse(`기준일 '${input.date}'을(를) 해석하지 못했습니다.`, [
119
+ "지원 형식: 2023-05-10 / 2023.5.10 / 20230510 / 2023년 5월 10일",
120
+ ]);
121
+ }
122
+ // 1. 법령 식별
123
+ const laws = await findLaws(apiClient, input.lawName, input.apiKey, 1);
124
+ if (laws.length === 0) {
125
+ return notFoundResponse(`'${input.lawName}' 법령을 찾을 수 없습니다.`, [
126
+ "search_law로 정확한 법령명을 먼저 확인하세요.",
127
+ ]);
128
+ }
129
+ const law = laws[0];
130
+ // 2. 연혁 → 기준일 시행 버전 특정 (versions는 시행일 내림차순)
131
+ const { versions } = await fetchHistoricalVersionsFull(apiClient, law.lawName, input.apiKey);
132
+ if (versions.length === 0) {
133
+ return notFoundResponse(`'${law.lawName}' 연혁을 조회하지 못했습니다.`, [
134
+ "get_law_history 또는 search_historical_law로 직접 확인하세요.",
135
+ ]);
136
+ }
137
+ const applicable = versions.find(v => v.efYd && v.efYd <= date);
138
+ const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
139
+ const current = versions.find(v => v.efYd && v.efYd <= today);
140
+ const laterVersions = versions.filter(v => v.efYd && v.efYd > date && v.efYd <= today);
141
+ const lines = [];
142
+ lines.push(`═══ 행위시법 판단: ${law.lawName} @ ${fmtYmd(date)} ═══`);
143
+ lines.push("");
144
+ if (!applicable) {
145
+ const earliest = versions[versions.length - 1];
146
+ lines.push(`✗ 기준일 ${fmtYmd(date)} 당시 이 법령은 시행 전입니다.`);
147
+ lines.push(` 최초 시행일: ${fmtYmd(earliest?.efYd || "")} (${earliest?.rrCls || "제정"})`);
148
+ lines.push("");
149
+ lines.push("⚠️ 기준일에 적용할 이 법령의 버전이 없습니다. 당시 규율하던 구법(폐지 법령)이 있는지 search_historical_law로 확인하세요.");
150
+ return { content: [{ type: "text", text: truncateResponse(lines.join("\n")) }] };
151
+ }
152
+ // 적용 버전 표시
153
+ lines.push(`▶ 기준일에 시행 중이던 버전`);
154
+ const promulgation = [`제${applicable.ancNo}호`, applicable.ancYd ? fmtYmd(applicable.ancYd) : "", applicable.rrCls]
155
+ .filter(Boolean).join(", ");
156
+ lines.push(` ${law.lawName} [시행 ${fmtYmd(applicable.efYd)}] [${promulgation}] (MST ${applicable.mst})`);
157
+ if (laterVersions.length > 0) {
158
+ lines.push(` ↳ 기준일 이후 현재까지 ${laterVersions.length}차례 개정·시행됨 (현행: 시행 ${fmtYmd(current?.efYd || "")})`);
159
+ }
160
+ else {
161
+ lines.push(` ↳ 이 버전이 현행입니다 (기준일 이후 개정 없음)`);
162
+ }
163
+ // 3. 조문 비교 (jo 지정 시)
164
+ const joDisplay = input.jo ? (input.jo.startsWith("제") ? input.jo : `제${input.jo}`) : undefined;
165
+ if (joDisplay) {
166
+ const joCode = buildJO(joDisplay);
167
+ // eflaw는 MST 단독 조회 불가 — 해당 버전의 efYd 동반 필수 (없으면 "일치하는 법령이 없습니다")
168
+ const [thenJson, nowJson] = await Promise.all([
169
+ apiClient.getLawText({ mst: applicable.mst, jo: joCode, efYd: applicable.efYd, apiKey: input.apiKey }).catch(() => ""),
170
+ current && current.mst !== applicable.mst
171
+ ? apiClient.getLawText({ mst: current.mst, jo: joCode, efYd: current.efYd, apiKey: input.apiKey }).catch(() => "")
172
+ : Promise.resolve(""),
173
+ ]);
174
+ const thenText = thenJson ? extractJoText(thenJson) : "";
175
+ const nowText = nowJson ? extractJoText(nowJson) : "";
176
+ lines.push("");
177
+ lines.push(`▶ 기준일 시점 조문: ${joDisplay}`);
178
+ if (thenText) {
179
+ lines.push(thenText.length > 2000 ? thenText.slice(0, 2000) + "\n…(생략)" : thenText);
180
+ }
181
+ else {
182
+ lines.push(` [NOT_FOUND] 해당 버전에서 ${joDisplay}를 찾지 못했습니다 (당시 미신설이거나 조회 실패). LLM은 본문을 추측하지 마세요.`);
183
+ }
184
+ if (current && current.mst !== applicable.mst) {
185
+ lines.push("");
186
+ const norm = (s) => s.replace(/\s+/g, "");
187
+ if (thenText && nowText) {
188
+ if (norm(thenText) === norm(nowText)) {
189
+ lines.push(`▶ 현행과 비교: ✅ 동일 (기준일 이후 이 조문은 개정되지 않음)`);
190
+ }
191
+ else {
192
+ lines.push(`▶ 현행과 비교: △ 변경됨 — 현행 본문과 다릅니다. 인용 시 반드시 기준일 버전을 사용하세요.`);
193
+ lines.push(` 상세 diff: chain_amendment_track(query="${law.lawName}", scenario="time_travel", fromDate="${date}", toDate="${today}")`);
194
+ }
195
+ }
196
+ else {
197
+ lines.push(`▶ 현행과 비교: 비교 불가 (한쪽 본문 조회 실패)`);
198
+ }
199
+ }
200
+ }
201
+ // 4. 이후 개정 부칙의 적용례·경과조치 발췌
202
+ if (laterVersions.length > 0 && current) {
203
+ try {
204
+ const lawJson = await apiClient.fetchApi({
205
+ endpoint: "lawService.do",
206
+ target: "law",
207
+ type: "JSON",
208
+ extraParams: { MST: current.mst },
209
+ apiKey: input.apiKey,
210
+ });
211
+ const parsed = JSON.parse(lawJson);
212
+ const units = toArray(parsed?.법령?.부칙?.부칙단위);
213
+ // 기준일 이후 시행 개정들의 공포번호 + 적용 버전 자신의 부칙
214
+ const relevant = new Set([
215
+ ...laterVersions.map(v => String(parseInt(v.ancNo || "0", 10))),
216
+ String(parseInt(applicable.ancNo || "0", 10)),
217
+ ]);
218
+ const excerpts = extractTransitionExcerpts(units, relevant, joDisplay);
219
+ lines.push("");
220
+ if (excerpts.length > 0) {
221
+ lines.push(`▶ 적용례·경과조치 발췌 (기준일 사건에 영향 가능 — 반드시 확인)`);
222
+ for (const ex of excerpts) {
223
+ lines.push(` ◆ ${ex.header}`);
224
+ ex.lines.forEach(l => lines.push(` ${l}`));
225
+ }
226
+ }
227
+ else {
228
+ lines.push(`▶ 적용례·경과조치: 이후 개정 부칙에서 경과규정 신호 미발견 (부칙 원문 확인: get_law_text)`);
229
+ }
230
+ }
231
+ catch {
232
+ lines.push("");
233
+ lines.push(`▶ 적용례·경과조치: [FAILED] 부칙 조회 실패 — get_law_text(mst="${current.mst}")로 부칙을 직접 확인하세요.`);
234
+ }
235
+ }
236
+ // 5. 법리 주의 문구
237
+ lines.push("");
238
+ lines.push("⚖️ 적용 법령 판단 시 주의");
239
+ lines.push(" - 형사처벌: 행위시법 원칙 (형법 제1조제1항). 단, 재판 시 법이 더 가벼우면 신법 적용 (같은 조 제2항)");
240
+ lines.push(" - 제재처분(과징금·영업정지 등): 위반행위 시 법령 적용 (행정기본법 제14조제3항 본문). 단, 제재 기준이 가벼워졌으면 변경된 법령 (같은 항 단서)");
241
+ lines.push(" - 인허가 등 일반 처분: 원칙적으로 처분 시 법령 (행정기본법 제14조제2항)");
242
+ lines.push(" - 위 원칙은 부칙 경과규정이 우선합니다 — 위 발췌를 반드시 확인하세요. 이 도구는 발췌만 제공하며 해석하지 않습니다.");
243
+ return { content: [{ type: "text", text: truncateResponse(lines.join("\n")) }] };
244
+ }
245
+ catch (error) {
246
+ return formatToolError(error, "applicable_law");
247
+ }
248
+ }
@@ -6,6 +6,7 @@ import { truncateResponse } from "../lib/schemas.js";
6
6
  import { buildJO } from "../lib/law-parser.js";
7
7
  import { cleanHtml } from "../lib/article-parser.js";
8
8
  import { formatToolError } from "../lib/errors.js";
9
+ import { toArray } from "../lib/xml-parser.js";
9
10
  export const GetArticleDetailSchema = z.object({
10
11
  mst: z.string().optional().describe("법령일련번호 (search_law에서 획득)"),
11
12
  lawId: z.string().optional().describe("법령ID (search_law에서 획득)"),
@@ -67,7 +68,7 @@ export async function getArticleDetail(apiClient, input) {
67
68
  resultText += `조회 위치: ${locationLabel}\n\n`;
68
69
  // 조문 추출
69
70
  const rawUnits = lawData.조문?.조문단위;
70
- const articleUnits = Array.isArray(rawUnits) ? rawUnits : rawUnits ? [rawUnits] : [];
71
+ const articleUnits = toArray(rawUnits);
71
72
  if (articleUnits.length === 0) {
72
73
  return {
73
74
  content: [{ type: "text", text: resultText + "[NOT_FOUND] 해당 조문을 찾을 수 없습니다.\n⚠️ LLM은 조문 내용을 추측/생성하지 마세요." }],
@@ -156,9 +156,6 @@ function noResult(query) {
156
156
  isError: true,
157
157
  };
158
158
  }
159
- function isSearchFailure(result) {
160
- return result.isError || /\[NOT_FOUND\]|\[FAILED\]|검색 결과가 없습니다|조회 실패/.test(result.text);
161
- }
162
159
  function filterReliableLawResults(laws, query) {
163
160
  const queryWords = query.replace(NON_LAW_NAME_RE, " ")
164
161
  .trim()
@@ -0,0 +1,28 @@
1
+ /**
2
+ * cite_check — 판례 생사 확인 / 인용 추적 (v4.3 killer feature, 한국형 Shepard's Citator)
3
+ *
4
+ * 문제: 전원합의체로 변경·폐기된 판례를 살아있는 것처럼 인용하는 것이
5
+ * 판례 인용에서 가장 위험한 실수. LLM도 사람도 자주 범한다.
6
+ *
7
+ * 입력: 사건번호 (예: '2013다61381')
8
+ * 처리:
9
+ * 1. nb= 정확 검색으로 대상 판례 특정
10
+ * 2. 본문검색(search=2)으로 그 사건번호를 인용한 후속 판례 역추적
11
+ * 3. 후속 판례 중 전원합의체 우선 본문 정밀 스캔 → 변경·폐기 문구 감지
12
+ * 4. 판정: 계속 인용 / 전합 후속 존재 / 변경 신호 감지 + 인용 타임라인
13
+ *
14
+ * 차별점: impact_map은 조문→판례 방향만 다룸. 판례→판례 인용 관계는 이 도구가 유일.
15
+ * 한계: 법제처 수록 판례(대법원 중심) 범위 내 — 출력에 명시하여 과신 방지.
16
+ */
17
+ import { z } from "zod";
18
+ import type { LawApiClient } from "../lib/api-client.js";
19
+ import type { ToolResponse } from "../lib/types.js";
20
+ export declare const CiteCheckSchema: z.ZodObject<{
21
+ caseNumber: z.ZodString;
22
+ display: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
23
+ deepScan: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
24
+ apiKey: z.ZodOptional<z.ZodString>;
25
+ }, z.core.$strip>;
26
+ export type CiteCheckInput = z.infer<typeof CiteCheckSchema>;
27
+ export declare function extractCaseNumbers(text: string): string[];
28
+ export declare function citeCheck(apiClient: LawApiClient, input: CiteCheckInput): Promise<ToolResponse>;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * cite_check — 판례 생사 확인 / 인용 추적 (v4.3 killer feature, 한국형 Shepard's Citator)
3
+ *
4
+ * 문제: 전원합의체로 변경·폐기된 판례를 살아있는 것처럼 인용하는 것이
5
+ * 판례 인용에서 가장 위험한 실수. LLM도 사람도 자주 범한다.
6
+ *
7
+ * 입력: 사건번호 (예: '2013다61381')
8
+ * 처리:
9
+ * 1. nb= 정확 검색으로 대상 판례 특정
10
+ * 2. 본문검색(search=2)으로 그 사건번호를 인용한 후속 판례 역추적
11
+ * 3. 후속 판례 중 전원합의체 우선 본문 정밀 스캔 → 변경·폐기 문구 감지
12
+ * 4. 판정: 계속 인용 / 전합 후속 존재 / 변경 신호 감지 + 인용 타임라인
13
+ *
14
+ * 차별점: impact_map은 조문→판례 방향만 다룸. 판례→판례 인용 관계는 이 도구가 유일.
15
+ * 한계: 법제처 수록 판례(대법원 중심) 범위 내 — 출력에 명시하여 과신 방지.
16
+ */
17
+ import { z } from "zod";
18
+ import { truncateResponse } from "../lib/schemas.js";
19
+ import { formatToolError, notFoundResponse } from "../lib/errors.js";
20
+ import { parsePrecedentXML } from "../lib/xml-parser.js";
21
+ import { cleanHtml } from "../lib/article-parser.js";
22
+ export const CiteCheckSchema = z.object({
23
+ caseNumber: z.string().describe("사건번호 (예: '2013다61381', '대법원 2018.10.30. 선고 2013다61381'처럼 문장 포함 가능)"),
24
+ display: z.number().optional().default(20).describe("후속 인용 판례 최대 표시 수 (기본 20)"),
25
+ deepScan: z.boolean().optional().default(true).describe("후속 인용 상위 판례 본문 정밀 스캔 (변경·폐기 문구 감지, 기본 true)"),
26
+ apiKey: z.string().optional().describe("법제처 Open API 인증키(OC). 사용자가 제공한 경우 전달"),
27
+ });
28
+ /** 텍스트에서 사건번호 추출 (예: 2013다61381, 96누4671, 2010두28604) */
29
+ const CASE_NO_RE = /(\d{2,4})\s*([가-힣]{1,5})\s*(\d{1,7})/g;
30
+ export function extractCaseNumbers(text) {
31
+ const out = [];
32
+ const seen = new Set();
33
+ let m;
34
+ CASE_NO_RE.lastIndex = 0;
35
+ while ((m = CASE_NO_RE.exec(text)) !== null) {
36
+ const cn = `${m[1]}${m[2]}${m[3]}`;
37
+ if (!seen.has(cn)) {
38
+ seen.add(cn);
39
+ out.push(cn);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /** 변경·폐기 treatment 신호 패턴 (대법원 전원합의체 판례변경 상투 문구) */
45
+ const CHANGE_PATTERNS = [
46
+ { re: /변경하기로\s*(?:한다|하면서|함|하였)/, label: "판례 변경 선언" },
47
+ { re: /폐기하기로|폐기한다|폐기되었/, label: "판례 폐기 선언" },
48
+ { re: /더\s*이상\s*유지(?:될\s*수\s*없|하기\s*어렵)/, label: "선례 유지 불가 판시" },
49
+ { re: /배치되는\s*범위\s*에?서?\s*(?:이를\s*)?(?:모두\s*)?변경/, label: "저촉 범위 변경" },
50
+ ];
51
+ function toYmd(date) {
52
+ return (date || "").replace(/[^\d]/g, "");
53
+ }
54
+ function isEnBancItem(item) {
55
+ return /전원합의체/.test(`${item.판례명 || ""} ${item.판결유형 || ""}`);
56
+ }
57
+ async function fetchPrecedentDetail(apiClient, id, apiKey) {
58
+ try {
59
+ const text = await apiClient.fetchApi({
60
+ endpoint: "lawService.do",
61
+ target: "prec",
62
+ type: "JSON",
63
+ extraParams: { ID: id },
64
+ apiKey,
65
+ });
66
+ const json = JSON.parse(text);
67
+ return json?.PrecService || json || null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function escapeRe(s) {
74
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75
+ }
76
+ /**
77
+ * 본문에서 대상 판례 언급 주변 ±window 자 내 변경 문구 감지 + 인용 맥락 추출.
78
+ *
79
+ * 판결문 관행 주의: 사건번호를 한 번 쓰고 "(이하 '2008년 전원합의체 판결'이라 한다)"로
80
+ * 별칭을 정의한 뒤, 정작 변경 선언은 별칭으로 한다 (예: 2018다248626 → 2007다27670 변경).
81
+ * 사건번호만 추적하면 false negative — 별칭 정의를 감지해 별칭 출현 지점도 함께 스캔한다.
82
+ */
83
+ function scanTreatment(body, targetCaseNo, window = 250) {
84
+ const clean = cleanHtml(body).replace(/\s+/g, " ");
85
+ // 사건번호는 본문에서 "2013다61381" 또는 "2013 다 61381" 형태
86
+ const targetSrc = targetCaseNo.replace(/(\d)([가-힣]+)(\d)/, "$1\\s*$2\\s*$3");
87
+ const refIndices = [];
88
+ let m;
89
+ const targetRe = new RegExp(targetSrc, "g");
90
+ while ((m = targetRe.exec(clean)) !== null)
91
+ refIndices.push(m.index);
92
+ // 별칭 정의: "…2007다27670 전원합의체 판결(이하 '2008년 전원합의체 판결'이라 한다)"
93
+ const aliasDefRe = new RegExp(targetSrc + "[^(]{0,40}\\(\\s*이하\\s*[‘'\"“]?([^’'\"”)]{2,40}?)[’'\"”]?\\s*(?:이?라\\s*고?\\s*)?한다", "g");
94
+ const aliases = new Set();
95
+ while ((m = aliasDefRe.exec(clean)) !== null)
96
+ aliases.add(m[1].trim());
97
+ for (const alias of aliases) {
98
+ const aliasRe = new RegExp(escapeRe(alias), "g");
99
+ while ((m = aliasRe.exec(clean)) !== null)
100
+ refIndices.push(m.index);
101
+ }
102
+ const signals = new Set();
103
+ let context;
104
+ for (const idx of refIndices.sort((a, b) => a - b)) {
105
+ const win = clean.slice(Math.max(0, idx - window), Math.min(clean.length, idx + window));
106
+ if (!context)
107
+ context = win.slice(0, 300);
108
+ for (const { re, label } of CHANGE_PATTERNS) {
109
+ if (re.test(win)) {
110
+ signals.add(label);
111
+ context = win.slice(0, 300); // 신호 잡힌 맥락을 우선 노출
112
+ }
113
+ }
114
+ }
115
+ return { changeSignals: [...signals], context };
116
+ }
117
+ export async function citeCheck(apiClient, input) {
118
+ try {
119
+ const candidates = extractCaseNumbers(input.caseNumber);
120
+ if (candidates.length === 0) {
121
+ return notFoundResponse(`'${input.caseNumber}'에서 사건번호를 추출하지 못했습니다.`, [
122
+ "사건번호 형식 예: 2013다61381, 96누4671, 2018두42559",
123
+ ]);
124
+ }
125
+ const caseNo = candidates[0];
126
+ // 1단계: 대상 판례 특정 (nb= 정확 검색)
127
+ const targetXml = await apiClient.fetchApi({
128
+ endpoint: "lawSearch.do",
129
+ target: "prec",
130
+ extraParams: { nb: caseNo, display: "10" },
131
+ apiKey: input.apiKey,
132
+ });
133
+ const targetParsed = parsePrecedentXML(targetXml);
134
+ // 동일 사건번호 정확 매칭 우선, 대법원 우선
135
+ const exact = targetParsed.items.filter(i => (i.사건번호 || "").replace(/\s/g, "").includes(caseNo));
136
+ const pool = exact.length > 0 ? exact : targetParsed.items;
137
+ const target = pool.find(i => /대법원/.test(i.법원명 || "")) || pool[0];
138
+ if (!target) {
139
+ return notFoundResponse(`사건번호 '${caseNo}' 판례를 법제처 DB에서 찾을 수 없습니다.`, [
140
+ "법제처 수록 판례는 대법원 중심입니다. 하급심은 search_decisions(query=키워드)로 검색하세요.",
141
+ "사건번호 오탈자 확인 (예: 다/두/도/누 구분)",
142
+ ]);
143
+ }
144
+ // 2~3단계 병렬: 대상 상세(참조판례) + 후속 인용 역추적(본문검색)
145
+ const [targetDetail, citingXml] = await Promise.all([
146
+ fetchPrecedentDetail(apiClient, target.판례일련번호, input.apiKey),
147
+ apiClient.fetchApi({
148
+ endpoint: "lawSearch.do",
149
+ target: "prec",
150
+ extraParams: { search: "2", query: caseNo, display: "50" },
151
+ apiKey: input.apiKey,
152
+ }),
153
+ ]);
154
+ const citingParsed = parsePrecedentXML(citingXml);
155
+ const citing = citingParsed.items
156
+ .filter(i => i.판례일련번호 !== target.판례일련번호)
157
+ .filter(i => (i.사건번호 || "").replace(/\s/g, "") !== caseNo)
158
+ .map(i => ({ ...i, isEnBanc: isEnBancItem(i) }))
159
+ .sort((a, b) => toYmd(b.선고일자).localeCompare(toYmd(a.선고일자)));
160
+ // 4단계: 정밀 스캔 — 전원합의체 > 대법원 > 최신 순으로 최대 3건
161
+ const scanResults = [];
162
+ if (input.deepScan && citing.length > 0) {
163
+ const prioritized = [...citing].sort((a, b) => {
164
+ if (a.isEnBanc !== b.isEnBanc)
165
+ return a.isEnBanc ? -1 : 1;
166
+ const aSup = /대법원/.test(a.법원명 || "") ? 1 : 0;
167
+ const bSup = /대법원/.test(b.법원명 || "") ? 1 : 0;
168
+ if (aSup !== bSup)
169
+ return bSup - aSup;
170
+ return toYmd(b.선고일자).localeCompare(toYmd(a.선고일자));
171
+ }).slice(0, 3);
172
+ const details = await Promise.all(prioritized.map(i => fetchPrecedentDetail(apiClient, i.판례일련번호, input.apiKey)));
173
+ details.forEach((d, idx) => {
174
+ const body = String(d?.판례내용 || "");
175
+ if (!body)
176
+ return;
177
+ const { changeSignals, context } = scanTreatment(body, caseNo);
178
+ scanResults.push({ item: prioritized[idx], signals: changeSignals, context });
179
+ });
180
+ }
181
+ // 판정
182
+ const changed = scanResults.filter(r => r.signals.length > 0);
183
+ const enBancCiting = citing.filter(c => c.isEnBanc);
184
+ const scannedIds = new Set(scanResults.map(r => r.item.판례일련번호));
185
+ const enBancUnscanned = enBancCiting.filter(c => !scannedIds.has(c.판례일련번호));
186
+ let verdict;
187
+ if (changed.length > 0) {
188
+ verdict = `❌ 변경·폐기 신호 감지 — ${changed.map(r => `${r.item.사건번호}(${r.signals.join(", ")})`).join("; ")}\n ⚠️ 이 판례를 현재 법리로 인용하기 전에 반드시 해당 후속 판결 전문을 확인하세요.`;
189
+ }
190
+ else if (enBancUnscanned.length > 0) {
191
+ // 스캔 안 된 전합 후속이 남아있을 때만 경고 (판례 변경은 전원합의체에서만 가능, 법원조직법 제7조)
192
+ verdict = `⚠️ 미스캔 전원합의체 후속 판결 ${enBancUnscanned.length}건 존재 — 법리 변경 여부 본문 확인 권장 (${enBancUnscanned.slice(0, 3).map(c => c.사건번호).join(", ")})`;
193
+ }
194
+ else if (citing.length > 0) {
195
+ const enBancNote = enBancCiting.length > 0 ? ` (전원합의체 ${enBancCiting.length}건 포함 정밀 스캔 완료)` : "";
196
+ verdict = `✅ 후속 인용 ${citing.length}건, 변경·폐기 신호 미감지 — 계속 인용되는 것으로 추정${enBancNote}`;
197
+ }
198
+ else {
199
+ verdict = `ℹ️ 법제처 수록 범위 내 후속 인용 없음 — 미수록 판례의 인용 가능성은 배제 못 함`;
200
+ }
201
+ // 출력 조립
202
+ const refCases = extractCaseNumbers(String(targetDetail?.참조판례 || "")).filter(c => c !== caseNo);
203
+ const lines = [];
204
+ lines.push(`═══ 판례 인용 추적 (Citator): ${caseNo} ═══`);
205
+ lines.push(`대상: ${target.법원명 || ""} ${target.선고일자 || ""} 선고 ${target.사건번호 || caseNo} ${isEnBancItem(target) ? "전원합의체 " : ""}판결`);
206
+ if (target.판례명)
207
+ lines.push(`사건명: ${target.판례명}`);
208
+ lines.push("");
209
+ lines.push(`📊 판정: ${verdict}`);
210
+ if (citing.length > 0) {
211
+ lines.push("");
212
+ lines.push(`▶ 이 판례를 인용한 후속 판례 (${citing.length}건, 최신순)`);
213
+ citing.slice(0, input.display).forEach((c, i) => {
214
+ const enBanc = c.isEnBanc ? " ⚡전원합의체" : "";
215
+ lines.push(` ${i + 1}. ${c.법원명 || ""} ${c.선고일자 || ""} ${c.사건번호 || ""}${enBanc} — ${(c.판례명 || "").slice(0, 60)}`);
216
+ });
217
+ if (citing.length > input.display)
218
+ lines.push(` … 외 ${citing.length - input.display}건`);
219
+ }
220
+ if (scanResults.length > 0) {
221
+ lines.push("");
222
+ lines.push(`▶ 본문 정밀 스캔 (${scanResults.length}건)`);
223
+ for (const r of scanResults) {
224
+ const mark = r.signals.length > 0 ? `🚨 ${r.signals.join(", ")}` : "인용 확인 (변경 문구 없음)";
225
+ lines.push(` - ${r.item.사건번호}: ${mark}`);
226
+ if (r.context)
227
+ lines.push(` 맥락: "…${r.context}…"`);
228
+ }
229
+ }
230
+ if (refCases.length > 0) {
231
+ lines.push("");
232
+ lines.push(`▶ 이 판례가 인용한 판례 (참조판례 ${refCases.length}건)`);
233
+ lines.push(` ${refCases.join(", ")}`);
234
+ lines.push(` ↳ 각 판례의 생사 확인: cite_check(caseNumber="...")`);
235
+ }
236
+ lines.push("");
237
+ lines.push("⚠️ 한계: 법제처 수록 판례(대법원 중심) 범위 내 검색입니다. 하급심·미수록 판례의 인용은 포함되지 않으며,");
238
+ lines.push(" 변경 신호 감지는 휴리스틱입니다. 최종 확인은 후속 판결 전문 검토(get_decision_text) 및 종합법률정보 병행을 권장합니다.");
239
+ return { content: [{ type: "text", text: truncateResponse(lines.join("\n")) }] };
240
+ }
241
+ catch (error) {
242
+ return formatToolError(error, "cite_check");
243
+ }
244
+ }
@@ -191,11 +191,13 @@ export async function getLawText(apiClient, input) {
191
191
  tocText += `lawId="${input.lawId}", jo="제XX조")`;
192
192
  }
193
193
  tocText += `\n여러 조문 일괄 조회: get_batch_articles 도구 사용`;
194
- lawCache.set(cacheKey, tocText);
194
+ // 절단본을 캐시 — 캐시 히트 경로는 절단 없이 반환하므로 미절단 캐시 시 50KB 제한 우회됨
195
+ const truncatedToc = truncateResponse(tocText);
196
+ lawCache.set(cacheKey, truncatedToc);
195
197
  return {
196
198
  content: [{
197
199
  type: "text",
198
- text: truncateResponse(tocText)
200
+ text: truncatedToc
199
201
  }]
200
202
  };
201
203
  }
@@ -6,6 +6,7 @@ import { cleanHtml } from "../lib/article-parser.js";
6
6
  import { buildJO } from "../lib/law-parser.js";
7
7
  import { truncateResponse } from "../lib/schemas.js";
8
8
  import { formatToolError } from "../lib/errors.js";
9
+ import { toArray } from "../lib/xml-parser.js";
9
10
  export const GetOrdinanceSchema = z.object({
10
11
  ordinSeq: z.string().describe("자치법규 일련번호"),
11
12
  jo: z.string().optional().describe("조문 번호 (예: '제20조'). 지정 시 해당 조문 본문만 반환"),
@@ -46,7 +47,7 @@ export async function getOrdinance(apiClient, input) {
46
47
  resultText += `\n---\n\n`;
47
48
  // 조문 내용 (단일 객체 → 배열 정규화)
48
49
  const rawArticles = lawService.조문?.조;
49
- const articles = Array.isArray(rawArticles) ? rawArticles : rawArticles ? [rawArticles] : [];
50
+ const articles = toArray(rawArticles);
50
51
  if (articles.length > 0) {
51
52
  // jo 파라미터가 있으면 해당 조문만 필터링
52
53
  if (input.jo) {
@@ -28,9 +28,18 @@ export async function runScenario(type, ctx) {
28
28
  try {
29
29
  return await runner(ctx);
30
30
  }
31
- catch {
32
- // 시나리오 실패는 체인 전체를 중단시키지 않음
33
- return { sections: [], suggestedActions: [] };
31
+ catch (e) {
32
+ // 시나리오 실패는 체인 전체를 중단시키지 않되, 무음 증발 금지 —
33
+ // 실패 섹션을 반환해 LLM이 "결과 없음"과 "실행 실패"를 구분하게 한다 (secOrSkip과 동일 원칙)
34
+ const msg = e instanceof Error ? e.message : String(e);
35
+ return {
36
+ sections: [{
37
+ title: `시나리오(${type}) [FAILED]`,
38
+ content: `⚠️ 시나리오 실행 실패 — LLM은 이 섹션 내용을 추측/생성하지 마세요.\n사유: ${msg.slice(0, 200)}`,
39
+ isError: true,
40
+ }],
41
+ suggestedActions: [],
42
+ };
34
43
  }
35
44
  }
36
45
  /** query-router 자동감지용: 쿼리에서 시나리오 타입 추론 */
@@ -1,4 +1,5 @@
1
1
  import { fetchHistoricalVersionsFull } from "../../lib/historical-utils.js";
2
+ import { toArray } from "../../lib/xml-parser.js";
2
3
  function normalizeText(s) {
3
4
  return (s || "")
4
5
  .replace(/<[^>]+>/g, " ")
@@ -11,7 +12,7 @@ function normalizeText(s) {
11
12
  }
12
13
  function extractArticleSnapshots(lawJson) {
13
14
  const raw = lawJson?.법령?.조문?.조문단위;
14
- const units = Array.isArray(raw) ? raw : raw ? [raw] : [];
15
+ const units = toArray(raw);
15
16
  const snapshots = [];
16
17
  for (const u of units) {
17
18
  if (u?.조문여부 !== "조문")
@@ -21,10 +22,10 @@ function extractArticleSnapshots(lawJson) {
21
22
  const title = String(u.조문제목 || "");
22
23
  let body = normalizeText(String(u.조문내용 || ""));
23
24
  // 항/호/목 본문 합산 (정규화)
24
- const hangs = Array.isArray(u.항) ? u.항 : u.항 ? [u.항] : [];
25
+ const hangs = toArray(u.항);
25
26
  for (const h of hangs) {
26
27
  body += " " + normalizeText(String(h.항내용 || ""));
27
- const hos = Array.isArray(h.호) ? h.호 : h.호 ? [h.호] : [];
28
+ const hos = toArray(h.호);
28
29
  for (const ho of hos) {
29
30
  body += " " + normalizeText(String(ho.호내용 || ""));
30
31
  }
@@ -20,6 +20,7 @@ import { findLaws } from "../lib/law-search.js";
20
20
  import { parseHangNumber } from "../lib/article-parser.js";
21
21
  import { truncateResponse } from "../lib/schemas.js";
22
22
  import { formatToolError } from "../lib/errors.js";
23
+ import { toArray } from "../lib/xml-parser.js";
23
24
  export const VerifyCitationsSchema = z.object({
24
25
  text: z.string().min(1).describe("검증할 법률 텍스트 (LLM 답변/계약서/판결문 등). 조문 인용이 포함된 문자열"),
25
26
  maxCitations: z.number().min(1).max(30).optional().default(15).describe("검증할 최대 인용 개수 (기본 15, 많을수록 느림)"),
@@ -120,7 +121,7 @@ async function verifyOne(apiClient, cite, apiKey) {
120
121
  const jsonText = await apiClient.getLawText({ mst: chosen.mst, jo: cite.joCode, apiKey });
121
122
  const json = JSON.parse(jsonText);
122
123
  const rawUnits = json?.법령?.조문?.조문단위;
123
- const units = Array.isArray(rawUnits) ? rawUnits : rawUnits ? [rawUnits] : [];
124
+ const units = toArray(rawUnits);
124
125
  const found = units.find((u) => u.조문여부 === "조문");
125
126
  if (!found) {
126
127
  // 전체 조회로 범위 힌트
@@ -128,7 +129,7 @@ async function verifyOne(apiClient, cite, apiKey) {
128
129
  try {
129
130
  const fullJson = JSON.parse(await apiClient.getLawText({ mst: chosen.mst, apiKey }));
130
131
  const fullRaw = fullJson?.법령?.조문?.조문단위;
131
- const fullUnits = Array.isArray(fullRaw) ? fullRaw : fullRaw ? [fullRaw] : [];
132
+ const fullUnits = toArray(fullRaw);
132
133
  const nums = fullUnits
133
134
  .filter((u) => u.조문여부 === "조문" && u.조문번호)
134
135
  .map((u) => parseInt(u.조문번호, 10))
@@ -145,7 +146,7 @@ async function verifyOne(apiClient, cite, apiKey) {
145
146
  const officialLabel = `${chosen.lawName} ${cite.displayArticle}`;
146
147
  if (cite.hang) {
147
148
  const rawHang = found.항;
148
- const hangs = Array.isArray(rawHang) ? rawHang : rawHang ? [rawHang] : [];
149
+ const hangs = toArray(rawHang);
149
150
  const hangNumbers = hangs
150
151
  .map((h) => parseHangNumber(h.항번호))
151
152
  .filter((n) => !isNaN(n));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "korean-law-mcp",
3
- "version": "4.2.1",
4
- "description": "법제처 42개 API → 17개 MCP 도구. 법령·판례·조례·조약 + LLM 환각 방지 인용 검증 + 조문 영향 그래프(impact_map) + 시점 비교(time_travel) + 상황별 5단계 안내(action_plan) + 국세청 해석례(nts)",
3
+ "version": "4.3.0",
4
+ "description": "법제처 42개 API → 19개 MCP 도구. 법령·판례·조례·조약 + LLM 환각 방지 인용 검증 + 조문 영향 그래프(impact_map) + 시점 비교(time_travel) + 상황별 5단계 안내(action_plan) + 판례 생사 확인(cite_check, 한국형 Citator) + 행위시법 판단(applicable_law) + 국세청 해석례(nts)",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
7
7
  "types": "build/index.d.ts",