448 lines
16 KiB
Python
448 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
# 법령 기반 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):
|
|
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 MatchResult(BaseModel):
|
|
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
|
|
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):
|
|
doc_id: str
|
|
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 = Field(default=0.3, gt=0.0, le=1.0, 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
|
|
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
|