feat: 전체 판례 검색과 판례 탭 추가

This commit is contained in:
hbyang 2026-08-20 16:20:37 +09:00
parent fa15117df7
commit 5386222f1b
11 changed files with 443 additions and 44 deletions

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Query, Request, UploadFile, status
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from app.api.schemas import ( from app.api.schemas import (
@ -21,6 +21,8 @@ from app.api.schemas import (
DetectRequest, DetectRequest,
DetectResponse, DetectResponse,
HealthResponse, HealthResponse,
PrecedentItem,
PrecedentListResponse,
SummaryRequest, SummaryRequest,
SummaryResponse, SummaryResponse,
TaxonomyResponse, TaxonomyResponse,
@ -240,6 +242,69 @@ async def batch_status(job_id: str, request: Request) -> BatchStatusResponse:
) )
# ---------- 판례 조회 ----------
@router.get(
"/precedents",
response_model=PrecedentListResponse,
tags=["precedents"],
)
async def precedent_list(
request: Request,
q: str = Query(default="", max_length=200),
grade: str | None = Query(default=None, pattern="^(A|B|C|unreviewed)$"),
work_type: str | None = Query(default=None, max_length=30),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=25, ge=1, le=100),
) -> PrecedentListResponse:
"""엔진이 검색 후보로 사용하는 전체 판례를 조회한다."""
precedents = list(_detector(request).precedents)
needle = q.strip().lower()
def matches(p) -> bool:
if grade == "unreviewed" and p.grade is not None:
return False
if grade in {"A", "B", "C"} and p.grade != grade:
return False
if work_type and p.work_types and work_type not in p.work_types:
return False
if needle:
haystack = " ".join([
p.case_id, p.title, *p.work_types, *p.legal_tags,
*p.criteria, p.holding_summary,
]).lower()
if needle not in haystack:
return False
return True
filtered = [p for p in precedents if matches(p)]
page = filtered[offset:offset + limit]
return PrecedentListResponse(
total=len(filtered),
loaded_total=len(precedents),
graded_total=sum(p.grade is not None for p in precedents),
offset=offset,
limit=limit,
items=[
PrecedentItem(
case_id=p.case_id,
title=p.title,
source_url=p.source_url,
work_types=list(p.work_types),
legal_tags=list(p.legal_tags),
criteria=list(p.criteria),
grade=p.grade,
holding_excerpt=(
p.holding_summary
if len(p.holding_summary) <= 800
else p.holding_summary[:800].rstrip() + ""
),
)
for p in page
],
)
# ---------- 코퍼스 관리 ---------- # ---------- 코퍼스 관리 ----------
def _rebuild(request: Request) -> int: def _rebuild(request: Request) -> int:

View File

@ -413,6 +413,26 @@ class CorpusListResponse(BaseModel):
docs: list[CorpusItem] docs: list[CorpusItem]
class PrecedentItem(BaseModel):
case_id: str
title: str
source_url: str
work_types: list[str] = Field(default_factory=list)
legal_tags: list[str] = Field(default_factory=list)
criteria: list[str] = Field(default_factory=list)
grade: Literal["A", "B", "C"] | None = None
holding_excerpt: str
class PrecedentListResponse(BaseModel):
total: int = Field(description="현재 검색·필터에 맞는 판례 수")
loaded_total: int = Field(description="엔진에 적재된 전체 판례 수")
graded_total: int = Field(description="A/B/C 사람 검토 등급이 있는 판례 수")
offset: int
limit: int
items: list[PrecedentItem]
class CorpusUploadRequest(BaseModel): class CorpusUploadRequest(BaseModel):
doc_id: str | None = Field(default=None, description="비우면 자동 생성") doc_id: str | None = Field(default=None, description="비우면 자동 생성")
title: str = Field(..., min_length=1) title: str = Field(..., min_length=1)

View File

@ -181,6 +181,10 @@ class PlagiarismDetector:
def precedent_count(self) -> int: def precedent_count(self) -> int:
return len(self._legal_engine.precedents) return len(self._legal_engine.precedents)
@property
def precedents(self):
return tuple(self._legal_engine.precedents)
@property @property
def ai_model_ready(self) -> bool: def ai_model_ready(self) -> bool:
return self._ai_detector.mode == "trained" return self._ai_detector.mode == "trained"
@ -333,6 +337,7 @@ class PlagiarismDetector:
} }
for m in matches[:5] for m in matches[:5]
], ],
query_text=text,
) )
# AI 탐지는 전처리 전 raw text에서만 실행한다. # AI 탐지는 전처리 전 raw text에서만 실행한다.

View File

