feat: 법령 검색에 결정례 4종 추가 + 자치법규·미국판례·판례상세 수정

키를 꽂고 나니 여러 문제가 한꺼번에 드러났다.

1) 자치법규가 늘 실패했다. target 코드가 `ordinance`가 아니라 `ordin`이다.
   틀린 코드를 보내면 에러가 아니라 **빈 응답(길이 0)** 이 와서 JSON 파싱
   단계에서 "Unexpected end of JSON input"으로 터진다 — 원인이 target이라는
   게 전혀 안 드러난다. 별칭 정규화를 넣고, 빈 본문은 명확한 메시지로 갈랐다.

2) 미국 판례(CourtListener)는 인증 헤더를 아예 안 보내고 있었다. 예전엔
   무인증으로 열렸지만 지금은 403이다. legal.courtlistener_api_key 지원을
   넣고, 키가 없으면 날것의 403 대신 발급 안내와 직접 검색 URL을 준다
   (korean_law_search의 키 미설정 경로와 같은 모양).

3) 판례 상세가 부실해 보였던 이유:
   - 값이 없는 항목도 "### 판시사항" 헤딩이 찍혀서 자료가 빈약해 보였다
   - 참조판례를 통째로 버리고 있었다
   - 전문이 4000자에서 조용히 잘렸다 → 8000자로 늘리고 잘릴 때 밝힌다
   - 선고일자가 `19860701` 원본 그대로였다 → 1986.07.01
   구조화 데이터도 같이 반환한다(예전엔 case_name 하나뿐이었다).

4) 성격이 다른 DB 4종을 추가했다: 헌재결정례(detc)·법령해석례(expc)·
   행정심판례(decc)·심판례(ppc). law.go.kr은 **대상마다 응답 구조가 전부
   다르다** — 래퍼 이름, 항목 배열 키, 필드명까지. 하나라도 어긋나면 에러가
   아니라 0건으로 조용히 나오므로, 실제 응답을 찍어 확인한 값을 TARGET_SPECS
   표 하나에 모았다. 다음 대상은 그 표만 채우면 된다.

SECRET_FIELD_MAP에 legal 키 둘을 추가해 평문이 들어와도 vault로 이관되게 했다.

