Add legal research tools and lawyer/psychiatrist skills

- src/tools/legal.ts: korean_law_search (law.go.kr API, 법령+판례),
  korean_law_fetch (전문 조회), us_case_search (CourtListener)
- registry.ts: legal tools 등록, web profile에 포함
- config.json: law.go.kr API 키 추가 (legal.law_go_kr_api_key)
- skills_state.json: lawyer, psychiatrist 스킬 활성화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-19 11:49:33 +09:00
co-authored by Claude Sonnet 4.6
parent ac678db97b
commit d93cac545a
4 changed files with 355 additions and 2 deletions
+340
View File
@@ -0,0 +1,340 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { ToolResult } from '../types.js';
const LAW_GO_KR_BASE = 'https://www.law.go.kr/DRF';
const COURT_LISTENER_BASE = 'https://www.courtlistener.com/api/rest/v3';
function getLawGoKrApiKey(): string | undefined {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg)
? projectCfg
: path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
return data.legal?.law_go_kr_api_key;
}
} catch {}
return undefined;
}
async function fetchJson(url: string, headers?: Record<string, string>): Promise<any> {
const res = await fetch(url, {
headers: { 'User-Agent': 'SmallClaw/1.0 legal-research', ...headers },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
function stripHtml(s: string): string {
return String(s || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
}
function toArray<T>(v: T | T[] | undefined): T[] {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
// ── korean_law_search ─────────────────────────────────────────────────────────
export const koreanLawSearchTool = {
name: 'korean_law_search',
description:
'국가법령정보센터(law.go.kr) API로 한국 법령 또는 판례를 검색합니다. ' +
'법령명, 조문 키워드, 죄명 등으로 검색. API 키는 config.json { "legal": { "law_go_kr_api_key": "..." } }에 설정.',
schema: {
query: '검색어 (법령명, 조문 키워드, 판례 키워드 등)',
target: '검색 대상: "law" (법령, 기본), "prec" (판례/판결), "ordinance" (자치법규), "admrul" (행정규칙)',
max_results: '최대 결과 수 (기본: 10, 최대: 20)',
sort: '정렬: "rel" (관련도순, 기본), "date" (최신순)',
},
jsonSchema: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: '검색어' },
target: { type: 'string', description: '"law" | "prec" | "ordinance" | "admrul"' },
max_results: { type: 'number', description: '최대 결과 수' },
sort: { type: 'string', description: '"rel" | "date"' },
},
additionalProperties: false,
},
execute: async (args: {
query: string;
target?: string;
max_results?: number;
sort?: string;
}): Promise<ToolResult> => {
if (!args?.query?.trim()) return { success: false, error: 'query is required' };
const apiKey = getLawGoKrApiKey();
if (!apiKey) {
const q = encodeURIComponent(args.query);
return {
success: true,
stdout: [
'⚠️ law.go.kr API 키 미설정.',
'설정 방법: config.json에 { "legal": { "law_go_kr_api_key": "YOUR_KEY" } } 추가',
'신청: https://www.law.go.kr/LSO/api/apidescMain.do',
'',
`직접 검색 URL: https://www.law.go.kr/lsSc.do?query=${q}`,
].join('\n'),
data: { api_key_missing: true },
};
}
const target = args.target || 'law';
const count = Math.min(args.max_results || 10, 20);
const sort = args.sort === 'date' ? 'efDt' : 'score';
try {
const params = new URLSearchParams({
OC: apiKey, target, type: 'JSON',
query: args.query, display: String(count),
search: '1', sort,
});
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 || {};
const total = Number(wrapper.totalCnt || 0);
const items: any[] = toArray(wrapper.law || wrapper.prec);
if (!items.length) {
return {
success: true,
stdout: `"${args.query}" 검색 결과 없음.`,
data: { total: 0, results: [] },
};
}
const results = items.map((item: any) => {
if (target === 'prec') {
return {
id: item.판례정보일련번호,
name: item.사건명,
case_no: item.사건번호,
court: item.법원명,
date: item.선고일자,
type: item.사건종류명,
};
}
return {
id: item.법령ID,
name: item.법령명한글,
type: item.법령구분명,
ministry: item.소관부처명,
effective_date: item.시행일자,
promulgation_date: item.공포일자,
};
});
const lines: string[] = [`**${target === 'prec' ? '판례' : '법령'} 검색 결과** (총 ${total}건 중 ${results.length}건)\n`];
for (const r of results) {
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}`);
}
lines.push('');
}
return { success: true, stdout: lines.join('\n'), data: { total, target, results } };
} catch (err: any) {
return { success: false, error: `법령 검색 실패: ${err.message}` };
}
},
};
// ── korean_law_fetch ──────────────────────────────────────────────────────────
export const koreanLawFetchTool = {
name: 'korean_law_fetch',
description:
'법령 ID 또는 판례 ID로 한국 법령 전문(조문 전체) 또는 판례 전문을 가져옵니다. ' +
'korean_law_search로 ID를 먼저 조회하세요.',
schema: {
law_id: '법령 ID 또는 판례정보일련번호 (korean_law_search 결과의 id 필드)',
target: '"law" (법령, 기본) 또는 "prec" (판례)',
article: '특정 조문 번호 (법령만 해당, 선택, 예: "1" → 제1조만)',
},
jsonSchema: {
type: 'object',
required: ['law_id'],
properties: {
law_id: { type: 'string', description: '법령 ID 또는 판례 ID' },
target: { type: 'string', description: '"law" | "prec"' },
article: { type: 'string', description: '조문 번호 (법령 전용)' },
},
additionalProperties: false,
},
execute: async (args: { law_id: string; target?: string; article?: string }): Promise<ToolResult> => {
if (!args?.law_id?.trim()) return { success: false, error: 'law_id is required' };
const apiKey = getLawGoKrApiKey();
if (!apiKey) {
return { success: false, error: 'law.go.kr API 키 미설정. config.json에 { "legal": { "law_go_kr_api_key": "..." } } 추가' };
}
const target = args.target || 'law';
try {
const params = new URLSearchParams({
OC: apiKey, target, type: 'JSON', ID: args.law_id,
});
const data = await fetchJson(`${LAW_GO_KR_BASE}/lawService.do?${params}`);
if (target === 'prec') {
const prec = data?.PrecService;
if (!prec) return { success: false, error: '판례 데이터를 찾을 수 없습니다.' };
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.사건명 } };
}
// statute
const law = data?.법령;
if (!law) return { success: false, error: '법령 데이터를 찾을 수 없습니다.' };
const basic = law.기본정보 || {};
const articleUnits = toArray(law.조문?.조문단위);
let filtered = articleUnits;
if (args.article) {
const target_no = args.article.replace(/\D/g, '');
filtered = articleUnits.filter((a: any) =>
String(a.조문번호 || '').replace(/\D/g, '') === target_no
);
}
const lines = [
`## ${basic.법령명한글} (${basic.법령구분명})`,
`- 시행일: ${basic.시행일자} | 소관부처: ${basic.소관부처명}`,
'',
];
const capped = filtered.slice(0, 40);
for (const a of capped) {
const heading = `**제${a.조문번호}조${a.조문제목 ? ` (${a.조문제목})` : ''}**`;
lines.push(heading);
if (a.조문내용) lines.push(stripHtml(a.조문내용));
for (const para of toArray(a.항)) {
lines.push(` ${para.항번호 || ''} ${stripHtml(para.항내용 || '')}`);
for (const ho of toArray(para.호)) {
lines.push(` ${ho.호번호 || ''} ${stripHtml(ho.호내용 || '')}`);
}
}
lines.push('');
}
if (filtered.length > 40) {
lines.push(`... (${filtered.length - 40}개 조문 생략. 특정 조문 조회 시 article 파라미터를 사용하세요.)`);
}
return {
success: true,
stdout: lines.join('\n'),
data: { law_name: basic.법령명한글, article_count: articleUnits.length },
};
} catch (err: any) {
return { success: false, error: `법령 조회 실패: ${err.message}` };
}
},
};
// ── us_case_search ────────────────────────────────────────────────────────────
export const usCaseSearchTool = {
name: 'us_case_search',
description:
'CourtListener API(courtlistener.com)로 미국 연방/주 판례를 검색합니다. ' +
'무료, 별도 인증 불필요. 영미법 계약·판례 조사에 활용.',
schema: {
query: '검색어 (영문, 사건명·법조항·키워드 등)',
court: '법원 코드: "scotus" (연방대법원), "ca1"~"ca11" (순회법원), "all" (전체, 기본)',
date_after: '이후 날짜 필터 (YYYY-MM-DD)',
max_results: '최대 결과 수 (기본: 10)',
},
jsonSchema: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: 'Search query (English)' },
court: { type: 'string', description: 'Court code or "all"' },
date_after: { type: 'string', description: 'Filed after (YYYY-MM-DD)' },
max_results: { type: 'number', description: 'Max results (default 10)' },
},
additionalProperties: false,
},
execute: async (args: {
query: string;
court?: string;
date_after?: string;
max_results?: number;
}): Promise<ToolResult> => {
if (!args?.query?.trim()) return { success: false, error: 'query is required' };
try {
const params = new URLSearchParams({
q: args.query,
type: 'o',
order_by: 'score desc',
stat_Precedential: 'on',
});
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 results: any[] = data?.results || [];
const total: number = data?.count || 0;
const maxR = Math.min(args.max_results || 10, results.length);
if (!results.length) {
return { success: true, stdout: `No US cases found for: "${args.query}"`, data: { total: 0 } };
}
const lines = [`**US Case Search** (${total} total, showing ${maxR})\nQuery: "${args.query}"\n`];
for (const r of results.slice(0, maxR)) {
lines.push(`### ${r.caseName}`);
lines.push(`- Court: ${r.court} | Date: ${r.dateFiled}`);
lines.push(`- Citations: ${(r.citation || []).join(', ') || 'N/A'}`);
if (r.snippet) {
lines.push(`- Excerpt: ${stripHtml(r.snippet).slice(0, 300)}...`);
}
lines.push(`- Link: https://www.courtlistener.com${r.absolute_url}`);
lines.push('');
}
return { success: true, stdout: lines.join('\n'), data: { total, results: results.slice(0, maxR) } };
} catch (err: any) {
return { success: false, error: `US case search failed: ${err.message}` };
}
},
};
+8
View File
@@ -22,6 +22,7 @@ import { pythonEvalTool } from './python.js';
import { sqliteTool } from './sqlite.js';
import { weatherSearchTool } from './weather.js';
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
import { koreanLawSearchTool, koreanLawFetchTool, usCaseSearchTool } from './legal.js';
export interface Tool {
name: string;
@@ -78,6 +79,9 @@ const TOOL_PROFILE_TOOL_NAMES: Record<Exclude<ToolProfile, 'full'>, ReadonlySet<
'pdf_read',
'image_read',
'image_preview',
'korean_law_search',
'korean_law_fetch',
'us_case_search',
]),
};
@@ -207,6 +211,10 @@ class ToolRegistry {
this.registerSafe(emailSendTool);
this.registerSafe(emailSearchTool);
this.registerSafe(emailDeleteTool);
// Legal research tools
this.registerSafe(koreanLawSearchTool);
this.registerSafe(koreanLawFetchTool);
this.registerSafe(usCaseSearchTool);
// Multi-agent spawn tool
this.registerSafe(spawnAgentTool);
}