@ -8,6 +8,8 @@ from __future__ import annotations
import json import json
import logging import logging
import math
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Iterable, Protocol from typing import Iterable, Protocol
@ -92,14 +94,69 @@ class LegalJudge(Protocol):
def judge(self, request: LegalJudgeInput) -> LegalJudgeDecision: ... def judge(self, request: LegalJudgeInput) -> LegalJudgeDecision: ...
#: 인용 우선순위. A/B/C는 모두 사람이 판시 본문을 확인해 최종 리스트업한 _TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]{2,}")
#: 판례다. 등급이 없는 적재본은 리스트업 제외 대상이므로, 등급 데이터가 하나라도 _TOKEN_STOPWORDS = {
#: 있는 운영 코퍼스에서는 인용 후보로 사용하지 않는다. "그리고", "그러나", "대한", "관한", "있는", "없는", "으로", "에서",
_GRADE_ORDER = {"A": 0, "B": 1, "C": 2} "판결", "사건", "원고", "피고", "저작권", "저작물", "경우", "해당",
}
_KOREAN_SUFFIXES = ("에서는", "으로", "에서", "에게", "까지", "부터", "처럼", "보다", "", "", "", "", "", "", "", "", "", "", "")
_GRADE_BOOST = {"A": 0.05, "B": 0.035, "C": 0.015}
_SOFTWARE_MARKERS = ("컴퓨터프로그램", "폰트파일", "글꼴파일", "글꼴 파일", "서체프로그램")
def _grade_rank(grade: str | None) -> int: def _tokens(text: str) -> set[str]:
return _GRADE_ORDER.get(grade or "", len(_GRADE_ORDER)) tokens: set[str] = set()
for raw in _TOKEN_RE.findall(text):
token = raw.lower()
if token in _TOKEN_STOPWORDS:
continue
tokens.add(token)
for suffix in _KOREAN_SUFFIXES:
if token.endswith(suffix) and len(token) >= len(suffix) + 2:
tokens.add(token[:-len(suffix)])
break
return tokens
def _normalized_work_types(raw: dict) -> tuple[str, ...]:
"""수집 라벨의 명백한 오분류를 판시 제목 기준으로 보정한다.
폰트 프로그램 사건 3건은 literary/visual로 수집돼 문학 판례 검색에 섞였다.
원천 레코드는 보존하되 엔진과 조회 API에서는 software로 취급한다.
"""
title = str(raw.get("title", "")).replace(" ", "")
if any(marker.replace(" ", "") in title for marker in _SOFTWARE_MARKERS):
return ("software",)
return tuple(raw.get("work_types", []))
def _precedent_score(
precedent: Precedent,
*,
tags: set[str],
work_type: str,
query_terms: set[str],
) -> float:
"""유형·법적 쟁점·판시 내용 관련성을 중심으로 한 결정적 검색 점수."""
tag_overlap = len(tags.intersection(precedent.legal_tags))
tag_score = tag_overlap / max(len(tags), 1)
precedent_terms = _tokens(" ".join([
precedent.title, *precedent.criteria, precedent.holding_summary,
]))
# 긴 판시문 때문에 관련 어휘가 희석되지 않도록 질의 쪽 포함률을 사용한다.
text_score = len(query_terms.intersection(precedent_terms)) / max(len(query_terms), 1)
criteria_terms = _tokens(" ".join(precedent.criteria))
criteria_score = len(query_terms.intersection(criteria_terms)) / max(len(query_terms), 1)
type_score = 1.0 if work_type in precedent.work_types else 0.5
quality_score = min(math.log1p(len(precedent.holding_summary)) / 10.0, 1.0)
return (
0.55 * tag_score
+ 0.20 * text_score
+ 0.10 * criteria_score
+ 0.08 * type_score
+ 0.02 * quality_score
+ _GRADE_BOOST.get(precedent.grade or "", 0.0)
)
def load_precedents(path: str | Path) -> list[Precedent]: def load_precedents(path: str | Path) -> list[Precedent]:
@ -126,7 +183,7 @@ def load_precedents(path: str | Path) -> list[Precedent]:
case_id=case_id, case_id=case_id,
title=str(raw["title"]), title=str(raw["title"]),
source_url=str(raw["source_url"]), source_url=str(raw["source_url"]),
work_types=tuple(raw.get("work_types", [])), work_types=_normalized_work_types(raw),
legal_tags=tuple(raw.get("legal_tags", [])), legal_tags=tuple(raw.get("legal_tags", [])),
criteria=tuple(raw.get("criteria", [])), criteria=tuple(raw.get("criteria", [])),
holding_summary=str(raw["holding_summary"]), holding_summary=str(raw["holding_summary"]),
@ -140,9 +197,6 @@ class LegalRiskEngine:
def __init__(self, precedents: Iterable[Precedent], judge: LegalJudge | None = None): def __init__(self, precedents: Iterable[Precedent], judge: LegalJudge | None = None):
self.precedents = list(precedents) self.precedents = list(precedents)
self.judge = judge self.judge = judge
# 등급 반영 전의 외부/테스트 코퍼스는 계속 동작시키되, 등급을 반영한 운영
# 코퍼스는 최종 리스트업(A/B/C)을 명시적인 allowlist로 취급한다.
self._has_reviewed_precedents = any(p.grade in _GRADE_ORDER for p in self.precedents)
def assess( def assess(
self, self,
@ -156,19 +210,29 @@ class LegalRiskEngine:
protected_expression_reviewed: bool = False, protected_expression_reviewed: bool = False,
rights_verified: bool = False, rights_verified: bool = False,
evidence: Iterable[dict] = (), evidence: Iterable[dict] = (),
query_text: str = "",
) -> LegalRiskAssessment: ) -> LegalRiskAssessment:
tags = set(legal_tags) tags = set(legal_tags)
related = sorted([ evidence_rows = tuple(evidence)
search_text = " ".join([
query_text,
*(str(excerpt) for row in evidence_rows for excerpt in row.get("excerpts", [])),
*(str(row.get("infringement_type", "")) for row in evidence_rows),
])
query_terms = _tokens(search_text)
candidates = [
p for p in self.precedents p for p in self.precedents
if (not self._has_reviewed_precedents or p.grade in _GRADE_ORDER) if (not p.work_types or work_type in p.work_types)
and (not p.work_types or work_type in p.work_types) ]
and (not p.legal_tags or tags.intersection(p.legal_tags)) related = sorted(
], key=lambda p: ( candidates,
_grade_rank(p.grade), key=lambda p: (
-len(tags.intersection(p.legal_tags)), -_precedent_score(
0 if work_type in p.work_types else 1, p, tags=tags, work_type=work_type, query_terms=query_terms,
),
p.case_id, p.case_id,
))[:5] ),
)[:5]
missing: list[str] = [] missing: list[str] = []
if not protected_expression_reviewed: if not protected_expression_reviewed:
missing.append("보호되는 창작적 표현인지에 대한 사람 검토") missing.append("보호되는 창작적 표현인지에 대한 사람 검토")
@ -217,7 +281,7 @@ class LegalRiskEngine:
access_evidence=access_evidence, access_evidence=access_evidence,
protected_expression_reviewed=protected_expression_reviewed, protected_expression_reviewed=protected_expression_reviewed,
rights_verified=rights_verified, rights_verified=rights_verified,
evidence=tuple(evidence), evidence=evidence_rows,
precedents=tuple(related), precedents=tuple(related),
deterministic_missing_factors=tuple(missing), deterministic_missing_factors=tuple(missing),
) )

