feat: 전체 판례 검색과 판례 탭 추가
This commit is contained in:
parent
fa15117df7
commit
5386222f1b
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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에서만 실행한다.
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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건도
|
||||||
|
내용 관련성이 높으면 인용하되 화면에 미검토로 표시한다.
|
||||||
|
|
||||||
| 출처 | 건수 | 비고 |
|
| 출처 | 건수 | 비고 |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
|
|||||||
@ -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 # 미리보기
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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