From d93cac545ac9a275ae2b8c191e21fc1fa08c6b28 Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 19 May 2026 11:49:33 +0900 Subject: [PATCH] Add legal research tools and lawyer/psychiatrist skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .smallclaw/config.json | 5 +- .smallclaw/skills_state.json | 4 +- src/tools/legal.ts | 340 +++++++++++++++++++++++++++++++++++ src/tools/registry.ts | 8 + 4 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 src/tools/legal.ts diff --git a/.smallclaw/config.json b/.smallclaw/config.json index c0aeae2..da1c16d 100644 --- a/.smallclaw/config.json +++ b/.smallclaw/config.json @@ -247,5 +247,8 @@ "auto_store_web_facts": true, "natural_language_tool_router": true, "retrieval_mode": "standard" + }, + "legal": { + "law_go_kr_api_key": "ailawyer" } -} \ No newline at end of file +} diff --git a/.smallclaw/skills_state.json b/.smallclaw/skills_state.json index ef0f9b9..4aefcf8 100644 --- a/.smallclaw/skills_state.json +++ b/.smallclaw/skills_state.json @@ -1,3 +1,5 @@ { - "multi-agent-orchestrator": true + "multi-agent-orchestrator": true, + "lawyer": true, + "psychiatrist": true } \ No newline at end of file diff --git a/src/tools/legal.ts b/src/tools/legal.ts new file mode 100644 index 0000000..457596d --- /dev/null +++ b/src/tools/legal.ts @@ -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): Promise { + 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(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 => { + 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 => { + 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 => { + 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}` }; + } + }, +}; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4c4934e..f85f216 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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, 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); }