o2o-plagiarism-ai/app/api/schemas.py
hbyang b73d27a850 feat: split author/admin detect views and add case matching KPI basis
월례회의 자료(2026-09-18) p.7 파이프라인의 3단계 「저자 화면에는 케이스 코드를
노출하지 않는다」를 구현하고, 후속 합의에 필요한 문서를 함께 남긴다.

- DetectOptions.audience(admin 기본 / author). author 직렬화에서 case_id,
  case_candidates, tags, legal_risk, is_infringement 을 제외하고 ccl_basis 를
  코드 없는 문장으로 대체한다. 일치 위치와 점수는 유지한다.
- is_infringement 는 필수 bool 로 둔다. run_precision_eval.py 등 소비자가 bool
  로 읽으므로 선택 필드로 두면 None 이 조용히 흘러간다. 제외는 직렬화에서만 한다.
- publication_verdict 필드 추가. 컴북스 코드표 미확보이므로 39건 전부 null 이며
  null 을 출간 허용으로 해석하지 않는다. enum 과 대표값 선정은 코드표 수령 후.
- request_id / taxonomy_version 을 응답에 싣는다. 관리자 확정 로그와 연결된다.
- engine_version 기본값을 2.2.1-cases-v1.3 으로 맞춘다. 직전 값(2.0.1)이 King
  운영값 2.2.0-persistent-cpu 보다 낮아 성적서 대조 시 뒤집혀 보였다.

케이스 정의는 39건(A 27건)을 유지한다. 회의 자료의 40건(A 28건)과 1건 차이가
있으나 아카이빙 DB v2.3 원본을 받기 전까지 추측해 채우지 않는다.

7,786편 운영 재검사는 모집단 불일치(현재 6,343건)로 중단했고 부분 실행은 집계하지
않는다. 별도 평가셋 재측정은 기존 testset_v2 수치(precision 98.4032%)를 그대로
재현했으며 새 독립 시험 결과가 아니다. 상세는 reports/CASE_MATCHING_EVAL_*.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:16:33 +09:00

