diff --git a/app/api/routes.py b/app/api/routes.py index 00b4356..f718f12 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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: diff --git a/app/api/schemas.py b/app/api/schemas.py index 68e2454..1a8cc0b 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -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) diff --git a/app/engine/detector.py b/app/engine/detector.py index d3485b3..a18b364 100644 --- a/app/engine/detector.py +++ b/app/engine/detector.py @@ -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에서만 실행한다. diff --git a/app/engine/legal_risk.py b/app/engine/legal_risk.py index e88908b..0e130f1 100644 --- a/app/engine/legal_risk.py +++ b/app/engine/legal_risk.py @@ -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, - p.case_id, - ))[:5] + 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] 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), ) diff --git a/app/static/index.html b/app/static/index.html index 3bfb8c9..8c18113 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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 @@ 탐지 검토 코퍼스 관리 + 판례 엔진 확인 중… @@ -443,6 +460,47 @@ + + + 판례 검색 후보 + + 적재된 판례 전체를 검색 후보로 사용합니다. A/B/C는 사람 검토 수준을 나타내는 보조 신호이며, + 미검토 판례도 내용 관련성이 높으면 인용될 수 있습니다. + + + 사건번호·제목·판시 내용 검색 + + + 검토 등급 + + 전체A 직접 적용 + B 조건부C 참고 + 미검토 + + + 저작물 유형 + + 전체문학·출판 + 음악미술·사진·영상 + 컴퓨터프로그램 + + + 검색 + + 이번 탐지에서 인용된 판례가 없습니다. + 판례 목록을 불러오는 중… + + 사건번호등급사건·판시 요약저작물 유형쟁점출처 + 로딩 중… + + + 이전 + 1 / 1 + 다음 + + + +
+ 적재된 판례 전체를 검색 후보로 사용합니다. A/B/C는 사람 검토 수준을 나타내는 보조 신호이며, + 미검토 판례도 내용 관련성이 높으면 인용될 수 있습니다. +