검증: 판례 8,748 / 법령 13 / 자치법규 884(주차장) / 헌재 19 / 해석례 13 /
행정심판 13 / 심판례 6 — 전부 정상 응답.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-25 10:19:33 +09:00
co-authored by Claude Opus 5
parent 38695fb4fb
commit f79ba166e4
2 changed files with 152 additions and 29 deletions
+1
View File
@@ -332,6 +332,7 @@ const SECRET_FIELD_MAP: Array<[string[], string]> = [
[['news', 'newsdata_api_key'], 'news.newsdata_api_key'],
[['nvr', 'password'], 'nvr.password'],
[['legal', 'law_go_kr_api_key'], 'legal.law_go_kr_api_key'],
[['legal', 'courtlistener_api_key'], 'legal.courtlistener_api_key'],
[['writer', 'providers', 'google_books', 'apiKey'], 'writer.providers.google_books.apiKey'],
[['writer', 'providers', 'aladin', 'apiKey'], 'writer.providers.aladin.apiKey'],
];
+151 -29
View File
@@ -19,7 +19,73 @@ async function fetchJson(url: string, headers?: Record<string, string>): Promise
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
// law.go.kr은 target 코드가 틀리면 200에 **빈 본문**을 준다. res.json()에 그대로 넘기면
// "Unexpected end of JSON input"이 나서 원인이 안 보이므로 여기서 갈라준다.
const text = await res.text();
if (!text.trim()) throw new Error('빈 응답 (target 코드가 잘못됐을 수 있습니다)');
try { return JSON.parse(text); }
catch { throw new Error(`JSON 파싱 실패: ${text.slice(0, 120)}`); }
}
// law.go.kr의 실제 target 코드는 자치법규가 `ordin`이다(`ordinance`가 아니다).
// 틀린 코드를 보내면 에러가 아니라 **빈 응답(길이 0)** 이 와서 JSON 파싱 단계에서
// "Unexpected end of JSON input"으로 터진다 — 원인이 target이라는 게 안 드러난다.
// 사람이 쓰기 쉬운 이름을 받아 실제 코드로 바꿔준다(2026-08-21).
const TARGET_ALIAS: Record<string, string> = { ordinance: 'ordin', ordinances: 'ordin', precedent: 'prec' };
const normalizeTarget = (t?: string) => TARGET_ALIAS[String(t || 'law')] || String(t || 'law');
// law.go.kr은 대상마다 **응답 구조가 전부 다르다** — 래퍼 이름, 항목 배열 키, 항목 필드명까지.
// 하나라도 어긋나면 에러가 아니라 결과 0건으로 조용히 나와서 원인을 못 찾는다.
// 그래서 대상별 어댑터를 한 표에 모아둔다(2026-08-21, 실제 응답을 찍어보고 채운 값).
//
// 법원 판례(prec) 말고도 성격이 다른 DB가 넷 더 있다. 헌법 쟁점이면 헌재결정례가,
// 행정처분 다툼이면 행정심판례가 본체다.
interface TargetSpec {
label: string;
wrapper: string; // 최상위 래퍼 키
listKey: string; // 그 안의 항목 배열 키
map: (it: any) => { id: string; name: string; court?: string; date?: string; type?: string; case_no?: string };
}
const TARGET_SPECS: Record<string, TargetSpec> = {
detc: {
label: '헌재결정례', wrapper: 'DetcSearch', listKey: 'Detc',
map: (it) => ({ id: it.헌재결정례일련번호, name: it.사건명, court: '헌법재판소',
date: it.종국일자, case_no: it.사건번호 }),
},
expc: {
label: '법령해석례', wrapper: 'Expc', listKey: 'expc',
map: (it) => ({ id: it.법령해석례일련번호, name: it.안건명, court: it.회신기관명,
date: it.회신일자, case_no: it.안건번호, type: it.질의기관명 }),
},
decc: {
label: '행정심판례', wrapper: 'Decc', listKey: 'decc',
map: (it) => ({ id: it.행정심판재결례일련번호, name: it.사건명, court: it.재결청,
date: it.의결일자, case_no: it.사건번호, type: it.재결구분명 }),
},
ppc: {
label: '심판례', wrapper: 'Ppc', listKey: 'ppc',
map: (it) => ({ id: it.결정문일련번호, name: it.안건명, court: it.회의종류,
date: it.의결일, case_no: it.의안번호, type: it.결정구분 }),
},
};
// CourtListener(미국 판례)는 law.go.kr과 **별개 계정·별개 키**다. 예전엔 무인증으로도
// 열렸지만 지금은 토큰이 없으면 403이다(2026-08-21 확인). 무료 가입 후 발급받는다:
// https://www.courtlistener.com/help/api/rest/ → Authorization: Token <키>
function getCourtListenerApiKey(): string | undefined {
try {
const cm = getConfig();
return cm.resolveSecret((cm.getConfig() as any).legal?.courtlistener_api_key) || undefined;
} catch {}
return undefined;
}
// law.go.kr은 상세 조회에서 날짜를 `19860701`처럼 붙여서 준다(검색 목록은 `1986.07.01`).
// 그대로 찍으면 화면에 숫자 뭉치가 나온다.
function fmtLawDate(v: any): string {
const t = String(v ?? '').trim();
const m = t.match(/^(\d{4})[.\-/]?(\d{2})[.\-/]?(\d{2})$/);
return m ? `${m[1]}.${m[2]}.${m[3]}` : t;
}
function stripHtml(s: string): string {
@@ -40,7 +106,7 @@ export const koreanLawSearchTool = {
'법령명, 조문 키워드, 죄명 등으로 검색. API 키는 config.json { "legal": { "law_go_kr_api_key": "..." } }에 설정.',
schema: {
query: '검색어 (법령명, 조문 키워드, 판례 키워드 등)',
target: '검색 대상: "law" (법령, 기본), "prec" (판례/판결), "ordinance" (자치법규), "admrul" (행정규칙)',
target: '검색 대상: "law"(법령, 기본) | "prec"(법원 판례) | "ordinance"(자치법규) | "admrul"(행정규칙) | "detc"(헌재결정례) | "expc"(법령해석례) | "decc"(행정심판례) | "ppc"(심판례)',
max_results: '최대 결과 수 (기본: 10, 최대: 20)',
sort: '정렬: "rel" (관련도순, 기본), "date" (최신순)',
},
@@ -49,7 +115,7 @@ export const koreanLawSearchTool = {
required: ['query'],
properties: {
query: { type: 'string', description: '검색어' },
target: { type: 'string', description: '"law" | "prec" | "ordinance" | "admrul"' },
target: { type: 'string', description: '"law" | "prec" | "ordinance" | "admrul" | "detc" | "expc" | "decc" | "ppc"' },
max_results: { type: 'number', description: '최대 결과 수' },
sort: { type: 'string', description: '"rel" | "date"' },
},
@@ -79,7 +145,7 @@ export const koreanLawSearchTool = {
};
}
const target = args.target || 'law';
const target = normalizeTarget(args.target);
const count = Math.min(args.max_results || 10, 20);
const sort = args.sort === 'date' ? 'efDt' : 'score';
@@ -92,9 +158,15 @@ export const koreanLawSearchTool = {
const data = await fetchJson(`${LAW_GO_KR_BASE}/lawSearch.do?${params}`);
// law.go.kr wraps results differently depending on target
const wrapper = data?.LawSearch || data?.PrecSearch || {};
// 헌재결정례·법령해석례 등은 구조가 아예 달라서 TARGET_SPECS 표를 탄다.
const spec = TARGET_SPECS[target];
const wrapper = spec
? (data?.[spec.wrapper] || {})
: (data?.LawSearch || data?.PrecSearch || data?.OrdinSearch || data?.AdmRulSearch || {});
const total = Number(wrapper.totalCnt || 0);
const items: any[] = toArray(wrapper.law || wrapper.prec);
const items: any[] = spec
? toArray(wrapper[spec.listKey])
: toArray(wrapper.law || wrapper.prec || wrapper.admrul);
if (!items.length) {
return {
@@ -119,6 +191,7 @@ export const koreanLawSearchTool = {
type: item.사건종류명,
};
}
if (spec) return spec.map(item);
return {
id: item.법령ID,
name: item.법령명한글,
@@ -129,17 +202,31 @@ export const koreanLawSearchTool = {
};
});
const lines: string[] = [`**${target === 'prec' ? '판례' : '법령'} 검색 결과** (총 ${total}건 중 ${results.length}건)\n`];
const targetLabel = spec ? spec.label : (target === 'prec' ? '판례' : target === 'ordin' ? '자치법규' : '법령');
const lines: string[] = [`**${targetLabel} 검색 결과** (총 ${total}건 중 ${results.length}건)\n`];
for (const r of results) {
if (spec) {
const rr = r as any;
lines.push(`### ${rr.name}`);
lines.push([rr.case_no && `사건/안건번호: ${rr.case_no}`, rr.court && `기관: ${rr.court}`,
rr.date && `일자: ${fmtLawDate(rr.date)}`, rr.type && `구분: ${rr.type}`]
.filter(Boolean).map(x => `- ${x}`).join('\n'));
lines.push(`- ID: ${rr.id}`);
lines.push('');
continue;
}
if (target === 'prec') {
lines.push(`### ${r.name}`);
lines.push(`- 사건번호: ${r.case_no} | 법원: ${r.court} | 선고일: ${r.date}`);
lines.push(`- 사건종류: ${r.type} | ID: ${r.id}`);
} else {
lines.push(`### ${r.name} (${r.type})`);
lines.push(`- 소관부처: ${r.ministry} | 시행일: ${r.effective_date}`);
lines.push(`- 법령 ID: ${r.id}`);
// spec 분기와 prec 분기를 지나왔으므로 여기는 법령/자치법규다. 위 분기들 때문에
// 유니온이 좁아져 ministry/effective_date가 안 보이므로 그 모양으로 좁혀준다.
const lr = r as { id: string; name: string; type?: string; ministry?: string; effective_date?: string };
lines.push(`### ${lr.name}${lr.type ? ` (${lr.type})` : ''}`);
lines.push(`- 소관부처: ${lr.ministry ?? '-'} | 시행일: ${fmtLawDate(lr.effective_date)}`);
lines.push(`- 법령 ID: ${lr.id}`);
}
lines.push('');
}
@@ -181,7 +268,7 @@ export const koreanLawFetchTool = {
return { success: false, error: 'law.go.kr API 키 미설정. config.json에 { "legal": { "law_go_kr_api_key": "..." } } 추가' };
}
const target = args.target || 'law';
const target = normalizeTarget(args.target);
try {
const params = new URLSearchParams({
@@ -193,25 +280,41 @@ export const koreanLawFetchTool = {
const prec = data?.PrecService;
if (!prec) return { success: false, error: '판례 데이터를 찾을 수 없습니다.' };
// 빈 항목은 제목만 남기지 않는다 — 예전엔 판시사항이 없는 판례도 "### 판시사항"
// 헤딩이 그대로 찍혀서, 내용이 비어 보이는 게 아니라 **자료가 부실해 보였다**.
const section = (title: string, body: string) => {
const t = stripHtml(body || '');
return t ? ['', `### ${title}`, t] : [];
};
const full = stripHtml(prec.판례내용 || '');
const FULL_CAP = 8000; // 4000자에서 늘림 — 대법원 판결은 그 안에서 잘리는 일이 잦다
const meta = [
prec.법원명, prec.선고 && prec.판결유형 ? `${prec.선고} ${prec.판결유형}` : (prec.판결유형 || prec.선고),
prec.사건종류명,
].filter(Boolean).join(' | ');
const lines = [
`## ${prec.사건명}`,
`- 사건번호: ${prec.사건번호}`,
`- 법원: ${prec.법원명} | 선고일: ${prec.선고일자}`,
`- 사건종류: ${prec.사건종류명}`,
'',
'### 판시사항',
stripHtml(prec.판시사항 || ''),
'',
'### 판결요지',
stripHtml(prec.판결요지 || ''),
'',
'### 참조조문',
stripHtml(prec.참조조문 || ''),
'',
'### 전문',
stripHtml(prec.판례내용 || '').slice(0, 4000),
];
return { success: true, stdout: lines.join('\n'), data: { case_name: prec.사건명 } };
`- 사건번호: ${prec.사건번호} | 선고일: ${fmtLawDate(prec.선고일자)}`,
meta ? `- ${meta}` : '',
...section('판시사항', prec.판시사항),
...section('판결요지', prec.판결요지),
...section('참조조문', prec.참조조문),
...section('참조판례', prec.참조판례),
...(full ? ['', '### 전문', full.slice(0, FULL_CAP)
+ (full.length > FULL_CAP ? `\n\n…(전문 ${full.length}자 중 ${FULL_CAP}자까지 표시)` : '')] : []),
].filter(l => l !== '');
return {
success: true,
stdout: lines.join('\n'),
// 화면·후속 도구가 쓸 수 있게 구조화해서 같이 넘긴다(예전엔 case_name 하나뿐이었다).
data: {
case_name: prec.사건명, case_no: prec.사건번호, court: prec.법원명,
date: fmtLawDate(prec.선고일자), case_type: prec.사건종류명,
has_full_text: !!full, full_text_len: full.length,
},
};
}
// statute
@@ -297,6 +400,24 @@ export const usCaseSearchTool = {
}): Promise<ToolResult> => {
if (!args?.query?.trim()) return { success: false, error: 'query is required' };
const clKey = getCourtListenerApiKey();
if (!clKey) {
// 키가 없으면 403이 난다. 날것의 403을 그대로 올리면 무엇이 문제인지 안 보이므로,
// 한국 법령 쪽(korean_law_search)과 같은 방식으로 안내와 직접 검색 URL을 준다.
const q = encodeURIComponent(args.query);
return {
success: true,
stdout: [
'⚠️ CourtListener(미국 판례) API 키 미설정 — 이 검색은 키가 있어야 합니다.',
'발급: https://www.courtlistener.com/help/api/rest/ (무료 가입 후 토큰 발급)',
'설정: config.json에 { "legal": { "courtlistener_api_key": "YOUR_TOKEN" } }',
'',
`직접 검색 URL: https://www.courtlistener.com/?q=${q}`,
].join('\n'),
data: { api_key_missing: true },
};
}
try {
const params = new URLSearchParams({
q: args.query,
@@ -307,7 +428,8 @@ export const usCaseSearchTool = {
if (args.court && args.court !== 'all') params.set('court', args.court);
if (args.date_after) params.set('filed_after', args.date_after);
const data = await fetchJson(`${COURT_LISTENER_BASE}/search/?${params}`);
const data = await fetchJson(`${COURT_LISTENER_BASE}/search/?${params}`,
{ Authorization: `Token ${clKey}` });
const results: any[] = data?.results || [];
const total: number = data?.count || 0;
const maxR = Math.min(args.max_results || 10, results.length);