503 lines
19 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, Field, model_serializer
# 법령 기반 10종 메타 태그 (PDF IV장)
LegalTag = Literal[
"reproduction", # 복제권
"public_transmission", # 공중송신권
"distribution", # 배포권
"derivative_work", # 2차적저작물작성권
"publication", # 공표권
"attribution", # 성명표시권
"integrity", # 동일성유지권
"citation_missing", # 인용 표시 누락
"false_authorship", # 자기 창작인 양 표시
"substandard_derivative", # 2차적저작물 미달 가공
]
TAG_LABEL_KO: dict[str, str] = {
"reproduction": "복제권",
"public_transmission": "공중송신권",
"distribution": "배포권",
"derivative_work": "2차적저작물작성권",
"publication": "공표권",
"attribution": "성명표시권",
"integrity": "동일성유지권",
"citation_missing": "인용 표시 누락",
"false_authorship": "자기 창작인 양 표시",
"substandard_derivative": "2차적저작물 미달 가공",
}
# 후방 호환용
InfringementType = Literal[
"copy", "transform", "plot", "character", "background", "unknown",
]
class DocumentMetadata(BaseModel):
title: str | None = None
author: str | None = None
genre: str | None = None
publisher: str | None = None
publication_year: int | None = None
class DetectOptions(BaseModel):
audience: Literal["admin", "author"] = "admin"
return_evidence: bool = True
threshold: float | None = Field(default=None, ge=0.0, le=1.0,
description="None이면 서버 설정 사용. PDF VII-4 권장 0.85")
top_k: int = Field(default=5, ge=1, le=50)
autobiography_mode: bool | None = Field(
default=None,
description="None이면 서버 설정 사용. 명시하면 요청 단위 override.",
)
class LegalContext(BaseModel):
"""사람이 확인한 법적 사실. 제공하지 않으면 missing_factors 로 남는다 (#10).
엔진은 이 값들을 **추론하지 않는다.** 텍스트만으로는 알 수 없는 사실이므로,
검토자가 확인한 경우에만 전달받아 판례 매칭과 위험도 판단에 반영한다.
"""
work_type: str = Field(
default="literary", description="저작물 유형 (literary/musical/visual 등)"
)
access_evidence: bool | None = Field(
default=None,
description="원저작물 접근·의거 가능성이 확인되었는지. None이면 미제공.",
)
protected_expression_reviewed: bool = Field(
default=False, description="보호되는 창작적 표현인지 사람이 검토했는지",
)
rights_verified: bool = Field(
default=False, description="저작권 귀속·이용허락·인용 요건이 확인되었는지",
)
class DetectRequest(BaseModel):
doc_id: str
text: str = Field(..., min_length=1)
metadata: DocumentMetadata | None = None
options: DetectOptions = Field(default_factory=DetectOptions)
legal_context: LegalContext | None = None
class EvidenceSpan(BaseModel):
start: int
end: int
matched: str
source_start: int | None = None
source_end: int | None = None
class InfringementTag(BaseModel):
"""법령 기반 침해 태그. 주(primary) 또는 보조(secondary) 역할."""
tag: LegalTag
role: Literal["primary", "secondary"]
label_ko: str
class ScoreBreakdown(BaseModel):
text_sim: float = Field(..., ge=0.0, le=1.0)
lemma_sim: float = Field(..., ge=0.0, le=1.0)
character_sim: float = Field(..., ge=0.0, le=1.0)
motif_sim: float = Field(..., ge=0.0, le=1.0)
lsh_jaccard: float | None = Field(default=None, ge=0.0, le=1.0)
class PartialPlagiarismSignal(BaseModel):
"""군집화 기반 요소별 부분 표절 분해 (계획서 2단계 고도화).
'본문은 그대로 두고 인물만 바꾼 표절' 같은 부분 표절을 요소 단위로 수치화.
"""
cluster_id: int
verdict: Literal["near_duplicate", "element_swap_plagiarism", "weak", "none"]
signature_score: float = Field(..., ge=0.0, le=1.0)
per_element: dict[str, float] = Field(default_factory=dict)
retained_elements: list[str] = Field(default_factory=list) # 그대로 유지된 요소
changed_elements: list[str] = Field(default_factory=list) # 바꿔치기한 요소
class CaseCandidate(BaseModel):
"""판정된 침해 케이스 후보 1건 + v1.3 총괄표의 대표판례."""
publication_verdict: str | None = Field(default=None, description="컴북스 확정 코드. 원본 미확보 시 null; 출간 허용을 뜻하지 않음.")
case_id: str
title: str
handling: Literal["technical_detection", "terms_or_report"]
representative_precedents: list[str] = Field(default_factory=list)
precedents_without_source: list[str] = Field(
default_factory=list,
description="대표판례이나 운영 적재본에 없어 출처를 제시할 수 없는 사건번호.",
)
precedent_note: str | None = Field(
default=None, description="대표판례 미지정 사유 (예: '확립 판례 없음').",
)
class MatchResult(BaseModel):
publication_verdict: str | None = None
publication_verdict_status: Literal["source_pending", "review_required"] = "source_pending"
source_doc: str
source_title: str | None = None
similarity: float = Field(..., ge=0.0, le=1.0)
tags: list[InfringementTag] = Field(default_factory=list)
case_id: str | None = None
case_title: str | None = None
case_candidates: list[CaseCandidate] = Field(
default_factory=list,
description="태그 동점 케이스 전부. 원본 종류(가사·기사·위키·교재 등)로 "
"사람이 확정한다. case_id 는 이 목록의 첫 항목이다.",
)
infringement_type: InfringementType = "unknown"
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
score_breakdown: ScoreBreakdown | None = None
partial_signal: PartialPlagiarismSignal | None = None
source_document_id: str | None = None
source_segment_id: str | None = None
source_locator: str | None = None
coordinate_scope: Literal["document", "page", "paragraph", "episode", "unknown"] = "unknown"
page_number: int | None = None
paragraph_number: int | None = None
source_char_start: int | None = None
source_char_end: int | None = None
matched_coverage: float = Field(
default=0.0, ge=0.0, le=1.0,
description="이 세그먼트 일치 구간이 질의 전체 길이에서 차지하는 비율",
)
longest_span: int = Field(default=0, ge=0)
match_reasons: list[str] = Field(
default_factory=list,
description=(
"이 후보가 채택된 이유 (#9). score_threshold=결합점수가 임계 초과, "
"exact_span=연속 일치 길이 조건 충족, coverage=커버리지 조건 충족."
),
)
class ExtractedElements(BaseModel):
characters: list[str] = Field(default_factory=list)
motifs: list[str] = Field(default_factory=list)
genre: str | None = None
keywords: list[str] = Field(default_factory=list)
class LegalRiskSignal(BaseModel):
"""판례 기반 검토 보조. 법률상 침해 확정값이 아님."""
status: Literal[
"review_required", "no_registered_corpus_match", "insufficient_precedent_data"
]
risk_level: Literal["low", "medium", "high"] | None = None
similarity_evidence: str
protected_expression: Literal["reviewed", "not_reviewed"]
access_evidence: Literal["provided", "not_found", "not_provided"]
missing_factors: list[str] = Field(default_factory=list)
precedent_ids: list[str] = Field(default_factory=list)
precedent_grades: dict[str, str] = Field(
default_factory=dict,
description=(
"인용 판례의 사람 검토 등급 (A=직접 적용, B=조건부, C=참고). "
"여기에 없는 사건번호는 아직 검토되지 않은 판례다."
),
)
judgment_method: Literal["rule_based", "llm", "rule_fallback"] = "rule_based"
llm_verdict: Literal["likely", "unlikely", "insufficient_evidence"] | None = None
llm_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
llm_review_required: bool | None = None
llm_matched_precedent_ids: list[str] = Field(default_factory=list)
supporting_reasons: list[str] = Field(default_factory=list)
counter_reasons: list[str] = Field(default_factory=list)
judge_model: str | None = None
judge_prompt_version: str | None = None
judge_note: str | None = None
judgment_summary: str = ""
disclaimer: str
class ScoreSemantics(BaseModel):
"""점수와 임계값의 의미를 명시 (#9).
``confidence``/``similarity`` 는 hashing 어휘 점수와 lemma 겹침을 설정
가중치로 섞은 **검색 랭킹 점수**이며, 침해 확률도 법적 판정도 아니다.
임계값 역시 실데이터 캘리브레이션 전이라 provisional 이다.
"""
combined_score: float = Field(..., ge=0.0, le=1.0)
score_kind: str = Field(
default="lexical_lemma_blend",
description="점수 구성. 확률값이 아니며 서로 다른 코퍼스 간 비교 불가.",
)
threshold_used: float = Field(..., ge=0.0, le=1.0)
threshold_source: Literal["request_override", "server_default"]
threshold_calibrated: bool = Field(
default=False, description="실데이터 FP 분포로 캘리브레이션되었는지",
)
provisional: bool = Field(
default=True, description="True면 임계값이 잠정값이라 판정 근거로 쓸 수 없음",
)
union_coverage: float = Field(
default=0.0, ge=0.0, le=1.0,
description="정밀 비교 후보 전체의 비중복 일치 구간 / 질의 길이 (#3)",
)
covered_chars: int = Field(default=0, ge=0)
query_chars: int = Field(default=0, ge=0)
evidence_truncated: bool = Field(
default=False,
description="True면 CPU 상한으로 일부 후보는 정밀 비교하지 않음",
)
note: str = (
"검색 랭킹 점수이며 침해 확률이 아닙니다. 임계값은 캘리브레이션 전 잠정값입니다."
)
class AiSegmentSignal(BaseModel):
index: int
start: int
end: int
char_count: int
score: float | None = Field(default=None, ge=0.0, le=1.0)
suspicion_level: Literal["low", "medium", "high"] | None = None
scored: bool
note: str = ""
class AiGenerationSignal(BaseModel):
"""한국어 언어특징 기반 검토 우선순위. AI 작성 확정값이 아님."""
suspicion_level: Literal["low", "medium", "high", "unknown"] = "unknown"
score: float | None = Field(default=None, ge=0.0, le=1.0, description="검토 우선순위 점수")
available: bool = False
provenance: Literal["human", "ai", "mixed", "edited", "unknown"] = "unknown"
is_stub: bool = Field(default=False, description="True면 미검증 휴리스틱 baseline")
model_version: str = "unavailable"
feature_set_version: str | None = None
pos_available: bool = False
warnings: list[str] = Field(default_factory=list)
segments: list[AiSegmentSignal] = Field(default_factory=list)
top_contributions: list[dict] = Field(default_factory=list)
note: str = "학습된 모델이 없어 결과를 제공하지 않습니다."
class ReviewSummary(BaseModel):
"""나누구 '저작권 탭' 화면 직접 매핑용 요약.
바이칼이 별도 계산 없이 그대로 표시할 수 있도록, 오투오가 점수 변환(독창성 환산)까지
완료해 제공한다. 화면 항목 ↔ 필드 대응:
독창성 98% ↔ originality_percent
유사도 2% ↔ similarity_percent
유사 문장 0건 ↔ similar_sentence_count
대조 3.5만 건 ↔ compared_count (코퍼스 크기)
표절 의심 구간 없음 ↔ has_suspicion(false)
AI 생성 의심도 ↔ ai_suspicion_level (미학습/채점 불가는 unknown)
"""
originality_percent: int = Field(..., ge=0, le=100, description="독창성 % (100 - 유사도)")
similarity_percent: int = Field(..., ge=0, le=100, description="유사도 %")
similar_sentence_count: int = Field(..., ge=0, description="유사 문장(매칭) 건수")
compared_count: int = Field(..., ge=0, description="대조한 원본(코퍼스) 건수")
has_suspicion: bool = Field(..., description="표절 의심 구간 존재 여부")
ai_suspicion_level: Literal["low", "medium", "high", "unknown"] = Field(
..., description="AI 생성 의심도. unknown이면 학습 모델 없음/채점 불가"
)
class DetectResponse(BaseModel):
request_id: str = Field(default_factory=lambda: str(uuid4()))
taxonomy_version: str | None = None
audience: Literal["admin", "author"] = "admin"
@model_serializer(mode="wrap")
def serialize_audience(self, handler):
data = handler(self)
if self.audience == "author":
# Free-form legal/LLM prose may include both case and precedent IDs.
data.pop("legal_risk", None)
data.pop("is_infringement", None)
data["ccl_basis"] = ("확인이 필요한 부분이 있습니다." if self.matches
else "등록된 비교 자료에서 일치 구간을 찾지 못했습니다.")
for match in data.get("matches", []):
for key in ("case_id", "case_title", "case_candidates", "tags",
"infringement_type", "publication_verdict",
"publication_verdict_status"):
match.pop(key, None)
return data
doc_id: str
#: 후방호환 검출값. 항상 설정되며, 저자용 직렬화에서만 출력에서 제외된다.
#: 소비자(run_precision_eval.py, evaluate_pairs.py 등)가 bool 로 읽으므로 필수로 둔다.
is_infringement: bool
confidence: float = Field(..., ge=0.0, le=1.0)
extracted_elements: ExtractedElements
matches: list[MatchResult]
ccl_basis: str | None = None
review_summary: ReviewSummary | None = None
ai_generation: AiGenerationSignal | None = None
has_similarity_match: bool | None = None
corpus_scope_note: str | None = None
legal_risk: LegalRiskSignal | None = None
score_semantics: ScoreSemantics | None = None
autobiography_mode: bool = False
candidates_before_filter: int | None = None
engine_version: str
analyzed_at: datetime
class CopyrightScoreCard(BaseModel):
"""저작권 탭 상단 카드에 그대로 표시할 값."""
originality_percent: int = Field(..., ge=0, le=100)
similarity_percent: int = Field(..., ge=0, le=100)
compared_count: int = Field(..., ge=0)
has_suspicion: bool
description: str
class CopyrightCountItem(BaseModel):
count: int = Field(..., ge=0)
label: str
class CopyrightAiItem(BaseModel):
level: Literal["low", "medium", "high", "unknown"]
label: str
class CopyrightLegalJudgment(BaseModel):
status: Literal["suspected", "low", "review_required", "unavailable"]
label: str
summary: str
precedent_ids: list[str] = Field(default_factory=list)
class CopyrightReviewResponse(BaseModel):
"""나누구 저작권 탭 전용 경량 응답."""
doc_id: str
copyright: CopyrightScoreCard
similar_sentences: CopyrightCountItem
ai_generation_suspicion: CopyrightAiItem
legal_judgment: CopyrightLegalJudgment
analyzed_at: datetime
class BatchItem(BaseModel):
doc_id: str
text: str
metadata: DocumentMetadata | None = None
class BatchRequest(BaseModel):
items: list[BatchItem] = Field(..., min_length=1, max_length=500)
options: DetectOptions = Field(default_factory=DetectOptions)
class BatchCreatedResponse(BaseModel):
job_id: str
status: Literal["queued", "running", "completed", "failed"]
total: int
created_at: datetime
class BatchStatusResponse(BaseModel):
job_id: str
status: Literal["queued", "running", "completed", "failed"]
total: int
processed: int
created_at: datetime
finished_at: datetime | None = None
results: list[DetectResponse] | None = None
error: str | None = None
class SummaryRequest(BaseModel):
text: str = Field(..., min_length=1)
ratio: float | None = Field(default=None, gt=0.0, le=1.0, description="요약 길이 비율(지정 시 detail보다 우선)")
detail: Literal["brief", "standard", "detailed"] = Field(
default="standard", description="요약 상세도"
)
emphasis: list[str] = Field(
default_factory=list, max_length=10, description="요약에서 우선 강조할 주제·키워드"
)
max_sentences: int | None = Field(default=None, ge=1, description="최대 문장 수 (옵션)")
use_abstractive: bool = Field(default=True, description="추상적(LLM) 단계 사용 — 키 없으면 추출적 폴백")
class SummaryResponse(BaseModel):
extractive: str
abstractive: str | None = None
final: str
mode: Literal["extractive", "hybrid"]
selected_indices: list[int] = Field(default_factory=list)
num_sentences_in: int
num_sentences_out: int
detail: Literal["brief", "standard", "detailed"] = "standard"
emphasis: list[str] = Field(default_factory=list)
engine_version: str
class HealthResponse(BaseModel):
status: Literal["ok"]
engine_version: str
corpus_size: int
taxonomy_version: str | None = None
autobiography_mode: bool = False
corpus_documents: int | None = None
index_backend: str | None = None
ai_model_ready: bool | None = None
precedent_count: int | None = None
class TaxonomyResponse(BaseModel):
meta_tags_version: str
cases_version: str
meta_tags: list[dict]
cases: list[dict]
class CorpusItem(BaseModel):
doc_id: str
title: str
size_bytes: int = 0
filename: str | None = None
class CorpusListResponse(BaseModel):
total: int
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)
text: str = Field(..., min_length=1)
class CorpusUploadResponse(BaseModel):
doc_id: str
title: str
size_bytes: int
corpus_size_after: int
rebuilt: bool