feat: 전체 판례 검색과 판례 탭 추가
This commit is contained in:
parent
fa15117df7
commit
5386222f1b
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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 app.api.schemas import (
|
||||
@ -21,6 +21,8 @@ from app.api.schemas import (
|
||||
DetectRequest,
|
||||
DetectResponse,
|
||||
HealthResponse,
|
||||
PrecedentItem,
|
||||
PrecedentListResponse,
|
||||
SummaryRequest,
|
||||
SummaryResponse,
|
||||
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:
|
||||
|
||||
@ -413,6 +413,26 @@ class CorpusListResponse(BaseModel):
|
||||
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):
|
||||
doc_id: str | None = Field(default=None, description="비우면 자동 생성")
|
||||
title: str = Field(..., min_length=1)
|
||||
|
||||
@ -181,6 +181,10 @@ class PlagiarismDetector:
|
||||
def precedent_count(self) -> int:
|
||||
return len(self._legal_engine.precedents)
|
||||
|
||||
@property
|
||||
def precedents(self):
|
||||
return tuple(self._legal_engine.precedents)
|
||||
|
||||
@property
|
||||
def ai_model_ready(self) -> bool:
|
||||
return self._ai_detector.mode == "trained"
|
||||
@ -333,6 +337,7 @@ class PlagiarismDetector:
|
||||
}
|
||||
for m in matches[:5]
|
||||
],
|
||||
query_text=text,
|
||||
)
|
||||
|
||||
# AI 탐지는 전처리 전 raw text에서만 실행한다.
|
||||
|
||||
@ -8,6 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Protocol
|
||||
@ -92,14 +94,69 @@ class LegalJudge(Protocol):
|
||||
def judge(self, request: LegalJudgeInput) -> LegalJudgeDecision: ...
|
||||
|
||||
|
||||
#: 인용 우선순위. A/B/C는 모두 사람이 판시 본문을 확인해 최종 리스트업한
|
||||
#: 판례다. 등급이 없는 적재본은 리스트업 제외 대상이므로, 등급 데이터가 하나라도
|
||||
#: 있는 운영 코퍼스에서는 인용 후보로 사용하지 않는다.
|
||||
_GRADE_ORDER = {"A": 0, "B": 1, "C": 2}
|
||||
_TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]{2,}")
|
||||
_TOKEN_STOPWORDS = {
|
||||
"그리고", "그러나", "대한", "관한", "있는", "없는", "으로", "에서",
|
||||
"판결", "사건", "원고", "피고", "저작권", "저작물", "경우", "해당",
|
||||
}
|
||||
_KOREAN_SUFFIXES = ("에서는", "으로", "에서", "에게", "까지", "부터", "처럼", "보다", "은", "는", "이", "가", "을", "를", "과", "와", "의", "에", "로")
|
||||
_GRADE_BOOST = {"A": 0.05, "B": 0.035, "C": 0.015}
|
||||
_SOFTWARE_MARKERS = ("컴퓨터프로그램", "폰트파일", "글꼴파일", "글꼴 파일", "서체프로그램")
|
||||
|
||||
|
||||
def _grade_rank(grade: str | None) -> int:
|
||||
return _GRADE_ORDER.get(grade or "", len(_GRADE_ORDER))
|
||||
def _tokens(text: str) -> set[str]:
|
||||
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]:
|
||||
@ -126,7 +183,7 @@ def load_precedents(path: str | Path) -> list[Precedent]:
|
||||
case_id=case_id,
|
||||
title=str(raw["title"]),
|
||||
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", [])),
|
||||
criteria=tuple(raw.get("criteria", [])),
|
||||
holding_summary=str(raw["holding_summary"]),
|
||||
@ -140,9 +197,6 @@ class LegalRiskEngine:
|
||||
def __init__(self, precedents: Iterable[Precedent], judge: LegalJudge | None = None):
|
||||
self.precedents = list(precedents)
|
||||
self.judge = judge
|
||||
# 등급 반영 전의 외부/테스트 코퍼스는 계속 동작시키되, 등급을 반영한 운영
|
||||
# 코퍼스는 최종 리스트업(A/B/C)을 명시적인 allowlist로 취급한다.
|
||||
self._has_reviewed_precedents = any(p.grade in _GRADE_ORDER for p in self.precedents)
|
||||
|
||||
def assess(
|
||||
self,
|
||||
@ -156,19 +210,29 @@ class LegalRiskEngine:
|
||||
protected_expression_reviewed: bool = False,
|
||||
rights_verified: bool = False,
|
||||
evidence: Iterable[dict] = (),
|
||||
query_text: str = "",
|
||||
) -> LegalRiskAssessment:
|
||||
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
|
||||
if (not self._has_reviewed_precedents or p.grade in _GRADE_ORDER)
|
||||
and (not p.work_types or work_type in p.work_types)
|
||||
and (not p.legal_tags or tags.intersection(p.legal_tags))
|
||||
], key=lambda p: (
|
||||
_grade_rank(p.grade),
|
||||
-len(tags.intersection(p.legal_tags)),
|
||||
0 if work_type in p.work_types else 1,
|
||||
if (not p.work_types or work_type in p.work_types)
|
||||
]
|
||||
related = sorted(
|
||||
candidates,
|
||||
key=lambda p: (
|
||||
-_precedent_score(
|
||||
p, tags=tags, work_type=work_type, query_terms=query_terms,
|
||||
),
|
||||
p.case_id,
|
||||
))[:5]
|
||||
),
|
||||
)[:5]
|
||||
missing: list[str] = []
|
||||
if not protected_expression_reviewed:
|
||||
missing.append("보호되는 창작적 표현인지에 대한 사람 검토")
|
||||
@ -217,7 +281,7 @@ class LegalRiskEngine:
|
||||
access_evidence=access_evidence,
|
||||
protected_expression_reviewed=protected_expression_reviewed,
|
||||
rights_verified=rights_verified,
|
||||
evidence=tuple(evidence),
|
||||
evidence=evidence_rows,
|
||||
precedents=tuple(related),
|
||||
deterministic_missing_factors=tuple(missing),
|
||||
)
|
||||
|
||||
@ -170,6 +170,21 @@
|
||||
.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 .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 {
|
||||
margin: 0; padding: 3px 10px; background: transparent; color: var(--danger);
|
||||
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;
|
||||
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;
|
||||
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; }
|
||||
|
||||
@ -267,6 +283,7 @@
|
||||
<nav class="tab-nav">
|
||||
<button class="tab-btn active" data-tab="detect">탐지 검토</button>
|
||||
<button class="tab-btn" data-tab="corpus">코퍼스 관리</button>
|
||||
<button class="tab-btn" data-tab="precedents">판례</button>
|
||||
</nav>
|
||||
<span id="health-badge" class="badge">엔진 확인 중…</span>
|
||||
</div>
|
||||
@ -443,6 +460,47 @@
|
||||
</div>
|
||||
</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>
|
||||
<a href="docs">API 문서 (Swagger)</a> ·
|
||||
<a href="openapi.json">OpenAPI 스펙</a> ·
|
||||
@ -475,6 +533,7 @@ const SAMPLES = {
|
||||
},
|
||||
clear: { title: "", author: "", text: "" },
|
||||
};
|
||||
let lastCitedPrecedentIds = new Set();
|
||||
|
||||
const TYPE_LABELS = {
|
||||
copy: "복제 (Copy)",
|
||||
@ -676,6 +735,8 @@ function renderLegalRisk(risk) {
|
||||
const selectedIds = risk.llm_matched_precedent_ids && risk.llm_matched_precedent_ids.length
|
||||
? risk.llm_matched_precedent_ids
|
||||
: (risk.precedent_ids || []);
|
||||
lastCitedPrecedentIds = new Set(selectedIds);
|
||||
renderCitedPrecedentSummary();
|
||||
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>`
|
||||
: "";
|
||||
@ -690,17 +751,17 @@ function renderLegalRisk(risk) {
|
||||
const chip = (id) => {
|
||||
const g = grades[id];
|
||||
return g
|
||||
? `<span class="precedent-chip g-${g}">${escapeHtml(id)}<span class="grade">${g}</span></span>`
|
||||
: `<span class="precedent-chip">${escapeHtml(id)}</span>`;
|
||||
? `<button type="button" class="precedent-chip g-${g}" onclick="openPrecedentTab('${escapeJs(id)}')">${escapeHtml(id)}<span class="grade">${g}</span></button>`
|
||||
: `<button type="button" class="precedent-chip" onclick="openPrecedentTab('${escapeJs(id)}')">${escapeHtml(id)}</button>`;
|
||||
};
|
||||
const legend = selectedIds.length
|
||||
? `<div class="precedent-legend">A 직접 적용 · B 조건부 · C 참고 · 인용 후보는 사람 검토를 마친 리스트업 판례로 제한</div>`
|
||||
? `<div class="precedent-legend">A/B/C는 사람 검토 등급 · 미표기는 미검토 · 등급보다 유형·쟁점·판시 내용 관련성을 우선</div>`
|
||||
: "";
|
||||
const precedents = selectedIds.length
|
||||
? selectedIds.map(chip).join("") + legend
|
||||
: noCorpus
|
||||
? '<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)
|
||||
? `<span class="count">인용 ${selectedIds.length}건 / 적재 ${precedentCount}건</span>`
|
||||
@ -853,9 +914,9 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
const tab = btn.dataset.tab;
|
||||
document.getElementById(`tab-${tab}`).classList.add("active");
|
||||
if (tab === "corpus") loadCorpus();
|
||||
if (tab === "precedents") loadPrecedents();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 코퍼스 관리 ==========
|
||||
async function loadCorpus() {
|
||||
const tbody = document.getElementById("corpus-tbody");
|
||||
@ -973,6 +1034,97 @@ function escapeJs(s) {
|
||||
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' 을 보여주는 데 쓴다.
|
||||
let precedentCount = null;
|
||||
@ -1006,6 +1158,10 @@ async function checkHealth() {
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -98,9 +98,11 @@ python -m scripts.validate_precedents data/precedents/precedents.jsonl
|
||||
- 실질적 유사성 인정·부정 이유
|
||||
- 저작물 유형과 결론
|
||||
|
||||
엔진은 등록된 사건번호만 반환하며 판례를 자유 생성하지 않는다. 적재본 545건 중
|
||||
`precedent_listup.csv`에서 판시 본문 검토와 A/B/C 등급 부여를 마친 57건만 인용 후보로
|
||||
사용한다. 등급을 적재본에 다시 반영할 때는 다음 명령을 실행한다.
|
||||
엔진은 등록된 사건번호만 반환하며 판례를 자유 생성하지 않는다. 적재본 545건 전체를
|
||||
검색 후보로 사용하고, 저작물 유형·법적 태그·입력 및 증거 텍스트와 판시 내용의 어휘 관련성으로
|
||||
재정렬한다. `precedent_listup.csv`의 A/B/C 등급은 사람 검토 수준을 나타내는 작은 보조
|
||||
가중치이며 미등급 판례도 검색에서 제외하지 않는다. 등급을 적재본에 다시 반영할 때는
|
||||
다음 명령을 실행한다.
|
||||
|
||||
```bash
|
||||
python scripts/apply_precedent_grades.py --write
|
||||
|
||||
@ -58,11 +58,12 @@
|
||||
|
||||
## 2-1. 적재본 545건의 출처 — 표절 탐지에 실제로 쓰이는 판례
|
||||
|
||||
엔진은 적재본 545건을 로드하지만, 판시 본문을 확인해 A/B/C 등급을 부여한
|
||||
**리스트업 57건만 런타임 인용 후보로 사용한다**
|
||||
(`PRECEDENTS_PATH=./data/precedents/precedents.jsonl`). 판정 1건마다 이 후보 중
|
||||
저작물 유형과 법적 태그가 맞는 상위 5건을 A → B → C 순으로 골라 근거로 인용한다
|
||||
(`app/engine/legal_risk.py`). 등급이 없는 488건은 원천 데이터에는 보존하되 인용하지 않는다.
|
||||
엔진은 적재본 **545건 전체를 런타임 검색 후보로 사용한다**
|
||||
(`PRECEDENTS_PATH=./data/precedents/precedents.jsonl`). 판정 1건마다 저작물 유형으로
|
||||
명백히 다른 사건을 제외하고, 법적 태그와 입력·증거 텍스트 대비 판시 내용의 어휘 관련성을
|
||||
합산해 상위 5건을 인용한다(`app/engine/legal_risk.py`). A/B/C 등급은 사람 검토 수준을
|
||||
나타내는 작은 보조 가중치이며 절대 순서나 검색 제외 조건이 아니다. 등급이 없는 488건도
|
||||
내용 관련성이 높으면 인용하되 화면에 미검토로 표시한다.
|
||||
|
||||
| 출처 | 건수 | 비고 |
|
||||
|---|---:|---|
|
||||
|
||||
@ -11,11 +11,10 @@
|
||||
|
||||
무엇을 하는가:
|
||||
CSV 의 `grade` 를 JSONL 각 레코드에 `grade` 필드로 넣는다. 등급이 없는 건은
|
||||
필드를 넣지 않는다. 엔진은 등급이 반영된 코퍼스에서는 A/B/C 57건만 인용하고
|
||||
나머지는 리스트업 제외 대상으로 취급한다. 선정 판례의 우선순위는 A → B → C다.
|
||||
필드를 넣지 않는다. 엔진은 545건 전체를 검색하되 A/B/C를 작은 품질 가중치와
|
||||
검토 상태 표시에 사용한다. 미등급 판례도 내용 관련성이 높으면 인용될 수 있다.
|
||||
|
||||
원천 판례를 지우지는 않는다. 545건은 적재본에 보존하되, 런타임 인용 후보는
|
||||
사람이 판시를 확인한 57건으로 제한한다.
|
||||
원천 판례를 지우지 않는다. 545건 모두 적재본과 런타임 검색 후보로 유지한다.
|
||||
|
||||
사용:
|
||||
python scripts/apply_precedent_grades.py # 미리보기
|
||||
|
||||
@ -57,7 +57,20 @@ def test_frontend_distinguishes_no_match_from_no_corpus():
|
||||
(엔진이 insufficient_precedent_data 를 낸다).
|
||||
"""
|
||||
assert "판례가 적재되지 않아 판단 근거를 제시할 수 없습니다." in HTML
|
||||
assert "저작물 유형·법적 태그가 맞는 판례가 없습니다." in HTML
|
||||
assert "저작물 유형과 입력 내용에 맞는 판례가 없습니다." in HTML
|
||||
# 미적재는 헬스 배지에서도 경고색으로 구분된다
|
||||
assert "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
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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)])
|
||||
assert result.precedent_ids == ("grade-c",)
|
||||
assert result.precedent_ids == ("grade-c", "unreviewed")
|
||||
|
||||
|
||||
def test_full_grade_order():
|
||||
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():
|
||||
@ -244,3 +262,18 @@ def test_operational_corpus_does_not_cite_excluded_font_program_case():
|
||||
work_type="literary",
|
||||
)
|
||||
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
|
||||
|
||||
41
tests/test_precedent_api.py
Normal file
41
tests/test_precedent_api.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user