View File

@ -170,6 +170,21 @@
.corpus-table th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; } .corpus-table th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; }
.corpus-table tr:hover { background: var(--panel-2); } .corpus-table tr:hover { background: var(--panel-2); }
.corpus-table .doc-id { font-family: ui-monospace, monospace; font-size: 11px; color: var(--accent); } .corpus-table .doc-id { font-family: ui-monospace, monospace; font-size: 11px; color: var(--accent); }
.precedent-toolbar {
display: grid; grid-template-columns: minmax(220px, 1fr) 160px 180px auto;
gap: 10px; align-items: end; margin-bottom: 14px;
}
.precedent-toolbar button { margin-top: 4px; }
.precedent-table .cited-row { background: rgba(88, 166, 255, 0.10); }
.precedent-table .case-link { color: var(--accent); text-decoration: none; font-family: ui-monospace, monospace; }
.precedent-table .title-cell { min-width: 280px; }
.precedent-table .holding { margin-top: 5px; color: var(--muted); font-size: 11px; white-space: pre-line; }
.precedent-table .tag { display: inline-block; margin: 2px 3px 2px 0; padding: 1px 5px; border: 1px solid var(--border); border-radius: 10px; font-size: 10px; }
.precedent-summary { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.precedent-summary strong { color: var(--text); }
.precedent-pager { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 14px; }
.precedent-pager button { margin: 0; }
@media (max-width: 900px) { .precedent-toolbar { grid-template-columns: 1fr 1fr; } }
.btn-danger { .btn-danger {
margin: 0; padding: 3px 10px; background: transparent; color: var(--danger); margin: 0; padding: 3px 10px; background: transparent; color: var(--danger);
border: 1px solid var(--danger); border-radius: 4px; font-size: 11px; cursor: pointer; border: 1px solid var(--danger); border-radius: 4px; font-size: 11px; cursor: pointer;
@ -233,6 +248,7 @@
display: inline-block; margin: 2px 4px 2px 0; padding: 3px 8px; display: inline-block; margin: 2px 4px 2px 0; padding: 3px 8px;
color: var(--accent); background: rgba(88, 166, 255, 0.1); color: var(--accent); background: rgba(88, 166, 255, 0.1);
border: 1px solid rgba(88, 166, 255, 0.45); border-radius: 12px; font-size: 11px; border: 1px solid rgba(88, 166, 255, 0.45); border-radius: 12px; font-size: 11px;
cursor: pointer; font-family: inherit; font-weight: 500;
} }
.legal-disclaimer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); color: var(--warning); font-size: 11px; } .legal-disclaimer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); color: var(--warning); font-size: 11px; }
@ -267,6 +283,7 @@
<nav class="tab-nav"> <nav class="tab-nav">
<button class="tab-btn active" data-tab="detect">탐지 검토</button> <button class="tab-btn active" data-tab="detect">탐지 검토</button>
<button class="tab-btn" data-tab="corpus">코퍼스 관리</button> <button class="tab-btn" data-tab="corpus">코퍼스 관리</button>
<button class="tab-btn" data-tab="precedents">판례</button>
</nav> </nav>
<span id="health-badge" class="badge">엔진 확인 중…</span> <span id="health-badge" class="badge">엔진 확인 중…</span>
</div> </div>
@ -443,6 +460,47 @@
</div> </div>
</main> </main>
<main id="tab-precedents" class="tab-content" style="max-width: 1600px; margin: 0 auto; padding: 24px 28px;">
<section class="panel" style="width: 100%; overflow-x: auto;">
<h2>판례 검색 후보</h2>
<p style="color: var(--muted); font-size: 12px; margin-top: 0;">
적재된 판례 전체를 검색 후보로 사용합니다. A/B/C는 사람 검토 수준을 나타내는 보조 신호이며,
미검토 판례도 내용 관련성이 높으면 인용될 수 있습니다.
</p>
<div class="precedent-toolbar">
<label>사건번호·제목·판시 내용 검색
<input type="text" id="precedent-query" placeholder="예: 실질적 유사성, 2014다14375">
</label>
<label>검토 등급
<select id="precedent-grade-filter">
<option value="">전체</option><option value="A">A 직접 적용</option>
<option value="B">B 조건부</option><option value="C">C 참고</option>
<option value="unreviewed">미검토</option>
</select>
</label>
<label>저작물 유형
<select id="precedent-type-filter">
<option value="">전체</option><option value="literary">문학·출판</option>
<option value="musical">음악</option><option value="visual">미술·사진·영상</option>
<option value="software">컴퓨터프로그램</option>
</select>
</label>
<button id="precedent-search-btn">검색</button>
</div>
<div id="precedent-cited-summary" class="precedent-summary">이번 탐지에서 인용된 판례가 없습니다.</div>
<div id="precedent-list-summary" class="precedent-summary">판례 목록을 불러오는 중…</div>
<table class="corpus-table precedent-table">
<thead><tr><th>사건번호</th><th>등급</th><th>사건·판시 요약</th><th>저작물 유형</th><th>쟁점</th><th>출처</th></tr></thead>
<tbody id="precedent-tbody"><tr><td colspan="6" style="text-align:center; padding:20px; color:var(--muted);">로딩 중…</td></tr></tbody>
</table>
<div class="precedent-pager">
<button class="ghost" id="precedent-prev-btn">이전</button>
<span id="precedent-page-label">1 / 1</span>
<button class="ghost" id="precedent-next-btn">다음</button>
</div>
</section>
</main>
<footer> <footer>
<a href="docs">API 문서 (Swagger)</a> · <a href="docs">API 문서 (Swagger)</a> ·
<a href="openapi.json">OpenAPI 스펙</a> · <a href="openapi.json">OpenAPI 스펙</a> ·
@ -475,6 +533,7 @@ const SAMPLES = {
}, },
clear: { title: "", author: "", text: "" }, clear: { title: "", author: "", text: "" },
}; };
let lastCitedPrecedentIds = new Set();
const TYPE_LABELS = { const TYPE_LABELS = {
copy: "복제 (Copy)", copy: "복제 (Copy)",
@ -676,6 +735,8 @@ function renderLegalRisk(risk) {
const selectedIds = risk.llm_matched_precedent_ids && risk.llm_matched_precedent_ids.length const selectedIds = risk.llm_matched_precedent_ids && risk.llm_matched_precedent_ids.length
? risk.llm_matched_precedent_ids ? risk.llm_matched_precedent_ids
: (risk.precedent_ids || []); : (risk.precedent_ids || []);
lastCitedPrecedentIds = new Set(selectedIds);
renderCitedPrecedentSummary();
const confidence = Number.isFinite(risk.llm_confidence) const confidence = Number.isFinite(risk.llm_confidence)
? `<div class="legal-risk-confidence">${(risk.llm_confidence * 100).toFixed(0)}<small style="font-size: 11px; color: var(--muted);"> / 100 자기평가</small></div>` ? `<div class="legal-risk-confidence">${(risk.llm_confidence * 100).toFixed(0)}<small style="font-size: 11px; color: var(--muted);"> / 100 자기평가</small></div>`
: ""; : "";
@ -690,17 +751,17 @@ function renderLegalRisk(risk) {
const chip = (id) => { const chip = (id) => {
const g = grades[id]; const g = grades[id];
return g return g
? `<span class="precedent-chip g-${g}">${escapeHtml(id)}<span class="grade">${g}</span></span>` ? `<button type="button" class="precedent-chip g-${g}" onclick="openPrecedentTab('${escapeJs(id)}')">${escapeHtml(id)}<span class="grade">${g}</span></button>`
: `<span class="precedent-chip">${escapeHtml(id)}</span>`; : `<button type="button" class="precedent-chip" onclick="openPrecedentTab('${escapeJs(id)}')">${escapeHtml(id)}</button>`;
}; };
const legend = selectedIds.length const legend = selectedIds.length
? `<div class="precedent-legend">A 직접 적용 · B 조건부 · C 참고 · 인용 후보는 사람 검토를 마친 리스트업 판례로 제한</div>` ? `<div class="precedent-legend">A/B/C는 사람 검토 등급 · 미표기는 미검토 · 등급보다 유형·쟁점·판시 내용 관련성을 우선</div>`
: ""; : "";
const precedents = selectedIds.length const precedents = selectedIds.length
? selectedIds.map(chip).join("") + legend ? selectedIds.map(chip).join("") + legend
: noCorpus : noCorpus
? '<div class="legal-risk-meta precedent-empty">판례가 적재되지 않아 판단 근거를 제시할 수 없습니다.</div>' ? '<div class="legal-risk-meta precedent-empty">판례가 적재되지 않아 판단 근거를 제시할 수 없습니다.</div>'
: '<div class="legal-risk-meta">이 건과 저작물 유형·법적 태그가 맞는 판례가 없습니다.</div>'; : '<div class="legal-risk-meta">이 건의 저작물 유형과 입력 내용에 맞는 판례가 없습니다.</div>';
// 적재 수를 함께 보여 줘야 "판례를 안 쓰는 것"이 아니라 "이 건에 맞는 게 없는 것"임이 드러난다. // 적재 수를 함께 보여 줘야 "판례를 안 쓰는 것"이 아니라 "이 건에 맞는 게 없는 것"임이 드러난다.
const precedentCountLabel = Number.isFinite(precedentCount) const precedentCountLabel = Number.isFinite(precedentCount)
? `<span class="count">인용 ${selectedIds.length}건 / 적재 ${precedentCount}건</span>` ? `<span class="count">인용 ${selectedIds.length}건 / 적재 ${precedentCount}건</span>`
@ -853,9 +914,9 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
const tab = btn.dataset.tab; const tab = btn.dataset.tab;
document.getElementById(`tab-${tab}`).classList.add("active"); document.getElementById(`tab-${tab}`).classList.add("active");
if (tab === "corpus") loadCorpus(); if (tab === "corpus") loadCorpus();
if (tab === "precedents") loadPrecedents();
}); });
}); });
// ========== 코퍼스 관리 ========== // ========== 코퍼스 관리 ==========
async function loadCorpus() { async function loadCorpus() {
const tbody = document.getElementById("corpus-tbody"); const tbody = document.getElementById("corpus-tbody");
@ -973,6 +1034,97 @@ function escapeJs(s) {
return String(s).replace(/['\\]/g, "\\$&"); return String(s).replace(/['\\]/g, "\\$&");
} }
// ========== 판례 목록 ==========
const PRECEDENT_PAGE_SIZE = 25;
let precedentOffset = 0;
let precedentTotal = 0;
function renderCitedPrecedentSummary() {
const el = document.getElementById("precedent-cited-summary");
if (!el) return;
const ids = [...lastCitedPrecedentIds];
el.innerHTML = ids.length
? `<strong>이번 탐지 인용 ${ids.length}건</strong> ${ids.map(id =>
`<button class="ghost" style="margin:0; padding:3px 8px;" onclick="searchPrecedentById('${escapeJs(id)}')">${escapeHtml(id)}</button>`
).join("")}`
: "이번 탐지에서 인용된 판례가 없습니다.";
}
function searchPrecedentById(caseId) {
document.getElementById("precedent-query").value = caseId;
precedentOffset = 0;
loadPrecedents();
}
function openPrecedentTab(caseId) {
document.querySelector('.tab-btn[data-tab="precedents"]').click();
searchPrecedentById(caseId);
}
async function loadPrecedents() {
const tbody = document.getElementById("precedent-tbody");
const q = document.getElementById("precedent-query").value.trim();
const grade = document.getElementById("precedent-grade-filter").value;
const workType = document.getElementById("precedent-type-filter").value;
const params = new URLSearchParams({
offset: String(precedentOffset), limit: String(PRECEDENT_PAGE_SIZE),
});
if (q) params.set("q", q);
if (grade) params.set("grade", grade);
if (workType) params.set("work_type", workType);
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:20px; color:var(--muted);">로딩 중…</td></tr>';
try {
const resp = await fetch(`v1/precedents?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
precedentTotal = data.total;
document.getElementById("precedent-list-summary").innerHTML =
`<strong>검색 ${data.total}건</strong> · 전체 적재 ${data.loaded_total}건 · A/B/C 검토 ${data.graded_total}건 · 미검토 ${data.loaded_total - data.graded_total}건`;
if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:20px; color:var(--muted);">조건에 맞는 판례가 없습니다.</td></tr>';
} else {
tbody.innerHTML = data.items.map(p => {
const cited = lastCitedPrecedentIds.has(p.case_id);
const grade = p.grade
? `<span class="precedent-chip g-${p.grade}">${p.grade}</span>`
: '<span style="color:var(--muted);">미검토</span>';
const tags = [...(p.criteria || []), ...(p.legal_tags || [])]
.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join("");
return `<tr class="${cited ? "cited-row" : ""}">
<td><a class="case-link" href="${escapeHtml(p.source_url)}" target="_blank" rel="noopener">${escapeHtml(p.case_id)}</a>${cited ? '<div style="color:var(--accent); font-size:10px; margin-top:4px;">이번 탐지 인용</div>' : ""}</td>
<td>${grade}</td>
<td class="title-cell"><strong>${escapeHtml(p.title)}</strong><details><summary style="cursor:pointer; color:var(--accent); font-size:11px;">판시 요약 보기</summary><div class="holding">${escapeHtml(p.holding_excerpt)}</div></details></td>
<td>${(p.work_types || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join("") || '<span style="color:var(--muted);">공통</span>'}</td>
<td>${tags || '<span style="color:var(--muted);">미분류</span>'}</td>
<td><a class="case-link" href="${escapeHtml(p.source_url)}" target="_blank" rel="noopener">공식 판례 ↗</a></td>
</tr>`;
}).join("");
}
const page = Math.floor(precedentOffset / PRECEDENT_PAGE_SIZE) + 1;
const pages = Math.max(1, Math.ceil(data.total / PRECEDENT_PAGE_SIZE));
document.getElementById("precedent-page-label").textContent = `${page} / ${pages}`;
document.getElementById("precedent-prev-btn").disabled = precedentOffset === 0;
document.getElementById("precedent-next-btn").disabled = precedentOffset + PRECEDENT_PAGE_SIZE >= data.total;
} catch (err) {
tbody.innerHTML = `<tr><td colspan="6" style="color:var(--danger); padding:20px;">로딩 실패: ${escapeHtml(err.message)}</td></tr>`;
}
}
document.getElementById("precedent-search-btn").addEventListener("click", () => {
precedentOffset = 0; loadPrecedents();
});
document.getElementById("precedent-query").addEventListener("keydown", (event) => {
if (event.key === "Enter") { precedentOffset = 0; loadPrecedents(); }
});
document.getElementById("precedent-prev-btn").addEventListener("click", () => {
precedentOffset = Math.max(0, precedentOffset - PRECEDENT_PAGE_SIZE); loadPrecedents();
});
document.getElementById("precedent-next-btn").addEventListener("click", () => {
if (precedentOffset + PRECEDENT_PAGE_SIZE < precedentTotal) {
precedentOffset += PRECEDENT_PAGE_SIZE; loadPrecedents();
}
});
// 헬스 체크 + 코퍼스 정보 표시 // 헬스 체크 + 코퍼스 정보 표시
// 적재된 판례 수. 저작권 판단 카드에서 '인용 N / 적재 M' 을 보여주는 데 쓴다. // 적재된 판례 수. 저작권 판단 카드에서 '인용 N / 적재 M' 을 보여주는 데 쓴다.
let precedentCount = null; let precedentCount = null;
@ -1006,6 +1158,10 @@ async function checkHealth() {
document.getElementById("precedent-count").textContent = "확인 불가"; document.getElementById("precedent-count").textContent = "확인 불가";
} }
} }
const initialTab = new URLSearchParams(window.location.search).get("tab");
if (["detect", "corpus", "precedents"].includes(initialTab)) {
document.querySelector(`.tab-btn[data-tab="${initialTab}"]`).click();
}
checkHealth(); checkHealth();
</script> </script>
</body> </body>

View File

@ -98,9 +98,11 @@ python -m scripts.validate_precedents data/precedents/precedents.jsonl
- 실질적 유사성 인정·부정 이유 - 실질적 유사성 인정·부정 이유
- 저작물 유형과 결론 - 저작물 유형과 결론
엔진은 등록된 사건번호만 반환하며 판례를 자유 생성하지 않는다. 적재본 545건 중 엔진은 등록된 사건번호만 반환하며 판례를 자유 생성하지 않는다. 적재본 545건 전체를
`precedent_listup.csv`에서 판시 본문 검토와 A/B/C 등급 부여를 마친 57건만 인용 후보로 검색 후보로 사용하고, 저작물 유형·법적 태그·입력 및 증거 텍스트와 판시 내용의 어휘 관련성으로
사용한다. 등급을 적재본에 다시 반영할 때는 다음 명령을 실행한다. 재정렬한다. `precedent_listup.csv`의 A/B/C 등급은 사람 검토 수준을 나타내는 작은 보조
가중치이며 미등급 판례도 검색에서 제외하지 않는다. 등급을 적재본에 다시 반영할 때는
다음 명령을 실행한다.
```bash ```bash
python scripts/apply_precedent_grades.py --write python scripts/apply_precedent_grades.py --write

View File

@ -58,11 +58,12 @@
## 2-1. 적재본 545건의 출처 — 표절 탐지에 실제로 쓰이는 판례 ## 2-1. 적재본 545건의 출처 — 표절 탐지에 실제로 쓰이는 판례
엔진은 적재본 545건을 로드하지만, 판시 본문을 확인해 A/B/C 등급을 부여한 엔진은 적재본 **545건 전체를 런타임 검색 후보로 사용한다**
**리스트업 57건만 런타임 인용 후보로 사용한다** (`PRECEDENTS_PATH=./data/precedents/precedents.jsonl`). 판정 1건마다 저작물 유형으로
(`PRECEDENTS_PATH=./data/precedents/precedents.jsonl`). 판정 1건마다 이 후보 중 명백히 다른 사건을 제외하고, 법적 태그와 입력·증거 텍스트 대비 판시 내용의 어휘 관련성을
저작물 유형과 법적 태그가 맞는 상위 5건을 A → B → C 순으로 골라 근거로 인용한다 합산해 상위 5건을 인용한다(`app/engine/legal_risk.py`). A/B/C 등급은 사람 검토 수준을
(`app/engine/legal_risk.py`). 등급이 없는 488건은 원천 데이터에는 보존하되 인용하지 않는다. 나타내는 작은 보조 가중치이며 절대 순서나 검색 제외 조건이 아니다. 등급이 없는 488건도
내용 관련성이 높으면 인용하되 화면에 미검토로 표시한다.
| 출처 | 건수 | 비고 | | 출처 | 건수 | 비고 |
|---|---:|---| |---|---:|---|

View File

@ -11,11 +11,10 @@
무엇을 하는가: 무엇을 하는가:
CSV `grade` JSONL 레코드에 `grade` 필드로 넣는다. 등급이 없는 건은 CSV `grade` JSONL 레코드에 `grade` 필드로 넣는다. 등급이 없는 건은
필드를 넣지 않는다. 엔진은 등급이 반영된 코퍼스에서는 A/B/C 57건만 인용하고 필드를 넣지 않는다. 엔진은 545 전체를 검색하되 A/B/C를 작은 품질 가중치와
나머지는 리스트업 제외 대상으로 취급한다. 선정 판례의 우선순위는 A B C. 검토 상태 표시에 사용한다. 미등급 판례도 내용 관련성이 높으면 인용될 .
원천 판례를 지우지는 않는다. 545건은 적재본에 보존하되, 런타임 인용 후보는 원천 판례를 지우지 않는다. 545 모두 적재본과 런타임 검색 후보로 유지한다.
사람이 판시를 확인한 57건으로 제한한다.
사용: 사용:
python scripts/apply_precedent_grades.py # 미리보기 python scripts/apply_precedent_grades.py # 미리보기

View File

@ -57,7 +57,20 @@ def test_frontend_distinguishes_no_match_from_no_corpus():
(엔진이 insufficient_precedent_data 낸다). (엔진이 insufficient_precedent_data 낸다).
""" """
assert "판례가 적재되지 않아 판단 근거를 제시할 수 없습니다." in HTML assert "판례가 적재되지 않아 판단 근거를 제시할 수 없습니다." in HTML
assert "저작물 유형·법적 태그가 맞는 판례가 없습니다." in HTML assert "저작물 유형과 입력 내용에 맞는 판례가 없습니다." in HTML
# 미적재는 헬스 배지에서도 경고색으로 구분된다 # 미적재는 헬스 배지에서도 경고색으로 구분된다
assert "badge warn" in HTML assert "badge warn" in HTML
assert "header .badge.warn" in HTML assert "header .badge.warn" in HTML
def test_frontend_has_searchable_precedent_tab_and_citation_cross_reference():
assert 'data-tab="precedents"' in HTML
assert 'id="tab-precedents"' in HTML
assert 'id="precedent-query"' in HTML
assert 'id="precedent-grade-filter"' in HTML
assert 'id="precedent-type-filter"' in HTML
assert 'id="precedent-tbody"' in HTML
assert 'fetch(`v1/precedents?${params}`)' in HTML
assert "이번 탐지 인용" in HTML
assert "lastCitedPrecedentIds" in HTML
assert "openPrecedentTab" in HTML

View File

@ -1,4 +1,5 @@
import json import json
from dataclasses import replace
from types import SimpleNamespace from types import SimpleNamespace
from app.engine.legal_risk import ( from app.engine.legal_risk import (
@ -191,15 +192,32 @@ def test_reviewed_precedents_are_cited_before_unreviewed():
assert result.precedent_ids[0] == "grade-a" assert result.precedent_ids[0] == "grade-a"
def test_unreviewed_is_excluded_when_reviewed_listup_exists(): def test_unreviewed_remains_searchable_when_reviewed_listup_exists():
"""등급 없는 적재본은 최종 리스트업에서 빠진 판례다.""" """등급 없음은 미검토 표시일 뿐 검색 제외 조건이 아니다."""
result = _assess([_graded("grade-c", "C"), _graded("unreviewed", None)]) result = _assess([_graded("grade-c", "C"), _graded("unreviewed", None)])
assert result.precedent_ids == ("grade-c",) assert result.precedent_ids == ("grade-c", "unreviewed")
def test_full_grade_order(): def test_full_grade_order():
cases = [_graded("c", "C"), _graded("none", None), _graded("b", "B"), _graded("a", "A")] cases = [_graded("c", "C"), _graded("none", None), _graded("b", "B"), _graded("a", "A")]
assert _assess(cases).precedent_ids == ("a", "b", "c") assert _assess(cases).precedent_ids == ("a", "b", "c", "none")
def test_text_relevance_can_outrank_grade_boost():
direct = replace(
_graded("direct-unreviewed", None),
holding_summary="독특한 문장 표현을 그대로 복제한 사안",
)
generic = replace(_graded("generic-a", "A"), holding_summary="일반적인 법률 원칙")
result = LegalRiskEngine([generic, direct]).assess(
max_similarity=0.9,
coverage=0.7,
longest_span=300,
legal_tags=["reproduction"],
work_type="literary",
query_text="독특한 문장 표현을 그대로 복제",
)
assert result.precedent_ids[0] == "direct-unreviewed"
def test_c_grade_precedents_remain_reference_candidates(): def test_c_grade_precedents_remain_reference_candidates():
@ -244,3 +262,18 @@ def test_operational_corpus_does_not_cite_excluded_font_program_case():
work_type="literary", work_type="literary",
) )
assert "95가합11403" not in result.precedent_ids assert "95가합11403" not in result.precedent_ids
def test_font_program_case_is_available_for_software_search():
cases = load_precedents("data/precedents/precedents.jsonl")
target = next(p for p in cases if p.case_id == "95가합11403")
assert target.work_types == ("software",)
result = LegalRiskEngine(cases).assess(
max_similarity=0.9,
coverage=0.7,
longest_span=300,
legal_tags=["reproduction", "derivative_work"],
work_type="software",
query_text="폰트파일 컴퓨터프로그램 복제 전환행위",
)
assert "95가합11403" in result.precedent_ids

View File

@ -0,0 +1,41 @@
from fastapi.testclient import TestClient
from app.main import app
def test_precedent_api_lists_full_engine_corpus_with_pagination():
with TestClient(app) as client:
response = client.get("/v1/precedents?limit=10")
assert response.status_code == 200
body = response.json()
assert body["loaded_total"] == 545
assert body["total"] == 545
assert body["graded_total"] == 57
assert len(body["items"]) == 10
assert {
"case_id", "title", "source_url", "work_types", "legal_tags",
"criteria", "grade", "holding_excerpt",
} == set(body["items"][0])
def test_precedent_api_search_and_normalized_software_type():
with TestClient(app) as client:
response = client.get(
"/v1/precedents",
params={"q": "95가합11403", "work_type": "software"},
)
assert response.status_code == 200
body = response.json()
assert body["total"] == 1
assert body["items"][0]["case_id"] == "95가합11403"
assert body["items"][0]["work_types"] == ["software"]
def test_precedent_api_filters_review_status():
with TestClient(app) as client:
graded = client.get("/v1/precedents?grade=A&limit=100").json()
unreviewed = client.get("/v1/precedents?grade=unreviewed&limit=1").json()
assert graded["total"] == 24
assert all(item["grade"] == "A" for item in graded["items"])
assert unreviewed["total"] == 488
assert unreviewed["items"][0]["grade"] is None