Skip to content

Commit fe834b8

Browse files
chrisryugjclaude
andcommitted
feat: 판례 검색범위 search="both" 옵트인 (4.15.6)
판례명 검색이 1건만 맞아도 끝나 본문검색이 생략되던 recall 문제 (#167). 기본 동작은 그대로 두고 옵트인과 안내를 더한다. - search="both": 판례명·본문 검색을 함께 돌려 판례ID로 중복 제거, 건마다 적중 범위 표시 - 판례명 검색이 3건 이하로 끝나면 본문검색 재검색 안내 - search_decisions options 의 문자열 search:"2" 가 판례명 검색으로 새던 문제 수정 - search_decisions 도구·options 설명에 search 노출 Closes #167 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkCJPw7rwHR5d5mBUB5MRt
1 parent ff33a71 commit fe834b8

13 files changed

Lines changed: 170 additions & 15 deletions

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [4.15.6] - 2026-10-05
4+
5+
### Added
6+
7+
- 판례 검색범위 `search="both"`: 판례명 검색과 본문검색을 함께 돌려 판례ID로 중복을 빼고, 건마다 어느 검색에서 맞았는지 표시한다. `search_decisions(domain="precedent", options={search:"both"})`. 기본 동작(판례명 우선)과 사건번호 exact 조회는 그대로다 (#167)
8+
- 판례명 검색이 보정 없이 3건 이하로 끝나면 본문검색 재검색 안내를 붙인다. 실측: "학원강사 근로자" 판례명 1건 · 본문 70건, "녹음파일 사본" 판례명 1건 · 본문 82건
9+
10+
### Fixed
11+
12+
- `search_decisions` options 로 온 `search:"2"`(문자열)가 판례명 검색으로 처리되고 본문 폴백도 건너뛰던 문제
13+
- `search_decisions` 도구·options 설명에 판례 검색범위(`search`)를 노출한다
14+
315
## [4.15.5] - 2026-10-02
416

517
전체 기능을 새 API 표본과 검색·상세·분석 연결 경로로 다시 검수했다. 재현한 오류를 수정하며 기능·의존성은 추가하지 않는다.

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
> - `.github/workflows/publish.yml`(GitHub Release → OIDC trusted publishing + provenance)은 **npm 쪽 trusted publisher 등록이 아직 안 됐다**. 그래서 게시는 위 로컬 `npm publish` 가 정규 경로이고, Release 는 그 뒤에 만든다 — 워크플로는 같은 버전이 이미 레지스트리에 있으면 게시를 건너뛰고 검증(typecheck·test·build·verify:package·audit)만 릴리스 시점에 재확인한다.
1010
> - **🚫 이 레포에서 `fly deploy` 직접 실행 절대 금지** — 통합 이미지를 law 단독 이미지로 덮어써 stats·patent·archhub·school까지 전부 죽는다. 자세한 배경: [docs/FLY-COST.md](docs/FLY-COST.md)
1111
12-
Korean Law MCP Server v4.15.5 - 법제처 42개 API → 10개 통합 도구 (내부 99개) + 9개 시나리오 + 자연어 CLI + HTTP stateless + 판례 토큰 74% 감축 + **legal_research (체인 8종 통합, task 파라미터)** + **legal_analysis (인용검증·판례생사·행위시법·영향그래프 통합, mode 파라미터)** + **time_travel (시점 diff)** + **action_plan (이럴 땐 이렇게, 5단계 안내)** + **시행예정 감지 (search_law가 제명변경·미시행 개정 자동 병기)** + **ordinance_radar (조례 정비 레이더 — 근거 상위법 개정 자동 대조, v4.7.0)** + **인용 검증 표기 내성 (낫표·가운뎃점·`같은 법` 조응, v4.9.0)** + **폐지 감지 (검색 0건 시 폐지 법령·행정규칙 연혁 추적 — 폐지사유·후속 통합 규정 자동 안내, v4.10.0)** + **search_law_bulk (등록부 대량 조회 + MST diff 감시, v4.13.0)** + **행정규칙 부분 조회 (get_admin_rule 의 jo·chapter·keyword·page — 통짜 전문을 조문 단위로, v4.14.0)** + **법령ID 계보 연혁 (제명이 바뀌기 전 버전까지 — search_historical_law·applicable_law·time_travel, get_annexes date 시점 별표, get_law_text efYd 기준일 보정, 행정규칙 기준일 판단, v4.15.0)**
12+
Korean Law MCP Server v4.15.6 - 법제처 42개 API → 10개 통합 도구 (내부 99개) + 9개 시나리오 + 자연어 CLI + HTTP stateless + 판례 토큰 74% 감축 + **legal_research (체인 8종 통합, task 파라미터)** + **legal_analysis (인용검증·판례생사·행위시법·영향그래프 통합, mode 파라미터)** + **time_travel (시점 diff)** + **action_plan (이럴 땐 이렇게, 5단계 안내)** + **시행예정 감지 (search_law가 제명변경·미시행 개정 자동 병기)** + **ordinance_radar (조례 정비 레이더 — 근거 상위법 개정 자동 대조, v4.7.0)** + **인용 검증 표기 내성 (낫표·가운뎃점·`같은 법` 조응, v4.9.0)** + **폐지 감지 (검색 0건 시 폐지 법령·행정규칙 연혁 추적 — 폐지사유·후속 통합 규정 자동 안내, v4.10.0)** + **search_law_bulk (등록부 대량 조회 + MST diff 감시, v4.13.0)** + **행정규칙 부분 조회 (get_admin_rule 의 jo·chapter·keyword·page — 통짜 전문을 조문 단위로, v4.14.0)** + **법령ID 계보 연혁 (제명이 바뀌기 전 버전까지 — search_historical_law·applicable_law·time_travel, get_annexes date 시점 별표, get_law_text efYd 기준일 보정, 행정규칙 기준일 판단, v4.15.0)**
1313

1414
## Structure
1515

‎README-EN.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131

3232
---
3333

34+
## v4.15.6: Precedent body-search option
35+
36+
- Added `search="both"` for precedent search: runs title and full-text search together, dedupes by precedent ID, and labels which search matched each hit. `search_decisions(domain="precedent", options={search:"both"})`
37+
- When a title search ends with 3 or fewer hits, the output suggests a full-text re-search (measured: "학원강사 근로자" 1 title hit vs 70 full-text hits)
38+
- Fixed string `search:"2"` passed via options being treated as a title search. Default behavior unchanged. [Changelog](CHANGELOG.md), issue #167
39+
40+
---
41+
3442
## v4.15.5: Retrieval and verification boundaries
3543

3644
- Fixed missing precedent bodies, cancelled cache reads, and statute/branch boundaries in article ranges

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030

3131
---
3232

33+
## v4.15.6: 판례 본문검색 병행 옵션
34+
35+
- 판례 검색에 `search="both"` 추가 — 판례명 검색과 본문검색을 함께 돌려 판례ID로 중복을 빼고, 건마다 어느 검색에서 맞았는지 표시. `search_decisions(domain="precedent", options={search:"both"})`
36+
- 판례명 검색이 3건 이하로 끝나면 본문검색 재검색을 안내 (실측: "학원강사 근로자" 판례명 1건 · 본문 70건)
37+
- options 로 온 문자열 `search:"2"` 가 판례명 검색으로 처리되던 문제 수정. 기본 동작은 그대로. [변경 내역](CHANGELOG.md), 이슈 #167
38+
39+
---
40+
3341
## v4.15.5: 조회·검증 경계 수정
3442

3543
- 판례 본문 없는 응답·취소된 캐시 조회, 조문 범위의 법령·가지번호 판별 수정

‎docs/PRECEDENT-SEARCH-GUIDELINES.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@
110110
- `options.includeText`가 `true` 또는 `"true"`이면 `searchPrecedentDecisionsWithText()` 경로로 들어간다.
111111
- `includeText` 경로는 구조화 판례 검색 결과 뒤에 `fetchPrecedentEvidence()` 상세 증거를 붙인다.
112112
- `options.detailLimit`은 숫자 또는 숫자 문자열을 받을 수 있고, 실제 상세조회 개수는 `precedent-evidence`의 상한을 따른다.
113+
- `options.search`는 검색범위다: `1`=판례명(기본), `2`=본문, `"both"`=판례명+본문(#167). options 는 스키마 검증 없이 넘어오므로 core 의 `precedentSearchScope()`가 `"2"` 같은 문자열까지 정규화한다.
114+
- `"both"`는 제목검색과 본문검색을 각각 한 번씩(각 `display`건) 돌려 판례ID로 중복을 빼고 제목 적중을 앞에 둔다. 렌더는 범위별 건수와 건마다 `적중: 제목검색|본문검색`을 보인다. 사건번호만 넣은 exact 조회는 `"both"`여도 한 번으로 끝난다.
115+
- 기본(제목검색)이 보정 없이 3건 이하로 끝나면 렌더 끝에 `options={search:"both"}` 재검색 안내를 붙인다. 제목에 우연히 맞은 몇 건으로 법리 탐색이 끝나는 것을 막는 신호다.
113116
- `get_decision_text`는 여전히 `domain="precedent"`와 검색 결과 ID를 받아 `getPrecedentText()`로 상세 본문을 조회한다.
114117

115118
### 조문 기반 보조 도구

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "korean-law-mcp",
3-
"version": "4.15.5",
3+
"version": "4.15.6",
44
"description": "법제처 42개 API → 9개 MCP 도구. 법령·판례·조례·조약 + 다단계 리서치(legal_research, 8 task) + 정밀분석(legal_analysis: 인용검증·판례생사·행위시법·영향그래프) + 시점 비교(time_travel) + 상황별 5단계 안내(action_plan) + 국세청 해석례(nts)",
55
"type": "module",
66
"main": "build/index.js",

‎src/tool-registry.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ export const allTools: McpTool[] = [
729729
// === 통합 도구 (v3) ===
730730
{
731731
name: "search_decisions",
732-
description: "[통합검색] 18개 도메인(판례·해석례·헌재·행심·조세심판·관세·국세청·공정위·개인정보위·노동위·권익위·소청심사·학칙·공사공단·공공기관·조약·영문법령) 통합 검색. domain으로 선택. 판례 본문까지 필요하면 domain='precedent', options.includeText=true, options.detailLimit=N. 세무 관련 국세청 직접 회신 해석은 domain='nts'.",
732+
description: "[통합검색] 18개 도메인(판례·해석례·헌재·행심·조세심판·관세·국세청·공정위·개인정보위·노동위·권익위·소청심사·학칙·공사공단·공공기관·조약·영문법령) 통합 검색. domain으로 선택. 판례 본문까지 필요하면 domain='precedent', options.includeText=true, options.detailLimit=N. 판례 기본은 판례명 검색이라 법리·사실관계·유사판례 탐색은 options.search='both'(판례명+본문). 세무 관련 국세청 직접 회신 해석은 domain='nts'.",
733733
schema: SearchDecisionsSchema,
734734
handler: searchDecisions
735735
},

‎src/tools/precedent-search-core.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { rethrowIfFatal } from "../lib/fatal-errors.js"
77
import { extractCaseNumbers, fieldHasExactCase } from "../lib/case-citation.js"
88

99
export type PrecedentSearchMode = 1 | 2
10+
export type PrecedentSearchScope = PrecedentSearchMode | "both"
11+
12+
/** search 인자 정규화. search_decisions 의 options 는 스키마 검증 없이 넘어와 "2" 같은 문자열도 온다. */
13+
export function precedentSearchScope(args: SearchPrecedentsInput): PrecedentSearchScope {
14+
const value = String(args.search ?? 1)
15+
if (value === "both") return "both"
16+
return value === "2" ? 2 : 1
17+
}
1018

1119
export interface PrecedentSearchAttempt {
1220
query?: string
@@ -195,25 +203,26 @@ async function runPrecedentSearchOnce(
195203

196204
function exactCaseNumber(args: SearchPrecedentsInput): string | undefined {
197205
if (args.caseNumber) return args.caseNumber.replace(/\s/g, "")
198-
if (args.search === 2) return undefined
206+
if (precedentSearchScope(args) === 2) return undefined
199207
const query = (args.query || "").replace(/\s/g, "")
200208
const [caseNo] = extractCaseNumbers(query)
201209
return caseNo === query ? caseNo : undefined
202210
}
203211

204212
function firstAttempt(args: SearchPrecedentsInput): SearchOnceInput {
205213
const caseNumber = exactCaseNumber(args)
214+
const search = precedentSearchScope(args) === 2 ? 2 : 1
206215
if (caseNumber) {
207216
return {
208217
caseNumber,
209-
search: (args.search || 1) as PrecedentSearchMode,
218+
search,
210219
reason: "case_number",
211220
}
212221
}
213222

214223
return {
215224
query: args.query,
216-
search: (args.search || 1) as PrecedentSearchMode,
225+
search,
217226
reason: "original_query",
218227
}
219228
}
@@ -225,7 +234,7 @@ function fallbackInputs(args: SearchPrecedentsInput, context: PrecedentSearchCon
225234

226235
if (fallbackPolicy === "none" || exactCaseNumber(args)) return inputs
227236

228-
if (args.query && (args.search || 1) === 1) {
237+
if (args.query && precedentSearchScope(args) !== 2) {
229238
inputs.push({
230239
query: args.query,
231240
search: 2,
@@ -330,6 +339,25 @@ export async function searchPrecedentsStructured(
330339
}
331340

332341
const initial = await run(firstAttempt(args))
342+
343+
// search="both" (#167): 제목 적중 몇 건으로 끝내지 않고 본문검색까지 합친다. 사건번호 exact 조회는 제외.
344+
if (initial && args.query && precedentSearchScope(args) === "both" && initial.attempt.reason === "original_query") {
345+
const body = await run({ query: args.query, search: 2, reason: "body_search" })
346+
const titleIds = new Set(initial.hits.map(hit => hit.id))
347+
const hits = [...initial.hits, ...(body?.hits ?? []).filter(hit => !titleIds.has(hit.id))]
348+
if (hits.length > 0) {
349+
return {
350+
originalArgs: args,
351+
totalCount: hasDateRange(args) ? hits.length : Math.max(initial.attempt.totalCount, body?.attempt.totalCount ?? 0),
352+
page,
353+
hits,
354+
attempts,
355+
fallbackUsed: false,
356+
successfulAttempt: initial.attempt.success ? initial.attempt : body?.attempt,
357+
}
358+
}
359+
}
360+
333361
if (initial?.attempt.success) {
334362
return {
335363
originalArgs: args,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* 판례 검색범위 옵트인 (#167) — 제목검색이 1건만 맞아도 끝나서 법리 탐색 recall 이 낮던 문제.
3+
* 기본 동작은 그대로 두고 search="both"(판례명+본문)와 소량 적중 안내를 더한다.
4+
*/
5+
import { describe, expect, it, vi } from "vitest"
6+
import type { LawApiClient } from "../lib/api-client.js"
7+
import { searchPrecedentsStructured } from "./precedent-search-core.js"
8+
import { renderPrecedentSearchResult } from "./precedents.js"
9+
10+
type Request = { extraParams: Record<string, string> }
11+
12+
const item = (id: string, title: string) =>
13+
`<prec><판례일련번호>${id}</판례일련번호><사건명>${title}</사건명>` +
14+
`<사건번호>2020다${id}</사건번호><선고일자>20240101</선고일자></prec>`
15+
const page = (total: number, ...items: string[]) =>
16+
`<PrecSearch><totalCnt>${total}</totalCnt><page>1</page>${items.join("")}</PrecSearch>`
17+
18+
// 실측(2026-10-05) 모양: "학원강사 근로자" 제목검색 1건, 본문검색 70건
19+
const titleXml = page(1, item("1", "학원강사를 근로자수에 포함"))
20+
const bodyXml = page(70, item("1", "학원강사를 근로자수에 포함"), item("2", "퇴직금"), item("3", "임금"))
21+
22+
function api() {
23+
const fetchApi = vi.fn(async (request: Request) => request.extraParams.search === "2" ? bodyXml : titleXml)
24+
return { fetchApi, client: { fetchApi } as unknown as LawApiClient }
25+
}
26+
27+
describe('search="both": 판례명+본문 합침', () => {
28+
it("두 검색을 모두 돌려 판례ID로 중복을 빼고 제목 적중을 앞에 둔다", async () => {
29+
const { fetchApi, client } = api()
30+
const result = await searchPrecedentsStructured(client, { query: "학원강사 근로자", search: "both", display: 20, page: 1 })
31+
expect(fetchApi).toHaveBeenCalledTimes(2)
32+
expect(result.hits.map(hit => [hit.id, hit.searchMode])).toEqual([["1", 1], ["2", 2], ["3", 2]])
33+
expect(result.fallbackUsed).toBe(false)
34+
})
35+
36+
it("렌더가 범위별 건수와 건마다 어느 검색에서 맞았는지 보인다", async () => {
37+
const text = renderPrecedentSearchResult(
38+
await searchPrecedentsStructured(api().client, { query: "학원강사 근로자", search: "both", display: 20, page: 1 })
39+
)
40+
expect(text).toContain("제목검색 1건 · 본문검색 70건")
41+
expect(text).toMatch(/\[1\] 학원강사를 근로자수에 포함\n(?:.*\n)*? 적중: 제목검색/)
42+
expect(text).toMatch(/\[2\] 퇴직금\n(?:.*\n)*? 적중: 본문검색/)
43+
})
44+
45+
it("사건번호만 넣으면 both 여도 exact 조회 한 번으로 끝난다", async () => {
46+
const { fetchApi, client } = api()
47+
await searchPrecedentsStructured(client, { query: "2020다1", search: "both", display: 20, page: 1 })
48+
expect(fetchApi).toHaveBeenCalledTimes(1)
49+
expect(fetchApi.mock.calls[0][0].extraParams.nb).toBe("2020다1")
50+
})
51+
})
52+
53+
describe("search 문자열 입력 (search_decisions options 는 스키마 검증 없이 넘어온다)", () => {
54+
it('"2" 는 본문검색으로 보낸다', async () => {
55+
const { fetchApi, client } = api()
56+
await searchPrecedentsStructured(client, { query: "학원강사 근로자", search: "2" as never, display: 20, page: 1 })
57+
expect(fetchApi.mock.calls[0][0].extraParams.search).toBe("2")
58+
})
59+
})
60+
61+
describe("제목검색 소량 적중 안내", () => {
62+
const hint = /본문검색도 보세요/
63+
64+
it("제목검색이 3건 이하로 끝나면 search 옵션을 안내한다", async () => {
65+
const text = renderPrecedentSearchResult(
66+
await searchPrecedentsStructured(api().client, { query: "학원강사 근로자", display: 20, page: 1 })
67+
)
68+
expect(text).toMatch(hint)
69+
expect(text).toContain('options={search:"both"}')
70+
})
71+
72+
it("제목검색 적중이 많거나 both·본문검색이면 안내하지 않는다", async () => {
73+
const many = { fetchApi: async () => page(4, item("1", "가"), item("2", "나"), item("3", "다"), item("4", "라")) } as unknown as LawApiClient
74+
for (const [client, search] of [[many, undefined], [api().client, "both"], [api().client, 2]] as const) {
75+
const text = renderPrecedentSearchResult(
76+
await searchPrecedentsStructured(client, { query: "학원강사 근로자", search, display: 20, page: 1 })
77+
)
78+
expect(text).not.toMatch(hint)
79+
}
80+
})
81+
})

0 commit comments

Comments
 (0)