월례회의 자료(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>
503 lines
19 KiB
Python
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
|