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:
@@ -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(/ /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}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user