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) 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 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