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 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, 짧은 법령명("상법" 등) 정확 매칭 찾으려면 큰 값 권장)
@@ -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
- return await response.text();
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
@@ -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 { searchPrecedents } from "./precedents.js";
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 searchPrecedents(apiClient, {
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 (!precedentResult.isError) {
49
- const precedentText = precedentResult.content[0].text;
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`;
@@ -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 { getPrecedentText, searchPrecedents } from "./precedents.js";
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, fetchCombinedSearchDetailChain } from "./search-detail-chain.js";
28
- import { buildCompactLegalQueries } from "./compact-query-planner.js";
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 PRECEDENT_RETRY_LIMIT = 5;
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 normalizeRelevanceText(text) {
146
- return text
147
- .toLowerCase()
148
- .replace(/\s+/g, "")
149
- .replace(/[^\p{L}\p{N}]+/gu, "");
150
- }
151
- function candidateRelevanceTerms(candidate) {
152
- return Array.from(new Set([candidate.query, candidate.semanticAnchor]
153
- .filter((term) => typeof term === "string" && normalizeRelevanceText(term).length >= 2)));
154
- }
155
- function textContainsCandidateTerm(text, candidate) {
156
- const haystack = normalizeRelevanceText(text);
157
- return candidateRelevanceTerms(candidate)
158
- .some(term => haystack.includes(normalizeRelevanceText(term)));
159
- }
160
- function extractPrecedentCaseLines(searchText) {
161
- return searchText
162
- .split("\n")
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 id = extractFirstPrecedentId(result.text);
182
- if (!id)
183
- return false;
184
- const detail = await callTool(getPrecedentText, apiClient, {
185
- id,
186
- full: candidate.search === 2,
187
- apiKey,
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
- if (isSearchFailure(detail) || !detail.text.trim())
190
- return false;
191
- return textContainsCandidateTerm(detail.text, candidate);
192
- }
193
- function formatRetryCandidate(candidate) {
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
- async function retryPrecedentsIfNeeded(apiClient, input, precedentResult, aiLawResult) {
197
- if (!isSearchFailure(precedentResult))
198
- return precedentResult;
199
- const route = routeQuery(input.query);
200
- const candidates = buildCompactLegalQueries({
201
- originalQuery: input.query,
202
- aiLawArticles: aiLawResult?.aiLawArticles,
203
- aiLawText: aiLawResult?.text,
204
- route,
205
- failedSearchText: precedentResult.text,
206
- max: PRECEDENT_RETRY_LIMIT,
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
- text: [
226
- precedentResult.text,
227
- "",
228
- `재검색 시도(최대 ${PRECEDENT_RETRY_LIMIT}회): ${candidates.map(formatRetryCandidate).join(", ")}`,
229
- "모든 재검색에서 판례 검색 결과가 없습니다.",
230
- ].join("\n"),
231
- isError: true,
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(parallel);
386
- parts.push(secOrSkip("대법원 판례", results[0]));
387
- parts.push(secOrSkip("행정심판례", results[1]));
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
- if (precDetail)
393
- parts.push(secOrSkip("대법원 판례 상세", precDetail));
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[2]) {
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[2]));
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[2], { apiKey: input.apiKey });
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, precResult, interpResult] = await Promise.all([
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 finalPrecResult = await retryPrecedentsIfNeeded(apiClient, input, precResult, aiResult);
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("관련 판례", finalPrecResult));
586
+ parts.push(secOrSkip("관련 판례", precedentBundle.searchResult));
584
587
  parts.push(secOrSkip("법령 해석례", interpResult));
585
- const [precDetail, interpDetail] = await Promise.all([
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 (precDetail)
590
- parts.push(secOrSkip("관련 판례 상세", precDetail));
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 searchPromises = [];
704
- for (const hint of uniqueHints) {
705
- searchPromises.push(callTool(searchPrecedents, apiClient, { query: hint, display: 3, apiKey: input.apiKey }));
706
- }
707
- // AI 법령 검색도 상위 3개 힌트로 병렬 실행
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
- for (const hint of lawHints) {
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 = searchResults[i];
717
- if (!r.isError && r.text.trim()) {
718
- precTexts.push(`[${uniqueHints[i]}]\n${r.text}`);
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 precedentDetail = await fetchCombinedSearchDetailChain(apiClient, "search_precedents", searchResults.slice(0, uniqueHints.length), { apiKey: input.apiKey });
725
- if (precedentDetail) {
726
- parts.push(secOrSkip("관련 판례 상세", precedentDetail));
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 = searchResults[uniqueHints.length + i];
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 = "ai_law_article_title" | "ai_law_law_article_title" | "search_retry_hint" | "router";
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 = "raw" | "terminal_function_word_removed" | "terminal_function_word_spaced" | "law_title" | "retry_hint" | "router";
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[];