o2o-plagiarism-ai/app/api/schemas.py
hbyang fefb393234 저작권 탭 UI 매핑(review_summary) + AI 생성 의심도 스텁 추가
나누구 앱 저작권 탭 연동을 위해 detect 응답에 두 블록 추가:
- review_summary: 독창성%/유사도%/유사문장 건수/대조 건수/의심 유무/AI 의심도
  (독창성 환산 캘리브레이션 포함 — 무관한 글은 유사도 0~5%로 눌러 표시)
- ai_generation: AI 생성 의심도 스텁(요청마다 더미, is_stub=true).
  워터마킹+언어특징 분류 정식 구현 전까지 계약 확정용.

바이칼 연동용 API 양식서(docs/API_SPEC_BAIKAL.md) 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 13:15:07 +09:00

257 lines
8.7 KiB
Python

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 DetectRequest(BaseModel):
doc_id: str
text: str = Field(..., min_length=1)
metadata: DocumentMetadata | None = None
options: DetectOptions = Field(default_factory=DetectOptions)
class EvidenceSpan(BaseModel):
start: int
end: int
matched: str
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
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 AiGenerationSignal(BaseModel):
"""AI 생성 의심도 — 스텁(더미) 응답.
⚠️ 현재는 실제 판별 로직이 아니라 요청마다 더미 값을 반환한다. 바이칼 UI 연동
(낮음/중간/높음 배지)을 위한 API 계약 확정용. 정식 구현은 워터마킹(내부 생성물)
+ 한국어 언어특징 분류(외부 유입분)로 대체 예정이며, 그때 is_stub=false 가 된다.
"""
suspicion_level: Literal["low", "medium", "high"] = Field(
..., description="낮음/중간/높음 — UI 배지용"
)
score: float = Field(..., ge=0.0, le=1.0, description="0~1 참고 점수")
is_stub: bool = Field(default=True, description="True면 더미 응답(미구현)")
note: str = "더미 응답 — 실제 AI 생성 판별 결과가 아님"
class ReviewSummary(BaseModel):
"""나누구 '저작권 탭' 화면 직접 매핑용 요약.
바이칼이 별도 계산 없이 그대로 표시할 수 있도록, 오투오가 점수 변환(독창성 환산)까지
완료해 제공한다. 화면 항목 ↔ 필드 대응:
독창성 98% ↔ originality_percent
유사도 2% ↔ similarity_percent
유사 문장 0건 ↔ similar_sentence_count
대조 3.5만 건 ↔ compared_count (코퍼스 크기)
표절 의심 구간 없음 ↔ has_suspicion(false)
AI 생성 의심도 낮음 ↔ ai_suspicion_level (현재 더미)
"""
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"] = Field(
..., description="AI 생성 의심도(낮음/중간/높음) — 현재 더미"
)
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
autobiography_mode: bool = False
candidates_before_filter: int | None = None
engine_version: str
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
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 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