diff --git a/.dockerignore b/.dockerignore index b22d846..de4d652 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,5 +3,8 @@ __pycache__ .venv .env .git -tests -scripts +data/input +data/runtime +data/models +*.xlsx +*.joblib diff --git a/.env.example b/.env.example index e77f43f..cc0bb23 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,28 @@ LOG_LEVEL=info RELOAD=false # 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때) ROOT_PATH= +# 설정하면 /v1/health를 제외한 API 요청에 X-API-Key가 필요합니다. +API_KEY= ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse REFERENCE_CORPUS_DIR=./data/reference TAXONOMY_DIR=./data/taxonomy AUTOBIOGRAPHY_PATTERNS_PATH=./data/autobiography/common_patterns.txt +# 3.5만 에피소드용 SQLite provenance + 증분 영속 인덱스. +# 서버에서 ingest/build 완료 후 true로 전환합니다. +USE_PERSISTENT_INDEX=false +CORPUS_DB_PATH=./data/runtime/corpus.sqlite3 +PERSISTENT_INDEX_DIR=./data/runtime/index +PERSISTENT_SIMILARITY_THRESHOLD=0.65 +PERSISTENT_MIN_EXACT_SPAN=80 +PERSISTENT_MIN_COVERAGE=0.30 +PERSISTENT_RERANK_TOP_K=20 +PRECEDENTS_PATH=./data/precedents/precedents.jsonl +AI_DETECTOR_MODEL_PATH=./data/models/ai_detector.joblib +AI_DETECTOR_ALLOW_HEURISTIC=false +AI_DETECTOR_USE_POS=true + # PDF VII-4 권장 보수적 임계값 (정밀도 우선) SIMILARITY_THRESHOLD=0.85 diff --git a/.gitignore b/.gitignore index babe90f..850c6d7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Python .venv/ +.venv-*/ venv/ __pycache__/ *.pyc @@ -31,6 +32,12 @@ data/training/*.csv data/reference/autobio-*.txt data/reference/corpus-*.txt +# 운영 원고·인덱스·학습 모델 (서버 볼륨에만 저장) +data/input/ +data/runtime/ +data/models/ +*.joblib + # 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능) # 로그 diff --git a/Dockerfile b/Dockerfile index 1accf16..8e94b13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-slim +FROM python:3.13-slim AS base WORKDIR /app @@ -18,6 +18,14 @@ RUN pip install -r requirements.txt COPY app ./app COPY data ./data +COPY scripts ./scripts + +FROM base AS test +RUN pip install "pytest>=8.0" +COPY tests ./tests +CMD ["pytest", "tests", "-q"] + +FROM base AS runtime # 코퍼스 업로드 디렉토리는 볼륨 마운트 권장 VOLUME ["/app/data/reference"] diff --git a/app/api/routes.py b/app/api/routes.py index ce0ca0b..88ff8e8 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status +from starlette.concurrency import run_in_threadpool from app.api.schemas import ( BatchCreatedResponse, @@ -49,6 +50,10 @@ async def health(request: Request) -> HealthResponse: corpus_size=det.corpus_size, taxonomy_version=taxonomy_version, autobiography_mode=settings.autobiography_mode, + corpus_documents=det.corpus_document_count, + index_backend=det.index_backend, + ai_model_ready=getattr(det, "ai_model_ready", False), + precedent_count=det.precedent_count, ) @@ -83,7 +88,8 @@ async def taxonomy(request: Request) -> TaxonomyResponse: tags=["plagiarism"], ) async def detect(req: DetectRequest, request: Request) -> DetectResponse: - return _detector(request).detect_request(req) + # 형태소/임베딩/행렬 연산으로 event loop가 막히지 않도록 worker thread에서 실행. + return await run_in_threadpool(_detector(request).detect_request, req) @router.post( @@ -166,6 +172,16 @@ def _rebuild(request: Request) -> int: return rebuild_detector(request.app) +def _persistent_add_locked(request: Request, detector, doc_id, title, text) -> str: + with request.app.state.detector_lock: + return detector.add_persistent_document(doc_id, title, text) + + +def _persistent_delete_locked(request: Request, detector, doc_id: str) -> bool: + with request.app.state.detector_lock: + return detector.delete_persistent_document(doc_id) + + @router.get( "/corpus", response_model=CorpusListResponse, @@ -173,7 +189,17 @@ def _rebuild(request: Request) -> int: ) async def corpus_list(request: Request) -> CorpusListResponse: settings = get_settings() - docs = list_documents(settings.corpus_path) + detector = _detector(request) + if detector.uses_persistent_index: + docs = [ + { + "doc_id": d["document_id"], "title": d["title"], + "size_bytes": d["characters"], "filename": d["source_path"], + } + for d in detector.list_persistent_documents() + ] + else: + docs = list_documents(settings.corpus_path) return CorpusListResponse( total=len(docs), docs=[CorpusItem(**d) for d in docs], @@ -189,6 +215,21 @@ async def corpus_list(request: Request) -> CorpusListResponse: async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse: """JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드.""" settings = get_settings() + detector = _detector(request) + if detector.uses_persistent_index: + try: + document_id = await run_in_threadpool( + _persistent_add_locked, request, detector, req.doc_id, req.title, req.text + ) + except FileExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return CorpusUploadResponse( + doc_id=document_id, title=req.title, + size_bytes=len(req.text.encode("utf-8")), + corpus_size_after=detector.corpus_size, rebuilt=False, + ) try: doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text) except FileExistsError as e: @@ -196,7 +237,7 @@ async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> Corp except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - new_size = _rebuild(request) + new_size = await run_in_threadpool(_rebuild, request) return CorpusUploadResponse( doc_id=doc.doc_id, title=doc.title, size_bytes=len(doc.text.encode("utf-8")), @@ -224,6 +265,21 @@ async def corpus_upload_file( except UnicodeDecodeError: raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.") + detector = _detector(request) + if detector.uses_persistent_index: + try: + document_id = await run_in_threadpool( + _persistent_add_locked, request, detector, doc_id, title, text + ) + except FileExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return CorpusUploadResponse( + doc_id=document_id, title=title, size_bytes=len(raw), + corpus_size_after=detector.corpus_size, rebuilt=False, + ) + try: doc = add_document(settings.corpus_path, doc_id, title, text) except FileExistsError as e: @@ -231,7 +287,7 @@ async def corpus_upload_file( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - new_size = _rebuild(request) + new_size = await run_in_threadpool(_rebuild, request) return CorpusUploadResponse( doc_id=doc.doc_id, title=doc.title, size_bytes=len(doc.text.encode("utf-8")), @@ -246,9 +302,17 @@ async def corpus_upload_file( ) async def corpus_delete(doc_id: str, request: Request) -> None: settings = get_settings() + detector = _detector(request) + if detector.uses_persistent_index: + deleted = await run_in_threadpool( + _persistent_delete_locked, request, detector, doc_id + ) + if not deleted: + raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found") + return if not delete_document(settings.corpus_path, doc_id): raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found") - _rebuild(request) + await run_in_threadpool(_rebuild, request) def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: BatchRequest) -> None: @@ -260,6 +324,7 @@ def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: text=item.text, metadata=item.metadata, options=req.options, + include_ai_segments=False, ) store.append_result(job_id, result) store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc)) diff --git a/app/api/schemas.py b/app/api/schemas.py index 1c56843..576a487 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -66,6 +66,8 @@ class EvidenceSpan(BaseModel): start: int end: int matched: str + source_start: int | None = None + source_end: int | None = None class InfringementTag(BaseModel): @@ -107,6 +109,16 @@ class MatchResult(BaseModel): 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) + longest_span: int = Field(default=0, ge=0) class ExtractedElements(BaseModel): @@ -116,19 +128,45 @@ class ExtractedElements(BaseModel): keywords: list[str] = Field(default_factory=list) -class AiGenerationSignal(BaseModel): - """AI 생성 의심도 — 스텁(더미) 응답. +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) + disclaimer: str - ⚠️ 현재는 실제 판별 로직이 아니라 요청마다 더미 값을 반환한다. 바이칼 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 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): @@ -141,15 +179,15 @@ class ReviewSummary(BaseModel): 유사 문장 0건 ↔ similar_sentence_count 대조 3.5만 건 ↔ compared_count (코퍼스 크기) 표절 의심 구간 없음 ↔ has_suspicion(false) - AI 생성 의심도 낮음 ↔ ai_suspicion_level (현재 더미) + 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"] = Field( - ..., description="AI 생성 의심도(낮음/중간/높음) — 현재 더미" + ai_suspicion_level: Literal["low", "medium", "high", "unknown"] = Field( + ..., description="AI 생성 의심도. unknown이면 학습 모델 없음/채점 불가" ) @@ -162,6 +200,9 @@ class DetectResponse(BaseModel): 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 autobiography_mode: bool = False candidates_before_filter: int | None = None engine_version: str @@ -221,6 +262,10 @@ class HealthResponse(BaseModel): 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): diff --git a/app/core/config.py b/app/core/config.py index e7dedb9..3598f78 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -13,11 +13,23 @@ class Settings(BaseSettings): log_level: str = "info" # debug / info / warning / error reload: bool = False # 개발용 자동 재시작 root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism) + api_key: str = "" # 설정 시 /v1/health 외 X-API-Key 필수 engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2" reference_corpus_dir: str = "./data/reference" taxonomy_dir: str = "./data/taxonomy" autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt" + corpus_db_path: str = "./data/runtime/corpus.sqlite3" + persistent_index_dir: str = "./data/runtime/index" + use_persistent_index: bool = False + persistent_similarity_threshold: float = 0.65 + persistent_min_exact_span: int = 80 + persistent_min_coverage: float = 0.30 + persistent_rerank_top_k: int = 20 + precedents_path: str = "./data/precedents/precedents.jsonl" + ai_detector_model_path: str = "./data/models/ai_detector.joblib" + ai_detector_allow_heuristic: bool = False + ai_detector_use_pos: bool = True # PDF VII-4 권장: 정밀도 우선 보수적 임계값 similarity_threshold: float = 0.85 @@ -61,6 +73,18 @@ class Settings(BaseSettings): def taxonomy_path(self) -> Path: return Path(self.taxonomy_dir).resolve() + @property + def corpus_db(self) -> Path: + return Path(self.corpus_db_path).resolve() + + @property + def persistent_index_path(self) -> Path: + return Path(self.persistent_index_dir).resolve() + + @property + def precedent_path(self) -> Path: + return Path(self.precedents_path).resolve() + @property def has_openai(self) -> bool: return bool(self.openai_api_key.strip()) diff --git a/app/engine/ai_detector.py b/app/engine/ai_detector.py new file mode 100644 index 0000000..9ec3ead --- /dev/null +++ b/app/engine/ai_detector.py @@ -0,0 +1,1065 @@ +"""한국어 AI 생성 의심도 — KatFishNet 계열 언어특징 기반 분류기. + +⚠️ 본 모듈이 산출하는 값은 **AI 생성 여부의 확정 판정이 아니라, 사람 검토를 + 우선 배정하기 위한 "의심도"** 다. 자서전 도메인은 대필·윤문·편집자 개입이 + 일상적이라 정제된 문체가 AI로 오판되기 쉽고, 학습에 쓰이지 않은 생성 모델에는 + 일반화가 잘 되지 않으며, 가벼운 수정만으로도 회피된다. 저자에게 통보되는 + 판정 근거로 쓰지 말 것. 자세한 한계는 docs/AI_DETECTION.md 참조. + +설계 원칙: + 1) **결정적** — 같은 입력이면 항상 같은 출력. 난수·해시 더미 없음. + 2) **설명 가능** — 점수의 근거가 되는 언어특징을 그대로 노출한다. 검토자가 + "왜 의심되는가"를 확인할 수 없으면 이 기능은 쓸 수 없다. + 3) **정직한 미가용** — 학습된 모델이 없으면 점수를 지어내지 않는다. + available=False 로 명시하거나, 미검증 휴리스틱임을 is_stub/model_version/ + note 로 드러낸다. + +특징 계열 (KatFishNet 계열 한국어 표지): + · 띄어쓰기 — 어절 길이 분포, 공백 비율 + · 문장/쉼표 — 문장 길이 변동계수(burstiness), 쉼표 밀도, 종결어미 분포 + · 품사 — 품사 다양도, 품사 n-gram 엔트로피/반복, 조사·어미 다양도 + · 반복 — 문자 3-gram·어절 bigram 반복률, hapax 비율 + · 길이 — 문자/어절/형태소 수 + +품사 특징은 kiwipiepy 가 있을 때만 산출된다. 미설치/오류 시에도 **특징 벡터의 +길이와 순서는 변하지 않으며**(0.0 채움 + pos_available=0.0), 모델 아티팩트의 +requires_pos 메타와 대조해 경고를 남긴다. +""" + +from __future__ import annotations + +import logging +import math +import os +import re +import unicodedata +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal, Sequence + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# 상수 +# --------------------------------------------------------------------------- + +#: 이 길이 미만이면 점수를 내지 않는다. 짧은 글은 언어특징 통계가 무의미하다. +HARD_MIN_CHARS = 120 + +#: 이 길이 미만이면 점수는 내되 신뢰도 경고를 붙인다. +MIN_RELIABLE_CHARS = 300 + +#: 세그먼트(구간) 채점 시 목표 길이. 문단을 이 길이 이상으로 병합해 채점한다. +SEGMENT_TARGET_CHARS = 600 + +#: 기본 모델 경로. config.py 는 담당 범위 밖이라 환경변수로 읽는다. +#: 통합 시 Settings.ai_detector_model_path 로 승격할 것 (docs/AI_DETECTION.md). +ENV_MODEL_PATH = "AI_DETECTOR_MODEL_PATH" +DEFAULT_MODEL_PATH = "./data/models/ai_detector.joblib" + +#: 잠정 의심도 구간. 학습 시 target FPR 기준으로 재산출되어 아티팩트에 저장되며, +#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 모델이 없을 때의 자리표시자다. +DEFAULT_LOW_CUT = 0.40 +DEFAULT_HIGH_CUT = 0.70 + +Provenance = Literal["human", "ai", "mixed", "edited", "unknown"] +SuspicionLevel = Literal["low", "medium", "high"] + + +# --------------------------------------------------------------------------- +# 특징 정의 — 순서가 곧 모델 입력 벡터의 순서다. 절대 중간 삽입/삭제 금지. +# 변경이 필요하면 FEATURE_SET_VERSION 을 올리고 모델을 재학습할 것. +# --------------------------------------------------------------------------- + +FEATURE_SET_VERSION = "kf-ko-v1" + +_BASE_FEATURES: tuple[str, ...] = ( + # 길이 + "char_count", + "eojeol_count", + "sentence_count", + "paragraph_count", + # 띄어쓰기 + "space_ratio", + "mean_eojeol_len", + "std_eojeol_len", + "cv_eojeol_len", + "long_eojeol_ratio", + "short_eojeol_ratio", + # 문장 + "mean_sentence_len", + "std_sentence_len", + "cv_sentence_len", + "max_sentence_len", + "min_sentence_len", + "mean_paragraph_len", + # 쉼표·문장부호 + "comma_per_sentence", + "comma_ratio", + "punct_ratio", + "punct_diversity", + "ellipsis_ratio", + "quote_ratio", + "exclaim_question_ratio", + # 종결 + "end_da_ratio", + "end_yo_ratio", + "end_noun_ratio", + # 문자 구성 + "hangul_ratio", + "digit_ratio", + "latin_ratio", + "newline_ratio", + # 반복·다양도 + "eojeol_ttr", + "hapax_ratio", + "top1_token_share", + "top5_token_share", + "char3gram_repeat_ratio", + "word_bigram_repeat_ratio", + "distinct_word_bigram_ratio", +) + +_POS_FEATURES: tuple[str, ...] = ( + "pos_available", + "morph_count", + "mean_morphs_per_eojeol", + "content_word_ratio", + "pos_ttr", + "pos_bigram_entropy", + "pos_trigram_repeat_ratio", + "josa_ratio", + "josa_diversity", + "eomi_ratio", + "eomi_diversity", + "noun_ratio", + "verb_ratio", + "adj_ratio", + "adverb_ratio", + "conj_adverb_ratio", + "dependent_noun_ratio", +) + +FEATURE_NAMES: tuple[str, ...] = _BASE_FEATURES + _POS_FEATURES + +#: 길이 계열 특징. 문체가 아니라 '분량'을 학습할 위험이 있어 학습 시 제외할 수 +#: 있다(train_ai_detector.py --drop-length-features). human 에피소드와 AI 생성물의 +#: 길이 분포가 다르면, 모델이 문체 대신 길이만 보고 맞히는 착시가 생긴다. +LENGTH_FEATURES: tuple[str, ...] = ( + "char_count", + "eojeol_count", + "sentence_count", + "paragraph_count", + "morph_count", + "max_sentence_len", + "min_sentence_len", + "mean_paragraph_len", +) + +#: 사람이 읽을 수 있는 특징 설명 (검토 콘솔·문서용). +FEATURE_LABELS_KO: dict[str, str] = { + "cv_sentence_len": "문장 길이 변동계수(낮을수록 균일 = AI 경향)", + "cv_eojeol_len": "어절 길이 변동계수", + "comma_per_sentence": "문장당 쉼표 수", + "eojeol_ttr": "어절 다양도(TTR)", + "hapax_ratio": "1회 등장 어절 비율", + "pos_bigram_entropy": "품사 bigram 엔트로피(낮을수록 정형적)", + "pos_trigram_repeat_ratio": "품사 trigram 반복률", + "eomi_diversity": "어미 다양도", + "josa_diversity": "조사 다양도", + "char3gram_repeat_ratio": "문자 3-gram 반복률", + "word_bigram_repeat_ratio": "어절 bigram 반복률", + "end_da_ratio": "'-다' 종결 비율", + "end_yo_ratio": "'-요/-습니다' 종결 비율", +} + + +# --------------------------------------------------------------------------- +# 형태소 분석기 (선택적) +# --------------------------------------------------------------------------- + +_kiwi_instance = None +_kiwi_failed = False + + +def _get_kiwi(): + """kiwipiepy 인스턴스. 미설치/초기화 실패 시 None 을 돌려주고 폴백한다.""" + global _kiwi_instance, _kiwi_failed + if _kiwi_instance is not None: + return _kiwi_instance + if _kiwi_failed: + return None + try: + from kiwipiepy import Kiwi + + _kiwi_instance = Kiwi() + return _kiwi_instance + except Exception as exc: # 미설치, 모델 파일 손상, 메모리 부족 등 전부 포함 + logger.warning("kiwipiepy unavailable — POS features disabled: %s", exc) + _kiwi_failed = True + return None + + +def reset_kiwi_cache() -> None: + """테스트용 — 형태소 분석기 캐시 초기화.""" + global _kiwi_instance, _kiwi_failed + _kiwi_instance = None + _kiwi_failed = False + + +# --------------------------------------------------------------------------- +# 텍스트 분해 +# --------------------------------------------------------------------------- + +_SENT_SPLIT = re.compile(r"(?<=[.!?。…])\s+|\n{1,}") +_PARA_SPLIT = re.compile(r"\n\s*\n") +_HANGUL = re.compile(r"[가-힣]") +_LATIN = re.compile(r"[A-Za-z]") +_DIGIT = re.compile(r"[0-9]") +_PUNCT = re.compile(r"[.,!?;:…·\"'“”‘’()\[\]{}—\-~/]") + + +def normalize_text(text: str) -> str: + """비교 가능한 형태로 정규화. 원문 손실을 최소화한다(공백만 정돈).""" + if not text: + return "" + t = unicodedata.normalize("NFKC", text) + t = t.replace("\r\n", "\n").replace("\r", "\n") + t = re.sub(r"[ \t  ]+", " ", t) + t = re.sub(r"\n{3,}", "\n\n", t) + return t.strip() + + +def split_sentences(text: str) -> list[str]: + return [s.strip() for s in _SENT_SPLIT.split(text) if s and s.strip()] + + +def split_paragraphs(text: str) -> list[str]: + parts = [p.strip() for p in _PARA_SPLIT.split(text) if p and p.strip()] + if parts: + return parts + return [text.strip()] if text.strip() else [] + + +def _mean(xs: Sequence[float]) -> float: + return sum(xs) / len(xs) if xs else 0.0 + + +def _std(xs: Sequence[float]) -> float: + if len(xs) < 2: + return 0.0 + m = _mean(xs) + return math.sqrt(sum((x - m) ** 2 for x in xs) / len(xs)) + + +def _safe_div(a: float, b: float) -> float: + return a / b if b else 0.0 + + +def _entropy(counts: Sequence[int]) -> float: + total = sum(counts) + if total <= 0: + return 0.0 + h = 0.0 + for c in counts: + if c <= 0: + continue + p = c / total + h -= p * math.log(p, 2) + return h + + +def _repeat_ratio(items: Sequence) -> float: + """전체 중 '두 번 이상 등장한 항목이 차지하는 비율'.""" + if not items: + return 0.0 + cnt = Counter(items) + repeated = sum(c for c in cnt.values() if c > 1) + return repeated / len(items) + + +# --------------------------------------------------------------------------- +# 특징 추출 +# --------------------------------------------------------------------------- + +def extract_features(text: str, use_pos: bool = True) -> dict[str, float]: + """raw 한국어 텍스트 → 결정적 언어특징 사전. + + 반환 키는 항상 FEATURE_NAMES 전체를 포함한다(값이 0.0 일지언정 누락 없음). + kiwipiepy 가 없으면 품사 특징은 0.0, pos_available=0.0 이 된다. + """ + feats: dict[str, float] = {name: 0.0 for name in FEATURE_NAMES} + + norm = normalize_text(text) + if not norm: + return feats + + chars = len(norm) + eojeols = norm.split() + sentences = split_sentences(norm) + paragraphs = split_paragraphs(norm) + eojeol_lens = [len(e) for e in eojeols] + sent_lens = [len(s) for s in sentences] + + feats["char_count"] = float(chars) + feats["eojeol_count"] = float(len(eojeols)) + feats["sentence_count"] = float(len(sentences)) + feats["paragraph_count"] = float(len(paragraphs)) + + # --- 띄어쓰기 --- + feats["space_ratio"] = _safe_div(norm.count(" "), chars) + mean_e = _mean(eojeol_lens) + std_e = _std(eojeol_lens) + feats["mean_eojeol_len"] = mean_e + feats["std_eojeol_len"] = std_e + feats["cv_eojeol_len"] = _safe_div(std_e, mean_e) + feats["long_eojeol_ratio"] = _safe_div( + sum(1 for n in eojeol_lens if n >= 8), len(eojeol_lens) + ) + feats["short_eojeol_ratio"] = _safe_div( + sum(1 for n in eojeol_lens if n <= 2), len(eojeol_lens) + ) + + # --- 문장 --- + mean_s = _mean(sent_lens) + std_s = _std(sent_lens) + feats["mean_sentence_len"] = mean_s + feats["std_sentence_len"] = std_s + # burstiness 대용. AI 생성문은 문장 길이가 균일해 이 값이 낮은 경향. + feats["cv_sentence_len"] = _safe_div(std_s, mean_s) + feats["max_sentence_len"] = float(max(sent_lens)) if sent_lens else 0.0 + feats["min_sentence_len"] = float(min(sent_lens)) if sent_lens else 0.0 + feats["mean_paragraph_len"] = _mean([len(p) for p in paragraphs]) + + # --- 쉼표·문장부호 --- + commas = norm.count(",") + puncts = _PUNCT.findall(norm) + feats["comma_per_sentence"] = _safe_div(commas, len(sentences)) + feats["comma_ratio"] = _safe_div(commas, chars) + feats["punct_ratio"] = _safe_div(len(puncts), chars) + feats["punct_diversity"] = _safe_div(len(set(puncts)), len(puncts)) + feats["ellipsis_ratio"] = _safe_div( + norm.count("…") + len(re.findall(r"\.\.\.", norm)), max(1, len(sentences)) + ) + feats["quote_ratio"] = _safe_div( + len(re.findall(r"[\"“”'‘’]", norm)), chars + ) + feats["exclaim_question_ratio"] = _safe_div( + norm.count("!") + norm.count("?"), max(1, len(sentences)) + ) + + # --- 종결 형태 --- + if sentences: + da = yo = noun_end = 0 + for s in sentences: + body = s.rstrip(".!?…\"'“”’ ") + if not body: + continue + if body.endswith(("습니다", "합니다", "요")): + yo += 1 + elif body.endswith("다"): + da += 1 + elif _HANGUL.search(body[-1:]): + noun_end += 1 + feats["end_da_ratio"] = _safe_div(da, len(sentences)) + feats["end_yo_ratio"] = _safe_div(yo, len(sentences)) + feats["end_noun_ratio"] = _safe_div(noun_end, len(sentences)) + + # --- 문자 구성 --- + feats["hangul_ratio"] = _safe_div(len(_HANGUL.findall(norm)), chars) + feats["digit_ratio"] = _safe_div(len(_DIGIT.findall(norm)), chars) + feats["latin_ratio"] = _safe_div(len(_LATIN.findall(norm)), chars) + feats["newline_ratio"] = _safe_div(norm.count("\n"), chars) + + # --- 반복·다양도 --- + if eojeols: + cnt = Counter(eojeols) + feats["eojeol_ttr"] = _safe_div(len(cnt), len(eojeols)) + feats["hapax_ratio"] = _safe_div( + sum(1 for c in cnt.values() if c == 1), len(cnt) + ) + ordered = cnt.most_common(5) + feats["top1_token_share"] = _safe_div(ordered[0][1], len(eojeols)) + feats["top5_token_share"] = _safe_div( + sum(c for _, c in ordered), len(eojeols) + ) + bigrams = [f"{a}␟{b}" for a, b in zip(eojeols, eojeols[1:])] + feats["word_bigram_repeat_ratio"] = _repeat_ratio(bigrams) + feats["distinct_word_bigram_ratio"] = _safe_div( + len(set(bigrams)), len(bigrams) + ) + + compact = re.sub(r"\s+", "", norm) + if len(compact) >= 3: + trigrams = [compact[i : i + 3] for i in range(len(compact) - 2)] + feats["char3gram_repeat_ratio"] = _repeat_ratio(trigrams) + + # --- 품사 --- + if use_pos: + pos_feats = _extract_pos_features(norm, len(eojeols)) + if pos_feats: + feats.update(pos_feats) + + return feats + + +def _extract_pos_features(norm: str, eojeol_count: int) -> dict[str, float] | None: + """kiwipiepy 기반 품사 특징. 사용 불가면 None (호출자가 0.0 유지).""" + kiwi = _get_kiwi() + if kiwi is None: + return None + try: + tokens = kiwi.tokenize(norm) + except Exception as exc: + logger.warning("kiwi tokenize failed — POS features skipped: %s", exc) + return None + + if not tokens: + return None + + tags = [t.tag for t in tokens] + n = len(tags) + out: dict[str, float] = { + "pos_available": 1.0, + "morph_count": float(n), + "mean_morphs_per_eojeol": _safe_div(n, eojeol_count), + } + + tag_counts = Counter(tags) + out["pos_ttr"] = _safe_div(len(tag_counts), n) + + bigrams = [f"{a}_{b}" for a, b in zip(tags, tags[1:])] + out["pos_bigram_entropy"] = _entropy(list(Counter(bigrams).values())) + trigrams = [f"{a}_{b}_{c}" for a, b, c in zip(tags, tags[1:], tags[2:])] + out["pos_trigram_repeat_ratio"] = _repeat_ratio(trigrams) + + def _ratio(prefixes: tuple[str, ...]) -> float: + return _safe_div( + sum(c for tag, c in tag_counts.items() if tag.startswith(prefixes)), n + ) + + def _diversity(prefixes: tuple[str, ...]) -> float: + """해당 계열 안에서 서로 다른 표층형이 얼마나 다양한가.""" + forms = {t.form for t in tokens if t.tag.startswith(prefixes)} + total = sum(1 for t in tokens if t.tag.startswith(prefixes)) + return _safe_div(len(forms), total) + + content = ("NNG", "NNP", "VV", "VA", "MAG") + out["content_word_ratio"] = _ratio(content) + out["josa_ratio"] = _ratio(("JK", "JX", "JC")) + out["josa_diversity"] = _diversity(("JK", "JX", "JC")) + out["eomi_ratio"] = _ratio(("EF", "EC", "EP", "ETN", "ETM")) + out["eomi_diversity"] = _diversity(("EF", "EC", "EP", "ETN", "ETM")) + out["noun_ratio"] = _ratio(("NNG", "NNP")) + out["verb_ratio"] = _ratio(("VV",)) + out["adj_ratio"] = _ratio(("VA",)) + out["adverb_ratio"] = _ratio(("MAG",)) + out["conj_adverb_ratio"] = _ratio(("MAJ",)) + out["dependent_noun_ratio"] = _ratio(("NNB",)) + return out + + +def features_to_vector( + feats: dict[str, float], zeroed: Sequence[str] = () +) -> list[float]: + """FEATURE_NAMES 순서 그대로의 수치 벡터. 모델 입출력의 유일한 계약. + + zeroed 에 든 특징은 0.0 으로 눌러 모델이 무시하도록 한다. 학습과 추론에서 + **반드시 같은 목록**을 써야 하므로, 목록은 아티팩트에 저장되고 로드 시 + 복원된다. 벡터 길이는 어떤 경우에도 변하지 않는다. + """ + blocked = set(zeroed) + return [ + 0.0 if name in blocked else float(feats.get(name, 0.0)) + for name in FEATURE_NAMES + ] + + +# --------------------------------------------------------------------------- +# 결과 객체 +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SegmentScore: + """구간별 의심도. 문서 전체가 아니라 어느 부분이 의심되는지 보여준다.""" + + index: int + start: int + end: int + char_count: int + score: float | None + suspicion_level: SuspicionLevel | None + scored: bool + note: str = "" + preview: str = "" + + +@dataclass(frozen=True) +class AiDetectionResult: + """AI 생성 의심도 결과. + + ⚠️ score 는 'AI가 썼을 확률'이 아니라 '사람 검토 우선순위'다. 확정 판정이 + 아니며, 저자 통보·계약 조치의 단독 근거로 사용해서는 안 된다. + """ + + available: bool + score: float | None + suspicion_level: SuspicionLevel | None + provenance: Provenance + is_stub: bool + model_version: str + note: str + feature_set_version: str = FEATURE_SET_VERSION + pos_available: bool = False + char_count: int = 0 + warnings: list[str] = field(default_factory=list) + segments: list[SegmentScore] = field(default_factory=list) + features: dict[str, float] = field(default_factory=dict) + top_contributions: list[tuple[str, float]] = field(default_factory=list) + + def to_dict(self) -> dict: + """API/로그 직렬화용. schemas.py 매핑은 docs/AI_DETECTION.md 참조.""" + return { + "available": self.available, + "score": self.score, + "suspicion_level": self.suspicion_level, + "provenance": self.provenance, + "is_stub": self.is_stub, + "model_version": self.model_version, + "note": self.note, + "feature_set_version": self.feature_set_version, + "pos_available": self.pos_available, + "char_count": self.char_count, + "warnings": list(self.warnings), + "segments": [ + { + "index": s.index, + "start": s.start, + "end": s.end, + "char_count": s.char_count, + "score": s.score, + "suspicion_level": s.suspicion_level, + "scored": s.scored, + "note": s.note, + "preview": s.preview, + } + for s in self.segments + ], + "top_contributions": [ + {"feature": name, "contribution": round(val, 4)} + for name, val in self.top_contributions + ], + } + + +# --------------------------------------------------------------------------- +# 휴리스틱 baseline (모델 없을 때, 명시적 opt-in) +# --------------------------------------------------------------------------- + +#: 미검증 참조 구간. 79권 인간 저작 코퍼스로 캘리브레이션하기 전까지는 +#: 문헌상 경향을 반영한 자리표시자일 뿐이다. 절대 판정 근거가 아니다. +#: (feature, human_typical, ai_typical) — ai 쪽에 가까울수록 가점. +_HEURISTIC_RULES: tuple[tuple[str, float, float], ...] = ( + ("cv_sentence_len", 0.75, 0.35), # AI: 문장 길이 균일 + ("cv_eojeol_len", 0.62, 0.45), # AI: 어절 길이도 균일 + ("hapax_ratio", 0.78, 0.60), # AI: 어휘 재사용 잦음 + ("comma_per_sentence", 0.60, 1.40), # AI: 쉼표 과다 사용 + ("char3gram_repeat_ratio", 0.28, 0.42), # AI: 표현 반복 + ("word_bigram_repeat_ratio", 0.05, 0.14), + ("mean_sentence_len", 38.0, 58.0), # AI: 만연체 경향 +) + + +def _heuristic_score(feats: dict[str, float]) -> float: + """규칙 기반 baseline 점수 (0~1). 결정적이며 근거를 추적할 수 있다. + + 각 규칙을 human_typical↔ai_typical 사이의 선형 위치로 환산해 평균한다. + 학습 모델이 준비되면 즉시 대체될 임시 계층이다. + """ + positions: list[float] = [] + for name, human_val, ai_val in _HEURISTIC_RULES: + val = feats.get(name) + if val is None: + continue + span = ai_val - human_val + if span == 0: + continue + pos = (val - human_val) / span + positions.append(max(0.0, min(1.0, pos))) + if not positions: + return 0.0 + return max(0.0, min(1.0, sum(positions) / len(positions))) + + +def _heuristic_contributions(feats: dict[str, float]) -> list[tuple[str, float]]: + """휴리스틱에서 어느 특징이 점수를 끌어올렸는지.""" + out: list[tuple[str, float]] = [] + for name, human_val, ai_val in _HEURISTIC_RULES: + val = feats.get(name) + if val is None: + continue + span = ai_val - human_val + if span == 0: + continue + pos = max(0.0, min(1.0, (val - human_val) / span)) + out.append((name, pos - 0.5)) + out.sort(key=lambda kv: abs(kv[1]), reverse=True) + return out[:6] + + +# --------------------------------------------------------------------------- +# 모델 아티팩트 +# --------------------------------------------------------------------------- + +@dataclass +class ModelArtifact: + """학습 산출물 + 재현에 필요한 메타데이터.""" + + estimator: object + feature_names: tuple[str, ...] + feature_set_version: str + model_version: str + requires_pos: bool + low_cut: float + high_cut: float + metrics: dict + zeroed_features: tuple[str, ...] = () + trained_at: str = "" + sklearn_version: str = "" + notes: str = "" + + +def load_artifact(path: str | Path) -> ModelArtifact | None: + """joblib 아티팩트 로드. 실패는 예외가 아니라 None (서비스는 계속 떠야 한다).""" + p = Path(path) + if not p.exists(): + logger.info("AI detector model not found at %s — running unavailable", p) + return None + try: + import joblib + except Exception as exc: + logger.warning("joblib unavailable — cannot load AI detector model: %s", exc) + return None + + try: + payload = joblib.load(p) + except Exception as exc: + logger.error("Failed to load AI detector artifact %s: %s", p, exc) + return None + + if not isinstance(payload, dict) or "estimator" not in payload: + logger.error("Malformed AI detector artifact at %s (missing 'estimator')", p) + return None + + names = tuple(payload.get("feature_names") or ()) + if names and names != FEATURE_NAMES: + logger.error( + "AI detector artifact feature mismatch (artifact=%d, code=%d). " + "Retrain required — refusing to load.", + len(names), + len(FEATURE_NAMES), + ) + return None + + return ModelArtifact( + estimator=payload["estimator"], + feature_names=names or FEATURE_NAMES, + feature_set_version=payload.get("feature_set_version", "unknown"), + model_version=payload.get("model_version", "unknown"), + requires_pos=bool(payload.get("requires_pos", False)), + low_cut=float(payload.get("low_cut", DEFAULT_LOW_CUT)), + high_cut=float(payload.get("high_cut", DEFAULT_HIGH_CUT)), + metrics=payload.get("metrics", {}), + zeroed_features=tuple(payload.get("zeroed_features") or ()), + trained_at=payload.get("trained_at", ""), + sklearn_version=payload.get("sklearn_version", ""), + notes=payload.get("notes", ""), + ) + + +# --------------------------------------------------------------------------- +# 탐지기 +# --------------------------------------------------------------------------- + +class AiGenerationDetector: + """언어특징 기반 AI 생성 의심도 산출기. + + 동작 모드 (결과의 is_stub/model_version/note 로 항상 구분 가능): + · trained — 학습 아티팩트 로드 성공. is_stub=False. + · heuristic — 아티팩트 없음 + allow_heuristic=True. is_stub=True, + model_version="heuristic-baseline-v1". + · unavailable— 아티팩트 없음 + allow_heuristic=False(기본). available=False, + score=None. + """ + + HEURISTIC_VERSION = "heuristic-baseline-v1" + + def __init__( + self, + model_path: str | Path | None = None, + allow_heuristic: bool = False, + use_pos: bool = True, + ): + self.model_path = str( + model_path or os.environ.get(ENV_MODEL_PATH) or DEFAULT_MODEL_PATH + ) + self.allow_heuristic = allow_heuristic + self.use_pos = use_pos + self.artifact = load_artifact(self.model_path) + + # -- 모드 -------------------------------------------------------------- + + @property + def mode(self) -> Literal["trained", "heuristic", "unavailable"]: + if self.artifact is not None: + return "trained" + return "heuristic" if self.allow_heuristic else "unavailable" + + @property + def model_version(self) -> str: + if self.artifact is not None: + return self.artifact.model_version + return self.HEURISTIC_VERSION if self.allow_heuristic else "unavailable" + + def _cuts(self) -> tuple[float, float]: + if self.artifact is not None: + return self.artifact.low_cut, self.artifact.high_cut + return DEFAULT_LOW_CUT, DEFAULT_HIGH_CUT + + def _level(self, score: float) -> SuspicionLevel: + low, high = self._cuts() + if score < low: + return "low" + if score < high: + return "medium" + return "high" + + # -- 점수 -------------------------------------------------------------- + + def _score_features(self, feats: dict[str, float]) -> float | None: + """특징 → 0~1 점수. 모드에 따라 학습 모델 또는 휴리스틱.""" + if self.artifact is not None: + vec = features_to_vector(feats, self.artifact.zeroed_features) + try: + proba = self.artifact.estimator.predict_proba([vec]) + return float(proba[0][1]) + except Exception as exc: + logger.error("AI detector inference failed: %s", exc) + return None + if self.allow_heuristic: + return _heuristic_score(feats) + return None + + def _contributions(self, feats: dict[str, float]) -> list[tuple[str, float]]: + """설명용 상위 기여 특징. + + 선형 모델이면 coef × 표준화값을, 그 외에는 휴리스틱 규칙 위치를 쓴다. + 기여도를 못 뽑는 모델(HGB 등)이면 빈 리스트 — 지어내지 않는다. + """ + if self.artifact is None: + return _heuristic_contributions(feats) if self.allow_heuristic else [] + est = self.artifact.estimator + try: + coefs, scaler = _linear_parts(est) + if coefs is None: + return [] + vec = features_to_vector(feats, self.artifact.zeroed_features) + if scaler is not None: + mean = getattr(scaler, "mean_", None) + scale = getattr(scaler, "scale_", None) + if mean is not None and scale is not None: + vec = [ + (v - float(m)) / (float(s) if s else 1.0) + for v, m, s in zip(vec, mean, scale) + ] + pairs = [ + (name, float(c) * float(v)) + for name, c, v in zip(FEATURE_NAMES, coefs, vec) + ] + pairs.sort(key=lambda kv: abs(kv[1]), reverse=True) + return pairs[:6] + except Exception as exc: + logger.debug("Contribution extraction skipped: %s", exc) + return [] + + # -- 공개 API ---------------------------------------------------------- + + def detect(self, text: str, with_segments: bool = True) -> AiDetectionResult: + """문서 1건의 AI 생성 의심도. + + 짧은 텍스트는 통계가 불안정하므로 HARD_MIN_CHARS 미만이면 채점을 + 거부하고, MIN_RELIABLE_CHARS 미만이면 경고를 붙인다. + """ + norm = normalize_text(text) + char_count = len(norm) + warnings: list[str] = [] + + if self.mode == "unavailable": + return AiDetectionResult( + available=False, + score=None, + suspicion_level=None, + provenance="unknown", + is_stub=False, + model_version="unavailable", + note=( + "AI 생성 판별 모델이 학습되지 않아 점수를 산출하지 않습니다. " + "scripts/train_ai_detector.py 로 학습 후 " + f"{ENV_MODEL_PATH} 를 지정하세요." + ), + char_count=char_count, + warnings=["model_not_trained"], + ) + + if char_count < HARD_MIN_CHARS: + return AiDetectionResult( + available=False, + score=None, + suspicion_level=None, + provenance="unknown", + is_stub=self.artifact is None, + model_version=self.model_version, + note=( + f"텍스트가 너무 짧아({char_count}자) 언어특징 통계가 " + f"무의미합니다. 최소 {HARD_MIN_CHARS}자 필요." + ), + char_count=char_count, + warnings=["text_too_short"], + ) + + if char_count < MIN_RELIABLE_CHARS: + warnings.append("short_text_low_confidence") + + feats = extract_features(norm, use_pos=self.use_pos) + pos_ok = feats.get("pos_available", 0.0) >= 1.0 + if not pos_ok: + warnings.append("pos_features_unavailable") + if self.artifact is not None and self.artifact.requires_pos and not pos_ok: + warnings.append("pos_required_by_model_but_missing") + logger.warning( + "Model %s was trained with POS features but kiwipiepy is " + "unavailable — score reliability degraded.", + self.artifact.model_version, + ) + + score = self._score_features(feats) + if score is None: + return AiDetectionResult( + available=False, + score=None, + suspicion_level=None, + provenance="unknown", + is_stub=self.artifact is None, + model_version=self.model_version, + note="점수 산출에 실패했습니다(추론 오류). 로그를 확인하세요.", + pos_available=pos_ok, + char_count=char_count, + warnings=warnings + ["inference_failed"], + features=feats, + ) + + segments = self._score_segments(norm) if with_segments else [] + provenance = self._infer_provenance(score, segments) + + if self.artifact is None: + note = ( + "⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델이 아니며 " + "판정 근거로 사용할 수 없습니다. 검토 우선순위 참고용." + ) + else: + note = ( + "AI 생성 '의심도'이며 확정 판정이 아닙니다. 대필·윤문된 원고는 " + "높게 나올 수 있으므로 반드시 사람 검토를 거치세요." + ) + + return AiDetectionResult( + available=True, + score=round(score, 4), + suspicion_level=self._level(score), + provenance=provenance, + is_stub=self.artifact is None, + model_version=self.model_version, + note=note, + pos_available=pos_ok, + char_count=char_count, + warnings=warnings, + segments=segments, + features=feats, + top_contributions=self._contributions(feats), + ) + + # -- 구간 채점 --------------------------------------------------------- + + def _build_segments(self, norm: str) -> list[tuple[int, int, str]]: + """문단을 SEGMENT_TARGET_CHARS 이상으로 병합. (start, end, text) 반환.""" + segments: list[tuple[int, int, str]] = [] + cursor = 0 + buf_start: int | None = None + buf: list[str] = [] + buf_len = 0 + + for para in split_paragraphs(norm): + idx = norm.find(para, cursor) + if idx < 0: + idx = cursor + cursor = idx + len(para) + if buf_start is None: + buf_start = idx + buf.append(para) + buf_len += len(para) + if buf_len >= SEGMENT_TARGET_CHARS: + segments.append((buf_start, cursor, "\n\n".join(buf))) + buf, buf_len, buf_start = [], 0, None + + if buf and buf_start is not None: + if segments and buf_len < HARD_MIN_CHARS: + # 마지막 자투리는 직전 구간에 흡수 (단독 채점 불가 길이) + s, _, prev = segments[-1] + segments[-1] = (s, cursor, prev + "\n\n" + "\n\n".join(buf)) + else: + segments.append((buf_start, cursor, "\n\n".join(buf))) + return segments + + def _score_segments(self, norm: str) -> list[SegmentScore]: + out: list[SegmentScore] = [] + for i, (start, end, seg_text) in enumerate(self._build_segments(norm)): + n = len(seg_text) + preview = seg_text[:60].replace("\n", " ") + if n < HARD_MIN_CHARS: + out.append( + SegmentScore( + index=i, start=start, end=end, char_count=n, + score=None, suspicion_level=None, scored=False, + note=f"{HARD_MIN_CHARS}자 미만 — 채점 제외", + preview=preview, + ) + ) + continue + feats = extract_features(seg_text, use_pos=self.use_pos) + s = self._score_features(feats) + if s is None: + out.append( + SegmentScore( + index=i, start=start, end=end, char_count=n, + score=None, suspicion_level=None, scored=False, + note="추론 실패", preview=preview, + ) + ) + continue + out.append( + SegmentScore( + index=i, start=start, end=end, char_count=n, + score=round(s, 4), suspicion_level=self._level(s), + scored=True, + note="" if n >= MIN_RELIABLE_CHARS else "짧은 구간 — 신뢰도 낮음", + preview=preview, + ) + ) + return out + + # -- provenance -------------------------------------------------------- + + def _infer_provenance( + self, doc_score: float, segments: list[SegmentScore] + ) -> Provenance: + """작성 경로 **추정**. 확정이 아니며 검토자 참고용이다. + + 규칙 (docs/AI_DETECTION.md 와 동일하게 유지할 것): + human — 문서·구간 모두 low + ai — 문서 high 이고 low 구간이 없음 + mixed — high 구간과 low 구간이 공존 (부분 삽입 의심) + edited — 전 구간이 medium 에 몰림 (AI 초안 + 사람 윤문, 또는 그 역) + unknown— 채점된 구간이 없어 판단 불가 + """ + low, high = self._cuts() + scored = [s for s in segments if s.scored and s.score is not None] + + if not scored: + if doc_score >= high: + return "ai" + if doc_score < low: + return "human" + return "edited" + + levels = [s.suspicion_level for s in scored] + has_high = "high" in levels + has_low = "low" in levels + + if has_high and has_low: + return "mixed" + if doc_score >= high and not has_low: + return "ai" + if doc_score < low and not has_high: + return "human" + if all(lv == "medium" for lv in levels): + return "edited" + return "mixed" if has_high else "edited" + + +# --------------------------------------------------------------------------- +# 싱글턴 접근자 +# --------------------------------------------------------------------------- + +_detector_cache: dict[tuple, AiGenerationDetector] = {} + + +def get_ai_detector( + model_path: str | Path | None = None, + allow_heuristic: bool | None = None, + use_pos: bool = True, +) -> AiGenerationDetector: + """탐지기 인스턴스(프로세스 캐시). + + allow_heuristic 을 생략하면 환경변수 AI_DETECTOR_ALLOW_HEURISTIC 을 따르고, + 그것도 없으면 False(= 모델 없으면 unavailable) 다. 기본값을 False 로 두는 + 이유는, 미검증 점수가 조용히 운영에 노출되는 상황을 막기 위해서다. + """ + if allow_heuristic is None: + allow_heuristic = os.environ.get( + "AI_DETECTOR_ALLOW_HEURISTIC", "" + ).strip().lower() in {"1", "true", "yes"} + key = (str(model_path or ""), bool(allow_heuristic), bool(use_pos)) + if key not in _detector_cache: + _detector_cache[key] = AiGenerationDetector( + model_path=model_path, allow_heuristic=allow_heuristic, use_pos=use_pos + ) + return _detector_cache[key] + + +def reset_detector_cache() -> None: + """테스트/재로딩용.""" + _detector_cache.clear() + + +def _linear_parts(estimator) -> tuple[Sequence[float] | None, object | None]: + """추정기에서 (선형 계수, 스케일러) 추출. 없으면 (None, None). + + Pipeline / CalibratedClassifierCV 를 한 겹씩 벗겨 본다. + """ + scaler = None + est = estimator + + steps = getattr(est, "steps", None) + if steps: + for _, step in steps: + if hasattr(step, "mean_") and hasattr(step, "scale_"): + scaler = step + est = steps[-1][1] + + calibrated = getattr(est, "calibrated_classifiers_", None) + if calibrated: + inner = getattr(calibrated[0], "estimator", None) + if inner is not None: + inner_steps = getattr(inner, "steps", None) + if inner_steps: + for _, step in inner_steps: + if hasattr(step, "mean_") and hasattr(step, "scale_"): + scaler = step + inner = inner_steps[-1][1] + est = inner + + coef = getattr(est, "coef_", None) + if coef is None: + return None, scaler + return list(coef[0]), scaler diff --git a/app/engine/detector.py b/app/engine/detector.py index 51afd32..4b34c31 100644 --- a/app/engine/detector.py +++ b/app/engine/detector.py @@ -25,6 +25,7 @@ from app.api.schemas import ( DocumentMetadata, InfringementTag, InfringementType, + LegalRiskSignal, MatchResult, PartialPlagiarismSignal, ReviewSummary, @@ -32,10 +33,15 @@ from app.api.schemas import ( ) from app.core.config import Settings, get_settings from app.engine.autobiography_filter import preprocess_for_autobiography +from app.engine.ai_detector import get_ai_detector from app.engine.clustering import ClusterIndex from app.engine.corpus import load_corpus from app.engine.extractor import Extractor, get_extractor from app.engine.lsh_filter import LshIndex +from app.engine.legal_risk import LegalRiskEngine, load_precedents +from app.engine.persistent_index import PersistentCorpusIndex, PersistentHit +from app.engine.provenance import DocumentRecord, SegmentRecord, stable_id +from app.engine.source_extraction import chunk_with_offsets from app.engine.similarity import ( DualSimilarityIndex, SimilarityHit, @@ -52,6 +58,34 @@ class PlagiarismDetector: self.settings = settings or get_settings() self._extractor: Extractor = extractor or get_extractor(self.settings) self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path) + self._legal_engine = LegalRiskEngine(load_precedents(self.settings.precedent_path)) + self._ai_detector = get_ai_detector( + self.settings.ai_detector_model_path, + allow_heuristic=self.settings.ai_detector_allow_heuristic, + use_pos=self.settings.ai_detector_use_pos, + ) + self._persistent: PersistentCorpusIndex | None = None + + if self.settings.use_persistent_index: + candidate = PersistentCorpusIndex( + self.settings.corpus_db, self.settings.persistent_index_path, + ) + if candidate.ready: + self._persistent = candidate.load() + self._corpus = [] + self._corpus_preprocessed_texts = [] + self._corpus_elements = [] + self._corpus_lemmas = [] + self._index = None + self._lsh = None + self._cluster = None + self._docs_by_id = {} + logger.info("Loaded persistent index: %d segments", self._persistent.size) + return + logger.warning( + "USE_PERSISTENT_INDEX=true but index is absent; falling back to text corpus: %s", + self.settings.persistent_index_path, + ) self._corpus = load_corpus(self.settings.corpus_path) @@ -106,17 +140,47 @@ class PlagiarismDetector: @property def corpus_size(self) -> int: + return self._persistent.size if self._persistent else len(self._corpus) + + @property + def corpus_document_count(self) -> int: + if self._persistent: + return self._persistent.document_count return len(self._corpus) + @property + def index_backend(self) -> str: + return "persistent-hashing-char-3-4" if self._persistent else "legacy-in-memory" + + @property + def precedent_count(self) -> int: + return len(self._legal_engine.precedents) + + @property + def ai_model_ready(self) -> bool: + return self._ai_detector.mode == "trained" + + @property + def uses_persistent_index(self) -> bool: + return self._persistent is not None + + def list_persistent_documents(self) -> list[dict]: + return self._persistent.store.list_documents() if self._persistent else [] + def detect( self, doc_id: str, text: str, metadata: DocumentMetadata | None = None, options: DetectOptions | None = None, + include_ai_segments: bool = True, ) -> DetectResponse: opts = options or DetectOptions() - threshold = opts.threshold if opts.threshold is not None else self.settings.similarity_threshold + default_threshold = ( + self.settings.persistent_similarity_threshold + if self._persistent else self.settings.similarity_threshold + ) + threshold = opts.threshold if opts.threshold is not None else default_threshold # 요청 단위 자서전 모드 override autobio_mode = ( @@ -136,37 +200,101 @@ class PlagiarismDetector: # 요소 추출 (원본 텍스트 기준 — 사용자 검토용) elements = self._extractor.extract(text) - # 1차 LSH 필터 (옵션) + # 영속 CPU 인덱스: 전량 행렬곱으로 후보를 구하고 상위 후보만 증거 비교한다. + persistent_hits: list[PersistentHit] = [] + if self._persistent: + persistent_query_lemmas = extract_lemmas(text) + persistent_hits = self._persistent.query( + text, + top_k=max(opts.top_k, self.settings.persistent_rerank_top_k), + ) + hits = [ + self._persistent_to_similarity_hit(h, persistent_query_lemmas, elements) + for h in persistent_hits + ] + hits.sort(key=lambda h: h.score, reverse=True) + candidates_count = len(hits) + lsh_jaccards: dict[str, float] = {} + else: + hits = [] + candidates_count = None + lsh_jaccards = {} + + # 1차 LSH 필터 (레거시 옵션) candidate_ids: set[str] | None = None - candidates_count: int | None = None - lsh_jaccards: dict[str, float] = {} - if self._lsh: + if not self._persistent and self._lsh: cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k) candidate_ids = {c.doc_id for c in cands} candidates_count = len(cands) lsh_jaccards = {c.doc_id: c.jaccard for c in cands} # 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔) - hits = self._index.query(query_text, elements, top_k=opts.top_k) - if candidate_ids is not None: - hits = [h for h in hits if h.doc_id in candidate_ids] + if not self._persistent: + hits = self._index.query(query_text, elements, top_k=opts.top_k) + if candidate_ids is not None: + hits = [h for h in hits if h.doc_id in candidate_ids] # 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준) query_lemmas = extract_lemmas(query_text) if self._cluster else None - matches = [ - self._to_match( + persistent_by_id = {h.segment_id: h for h in persistent_hits} + matches = [] + for h in hits: + provenance_hit = persistent_by_id.get(h.doc_id) + exact_partial_match = bool( + provenance_hit and ( + provenance_hit.longest_span >= self.settings.persistent_min_exact_span + or provenance_hit.coverage >= self.settings.persistent_min_coverage + ) + ) + if (h.score < threshold and not exact_partial_match) or len(matches) >= opts.top_k: + continue + match = self._to_match( h, opts.return_evidence, lsh_jaccards.get(h.doc_id), self._partial_signal(h.doc_id, elements, query_lemmas), ) - for h in hits if h.score >= threshold - ] + if provenance_hit: + match = self._add_provenance(match, provenance_hit) + matches.append(match) confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0) - is_infringement = bool(matches) + is_infringement = bool(matches) # 후방호환 필드. 법적 확정이 아니라 임계 초과 매칭. ccl_basis = self._build_ccl_basis(matches) if is_infringement else None - # 저작권 탭 UI 매핑 (나누구 앱). 독창성=100-유사도, AI 의심도는 현재 스텁. - ai_signal = _dummy_ai_generation_signal(text) + top_coverage = max((m.matched_coverage for m in matches), default=0.0) + top_longest = max((m.longest_span for m in matches), default=0) + legal_tags = [t.tag for m in matches for t in m.tags] + legal = self._legal_engine.assess( + max_similarity=matches[0].similarity if matches else 0.0, + coverage=top_coverage, + longest_span=top_longest, + legal_tags=legal_tags, + work_type="literary", + ) + + # AI 탐지는 전처리 전 raw text에서만 실행한다. + ai_result = self._ai_detector.detect(text, with_segments=include_ai_segments) + ai_signal = AiGenerationSignal( + suspicion_level=ai_result.suspicion_level or "unknown", + score=ai_result.score, + available=ai_result.available, + provenance=ai_result.provenance, + is_stub=ai_result.is_stub, + model_version=ai_result.model_version, + feature_set_version=ai_result.feature_set_version, + pos_available=ai_result.pos_available, + warnings=ai_result.warnings, + segments=[{ + "index": s.index, "start": s.start, "end": s.end, + "char_count": s.char_count, "score": s.score, + "suspicion_level": s.suspicion_level, "scored": s.scored, + "note": s.note, + } for s in ai_result.segments], + top_contributions=[ + {"feature": name, "contribution": round(value, 4)} + for name, value in ai_result.top_contributions + ], + note=ai_result.note, + ) sim_pct = _calibrate_similarity(confidence, threshold) review = ReviewSummary( originality_percent=100 - sim_pct, @@ -186,6 +314,21 @@ class PlagiarismDetector: ccl_basis=ccl_basis, review_summary=review, ai_generation=ai_signal, + has_similarity_match=bool(matches), + corpus_scope_note=( + f"현재 등록된 {self.corpus_document_count}개 원천 문서의 " + f"{self.corpus_size}개 검색 세그먼트만 대조했습니다. 미매칭은 비침해 확정이 아닙니다." + ), + legal_risk=LegalRiskSignal( + status=legal.status, + risk_level=legal.risk_level, + similarity_evidence=legal.similarity_evidence, + protected_expression=legal.protected_expression, + access_evidence=legal.access_evidence, + missing_factors=list(legal.missing_factors), + precedent_ids=list(legal.precedent_ids), + disclaimer=legal.disclaimer, + ), autobiography_mode=autobio_mode, candidates_before_filter=candidates_count, engine_version=self.settings.engine_version, @@ -195,6 +338,87 @@ class PlagiarismDetector: def detect_request(self, req: DetectRequest) -> DetectResponse: return self.detect(req.doc_id, req.text, req.metadata, req.options) + def _persistent_to_similarity_hit( + self, hit: PersistentHit, query_lemmas: list[str], query_elements, + ) -> SimilarityHit: + from app.api.schemas import EvidenceSpan + from app.engine.similarity import _element_similarities + from app.engine.structural import lemma_overlap_ratio + + reference_elements = self._extractor.extract(hit.reference_text) + element_sim = _element_similarities(query_elements, reference_elements) + lemma_sim = lemma_overlap_ratio(query_lemmas, extract_lemmas(hit.reference_text)) + s = self.settings + combined = ( + s.weight_text_sim * hit.score + + s.weight_lemma_sim * lemma_sim + + s.weight_char_sim * element_sim["characters"] + + s.weight_motif_sim * element_sim["motifs"] + ) + + return SimilarityHit( + doc_id=hit.segment_id, + title=hit.title, + score=combined, + text_sim=hit.score, + lemma_sim=lemma_sim, + element_sim=element_sim, + evidence=[EvidenceSpan(**span) for span in hit.evidence], + ) + + @staticmethod + def _add_provenance(match: MatchResult, hit: PersistentHit) -> MatchResult: + return match.model_copy(update={ + "source_document_id": hit.document_id, + "source_segment_id": hit.segment_id, + "source_locator": hit.source_locator, + "coordinate_scope": hit.coordinate_scope, + "page_number": hit.page_number, + "paragraph_number": hit.paragraph_number, + "source_char_start": hit.source_char_start, + "source_char_end": hit.source_char_end, + "matched_coverage": round(hit.coverage, 4), + "longest_span": hit.longest_span, + }) + + def add_persistent_document(self, doc_id: str | None, title: str, text: str) -> str: + if not self._persistent: + raise RuntimeError("persistent index is not enabled") + document_id = (doc_id or stable_id("doc", title)).strip() + self._persistent.store.upsert_document(DocumentRecord(document_id=document_id, title=title)) + clean_text = text.strip() + segments = [SegmentRecord( + segment_id=stable_id("seg", document_id, str(start), chunk), + document_id=document_id, text=chunk, ordinal=str(i), + coordinate_scope="document", char_start=start, char_end=end, + source_locator=f"api://corpus/{document_id}#chars={start}-{end}", + metadata={"provenance_quality": "api_document"}, + ) for i, (start, end, chunk) in enumerate( + chunk_with_offsets(clean_text, size=1000, stride=500), 1 + )] + inserted, duplicates = self._persistent.store.add_segments(segments) + if duplicates and not inserted: + raise FileExistsError(f"doc_id '{document_id}' 또는 동일 본문이 이미 존재합니다") + self._replace_persistent_index() + return document_id + + def delete_persistent_document(self, doc_id: str) -> bool: + if not self._persistent: + raise RuntimeError("persistent index is not enabled") + deleted = self._persistent.store.delete_document(doc_id) + if deleted: + self._replace_persistent_index() + return deleted + + def _replace_persistent_index(self) -> None: + """새 객체를 완성한 뒤 포인터를 한 번에 교체해 동시 질의 정합성을 보장.""" + replacement = PersistentCorpusIndex( + self.settings.corpus_db, self.settings.persistent_index_path, + ) + replacement.sync() + replacement.load() + self._persistent = replacement + def _partial_signal(self, doc_id, query_elements, query_lemmas) -> PartialPlagiarismSignal | None: """군집화 기반 요소별 부분 표절 분해 (옵션).""" if not self._cluster: @@ -308,8 +532,9 @@ class PlagiarismDetector: tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요" case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else "" return ( - f"'{top.source_title}'와 결합 유사도 {top.similarity:.2%}로 매칭. " - f"주 침해 태그: {tag_summary}.{case_part}{breakdown}" + f"'{top.source_title}'와 검색 유사도 {top.similarity:.2%}로 후보 매칭. " + f"검토 가설 태그: {tag_summary}.{case_part}{breakdown} " + "법적 침해 여부는 보호되는 표현·의거관계·권리관계를 별도 검토해야 합니다." ) @@ -330,20 +555,6 @@ def _calibrate_similarity(raw: float, threshold: float) -> int: return max(0, min(100, round(disp))) -def _dummy_ai_generation_signal(text: str) -> AiGenerationSignal: - """AI 생성 의심도 스텁 — 실제 판별이 아니라 텍스트 기반 결정적 더미 값. - - 바이칼 UI(낮음/중간/높음 배지) 연동용 계약 확정 목적. 정식 구현(워터마킹+ - 언어특징 분류) 전까지 is_stub=True 로 반환한다. - """ - h = sum(ord(c) for c in text[:300]) % 100 - if h < 70: - return AiGenerationSignal(suspicion_level="low", score=round(0.05 + h / 500, 3)) - if h < 90: - return AiGenerationSignal(suspicion_level="medium", score=round(0.45 + (h - 70) / 200, 3)) - return AiGenerationSignal(suspicion_level="high", score=round(0.72 + (h - 90) / 200, 3)) - - def _classify_legacy(hit: SimilarityHit) -> InfringementType: """후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용).""" elem = hit.element_sim diff --git a/app/engine/legal_risk.py b/app/engine/legal_risk.py new file mode 100644 index 0000000..673f361 --- /dev/null +++ b/app/engine/legal_risk.py @@ -0,0 +1,133 @@ +"""등록된 판례만 인용하는 저작권 위험도 보조 엔진. + +유사도 점수를 법적 결론으로 바꾸지 않는다. 보호되는 표현, 의거관계, 권리 귀속 +등 입력되지 않은 사실을 ``missing_factors`` 로 남기고 사람 검토를 요구한다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + + +@dataclass(frozen=True) +class Precedent: + case_id: str + title: str + source_url: str + work_types: tuple[str, ...] + legal_tags: tuple[str, ...] + criteria: tuple[str, ...] + holding_summary: str + outcome: str | None = None + + +@dataclass(frozen=True) +class LegalRiskAssessment: + status: str + risk_level: str | None + similarity_evidence: str + protected_expression: str + access_evidence: str + missing_factors: tuple[str, ...] + precedent_ids: tuple[str, ...] = field(default_factory=tuple) + disclaimer: str = ( + "이 결과는 등록 코퍼스와 판례에 기반한 검토 우선순위이며 법률상 침해 확정이 아닙니다." + ) + + +def load_precedents(path: str | Path) -> list[Precedent]: + file = Path(path) + if not file.exists(): + return [] + rows: list[Precedent] = [] + seen: set[str] = set() + for lineno, line in enumerate(file.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip(): + continue + raw = json.loads(line) + required = ("case_id", "title", "source_url", "holding_summary") + missing = [key for key in required if not str(raw.get(key, "")).strip()] + if missing: + raise ValueError(f"{file}:{lineno} 필수 필드 없음: {missing}") + case_id = str(raw["case_id"]) + if case_id in seen: + raise ValueError(f"중복 사건번호: {case_id}") + if not str(raw["source_url"]).startswith("https://"): + raise ValueError(f"검증 가능한 HTTPS 출처가 필요합니다: {case_id}") + seen.add(case_id) + rows.append(Precedent( + case_id=case_id, + title=str(raw["title"]), + source_url=str(raw["source_url"]), + work_types=tuple(raw.get("work_types", [])), + legal_tags=tuple(raw.get("legal_tags", [])), + criteria=tuple(raw.get("criteria", [])), + holding_summary=str(raw["holding_summary"]), + outcome=raw.get("outcome"), + )) + return rows + + +class LegalRiskEngine: + def __init__(self, precedents: Iterable[Precedent]): + self.precedents = list(precedents) + + def assess( + self, + *, + max_similarity: float, + coverage: float, + longest_span: int, + legal_tags: Iterable[str], + work_type: str = "literary", + access_evidence: bool | None = None, + protected_expression_reviewed: bool = False, + rights_verified: bool = False, + ) -> LegalRiskAssessment: + tags = set(legal_tags) + related = sorted([ + p for p in self.precedents + if (not p.work_types or work_type in p.work_types) + and (not p.legal_tags or tags.intersection(p.legal_tags)) + ], key=lambda p: ( + -len(tags.intersection(p.legal_tags)), + 0 if work_type in p.work_types else 1, + p.case_id, + ))[:5] + missing: list[str] = [] + if not protected_expression_reviewed: + missing.append("보호되는 창작적 표현인지에 대한 사람 검토") + if access_evidence is None: + missing.append("원저작물 접근·의거 가능성") + if not rights_verified: + missing.append("저작권 귀속·이용허락·인용 요건") + + if not self.precedents: + status, level = "insufficient_precedent_data", None + elif max_similarity <= 0 and coverage <= 0: + status, level = "no_registered_corpus_match", "low" + else: + status = "review_required" + strong_copy = coverage >= 0.30 or longest_span >= 100 + level = "high" if strong_copy and max_similarity >= 0.75 else "medium" + if not strong_copy and max_similarity < 0.60: + level = "low" + + return LegalRiskAssessment( + status=status, + risk_level=level, + similarity_evidence=( + f"검색 유사도 {max_similarity:.3f}, 질의 커버리지 {coverage:.3f}, " + f"최장 연속 일치 {longest_span}자" + ), + protected_expression=("reviewed" if protected_expression_reviewed else "not_reviewed"), + access_evidence=( + "provided" if access_evidence is True else + "not_found" if access_evidence is False else "not_provided" + ), + missing_factors=tuple(missing), + precedent_ids=tuple(p.case_id for p in related), + ) diff --git a/app/engine/persistent_index.py b/app/engine/persistent_index.py new file mode 100644 index 0000000..b8fd12f --- /dev/null +++ b/app/engine/persistent_index.py @@ -0,0 +1,267 @@ +"""CPU 친화적인 영속 후보 검색 인덱스. + +HashingVectorizer를 사용해 학습 vocabulary를 메모리에 들고 있지 않으며, sparse +행렬을 디스크에 저장한다. 기존 ID가 모두 유지된 경우 새 세그먼트만 transform하여 +append하므로 문서 한 건 추가 시 전체 임베딩/인덱스를 재계산하지 않는다. +""" + +from __future__ import annotations + +import json +import hashlib +from dataclasses import dataclass +from difflib import SequenceMatcher +from pathlib import Path + +import numpy as np + +from app.engine.provenance import CorpusStore, SegmentRecord + + +INDEX_VERSION = 1 +VECTORIZER_CONFIG = { + "analyzer": "char_wb", + "ngram_range": [3, 4], + "alternate_sign": False, + "lowercase": False, + "norm": "l2", +} + + +@dataclass(frozen=True) +class PersistentHit: + segment_id: str + document_id: str + title: str + score: float + evidence: list[dict] + coverage: float + longest_span: int + source_locator: str | None + coordinate_scope: str + page_number: int | None + paragraph_number: int | None + source_char_start: int | None + source_char_end: int | None + reference_text: str + + +def _vectorizer(n_features: int, config: dict | None = None): + from sklearn.feature_extraction.text import HashingVectorizer + + cfg = config or VECTORIZER_CONFIG + return HashingVectorizer( + analyzer=cfg["analyzer"], + ngram_range=tuple(cfg["ngram_range"]), + n_features=n_features, + alternate_sign=cfg["alternate_sign"], + lowercase=cfg["lowercase"], + norm=cfg["norm"], + dtype=np.float32, + ) + + +def _evidence_spans(query: str, reference: str, min_match: int = 12, limit: int = 10) -> tuple[list[dict], float, int]: + """원문 query 좌표의 공통 연속 구간과 coverage를 반환.""" + if not query or not reference: + return [], 0.0, 0 + blocks = SequenceMatcher(None, query, reference, autojunk=False).get_matching_blocks() + useful = [b for b in blocks if b.size >= min_match] + useful.sort(key=lambda b: (-b.size, b.a)) + selected = sorted(useful[:limit], key=lambda b: b.a) + spans = [ + { + "start": b.a, + "end": b.a + b.size, + "source_start": b.b, + "source_end": b.b + b.size, + "matched": query[b.a : b.a + b.size], + } + for b in selected + ] + covered: set[int] = set() + for b in useful: + covered.update(range(b.a, b.a + b.size)) + return spans, len(covered) / max(1, len(query)), max((b.size for b in useful), default=0) + + +class PersistentCorpusIndex: + MATRIX_FILE = "lexical.npz" + META_FILE = "index.json" + + def __init__(self, store_path: str | Path, index_dir: str | Path): + self.store = CorpusStore(store_path) + self.index_dir = Path(index_dir) + self._matrix = None + self._meta: dict = {} + + @property + def ready(self) -> bool: + meta_path = self.index_dir / self.META_FILE + if not meta_path.exists(): + return False + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + return self._matrix_path(meta).exists() + except (OSError, ValueError, json.JSONDecodeError): + return False + + @property + def size(self) -> int: + return len(self._meta.get("segment_ids", [])) + + @property + def document_count(self) -> int: + if "document_count" in self._meta: + return int(self._meta["document_count"]) + return self.store.document_count() + + def load(self) -> "PersistentCorpusIndex": + if not self.ready: + raise FileNotFoundError(f"영속 인덱스가 없습니다: {self.index_dir}") + from scipy.sparse import load_npz + + self._meta = json.loads((self.index_dir / self.META_FILE).read_text(encoding="utf-8")) + if self._meta.get("version") != INDEX_VERSION: + raise ValueError("지원하지 않는 영속 인덱스 버전") + if self._meta.get("vectorizer_config") != VECTORIZER_CONFIG: + raise ValueError("인덱스 vectorizer 설정이 현재 코드와 달라 재빌드가 필요합니다") + self._matrix = load_npz(self._matrix_path(self._meta)).tocsr() + if self._matrix.shape[0] != len(self._meta["segment_ids"]): + raise ValueError("인덱스 행과 segment_ids 개수가 다릅니다") + return self + + def _matrix_path(self, meta: dict) -> Path: + return self.index_dir / str(meta.get("matrix_file") or self.MATRIX_FILE) + + def sync(self, n_features: int = 2**20) -> dict: + """DB와 동기화. 추가만 있으면 append, 삭제/변경이면 안전하게 rebuild.""" + from scipy.sparse import load_npz, save_npz, vstack + + self.index_dir.mkdir(parents=True, exist_ok=True) + segments = list(self.store.iter_segments()) + current = {s.segment_id: s.text_sha256 for s in segments} + mode = "rebuild" + existing_ids: list[str] = [] + matrix = None + if self.ready: + old = json.loads((self.index_dir / self.META_FILE).read_text(encoding="utf-8")) + old_ids = old.get("segment_ids", []) + old_hashes = old.get("text_hashes", {}) + if ( + old.get("vectorizer_config") == VECTORIZER_CONFIG + and int(old.get("n_features", 0)) == n_features + and all(i in current and current[i] == old_hashes.get(i) for i in old_ids) + ): + existing_ids = old_ids + matrix = load_npz(self._matrix_path(old)).tocsr() + mode = "append" + + by_id = {s.segment_id: s for s in segments} + existing_set = set(existing_ids) + new_ids = [s.segment_id for s in segments if s.segment_id not in existing_set] + vectorizer = _vectorizer(n_features) + if matrix is None: + existing_ids = [] + new_ids = [s.segment_id for s in segments] + matrix = vectorizer.transform([by_id[i].text for i in new_ids]).tocsr() + elif new_ids: + delta = vectorizer.transform([by_id[i].text for i in new_ids]).tocsr() + matrix = vstack([matrix, delta], format="csr") + ids = existing_ids + new_ids + generation_payload = json.dumps( + {"ids": ids, "hashes": {i: current[i] for i in ids}, + "config": VECTORIZER_CONFIG, "features": n_features}, + sort_keys=True, + ).encode("utf-8") + generation = hashlib.sha256(generation_payload).hexdigest()[:16] + matrix_file = f"lexical-{generation}.npz" + meta = { + "version": INDEX_VERSION, + "backend": "hashing-char-3-4", + "n_features": n_features, + "vectorizer_config": VECTORIZER_CONFIG, + "segment_ids": ids, + "text_hashes": {i: current[i] for i in ids}, + "document_count": self.store.document_count(), + "matrix_file": matrix_file, + } + matrix_tmp = self.index_dir / f"{matrix_file}.tmp.npz" + save_npz(matrix_tmp, matrix, compressed=False) + matrix_tmp.replace(self.index_dir / matrix_file) + tmp = self.index_dir / f"{self.META_FILE}.tmp" + tmp.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") + tmp.replace(self.index_dir / self.META_FILE) + self._matrix, self._meta = matrix, meta + return {"mode": mode, "total": len(ids), "added": len(new_ids)} + + def query(self, text: str, top_k: int = 50, min_score: float = 0.0) -> list[PersistentHit]: + if self._matrix is None: + self.load() + if not text.strip() or self._matrix is None or self._matrix.shape[0] == 0: + return [] + vectorizer = _vectorizer( + int(self._meta["n_features"]), self._meta["vectorizer_config"] + ) + # 긴 원고 앞부분만 보거나 전체 벡터에 부분 복사가 희석되지 않도록 query도 + # 1,200자/600 stride로 나누고 각 원문 세그먼트의 최대 점수를 사용한다. + query_chunks: list[tuple[int, str]] = [] + for start in range(0, len(text), 600): + chunk = text[start : start + 1200] + if chunk.strip(): + query_chunks.append((start, chunk)) + if start + 1200 >= len(text): + break + scores = np.zeros(self._matrix.shape[0], dtype=np.float32) + best_chunk = np.zeros(self._matrix.shape[0], dtype=np.int32) + for batch_start in range(0, len(query_chunks), 32): + batch = query_chunks[batch_start : batch_start + 32] + queries = vectorizer.transform([chunk for _, chunk in batch]) + block = (self._matrix @ queries.T).toarray() + local_argmax = block.argmax(axis=1) + local_scores = block[np.arange(block.shape[0]), local_argmax] + improved = local_scores > scores + scores[improved] = local_scores[improved] + best_chunk[improved] = batch_start + local_argmax[improved] + k = min(max(1, top_k), len(scores)) + indexes = np.argpartition(scores, -k)[-k:] + indexes = indexes[np.argsort(scores[indexes])[::-1]] + ids = [self._meta["segment_ids"][int(i)] for i in indexes if scores[int(i)] >= min_score] + records = self.store.get_segments(ids) + hits: list[PersistentHit] = [] + for i in indexes: + score = float(scores[int(i)]) + if score < min_score: + continue + segment_id = self._meta["segment_ids"][int(i)] + record = records.get(segment_id) + if not record: + continue + chunk_start, chunk_text = query_chunks[int(best_chunk[int(i)])] + evidence, _, longest = _evidence_spans(chunk_text, record.text) + for span in evidence: + span["start"] += chunk_start + span["end"] += chunk_start + covered = sum(span["end"] - span["start"] for span in evidence) + coverage = min(1.0, covered / max(1, len(text))) + hits.append(self._to_hit(record, score, evidence, coverage, longest)) + return hits + + @staticmethod + def _to_hit(record: SegmentRecord, score: float, evidence: list[dict], coverage: float, longest: int) -> PersistentHit: + return PersistentHit( + segment_id=record.segment_id, + document_id=record.document_id, + title=str(record.metadata.get("document_title") or record.document_id), + score=max(0.0, min(1.0, score)), + evidence=evidence, + coverage=coverage, + longest_span=longest, + source_locator=record.source_locator, + coordinate_scope=record.coordinate_scope, + page_number=record.page_number, + paragraph_number=record.paragraph_number, + source_char_start=record.char_start, + source_char_end=record.char_end, + reference_text=record.text, + ) diff --git a/app/engine/provenance.py b/app/engine/provenance.py new file mode 100644 index 0000000..34be0d4 --- /dev/null +++ b/app/engine/provenance.py @@ -0,0 +1,268 @@ +"""원문 위치를 보존하는 SQLite 코퍼스 저장소. + +검색용 전처리 텍스트와 증거 표시용 원문을 분리한다. 현재 수령한 XLSX처럼 +페이지 정보가 없는 데이터도 ``coordinate_scope=episode`` 로 정직하게 기록하며, +향후 PDF/DOCX 재추출 시 동일 스키마에 page/paragraph/char offset을 채울 수 있다. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Iterator + + +SCHEMA_VERSION = 1 + + +def text_sha256(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def stable_id(prefix: str, *parts: str) -> str: + payload = "\x1f".join(parts).encode("utf-8") + return f"{prefix}-{hashlib.sha256(payload).hexdigest()[:20]}" + + +@dataclass(frozen=True) +class DocumentRecord: + document_id: str + title: str + source_path: str | None = None + source_sha256: str | None = None + metadata: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class SegmentRecord: + segment_id: str + document_id: str + text: str + ordinal: str + coordinate_scope: str = "episode" + page_number: int | None = None + paragraph_number: int | None = None + char_start: int | None = None + char_end: int | None = None + source_locator: str | None = None + metadata: dict = field(default_factory=dict) + + @property + def text_sha256(self) -> str: + return text_sha256(self.text) + + +class CorpusStore: + """SQLite 기반 원문/세그먼트 저장소. + + 연결은 호출 단위로 열어 멀티프로세스 API에서도 안전하게 사용한다. WAL은 + 읽기 중 증분 적재를 허용한다. + """ + + def __init__(self, path: str | Path): + self.path = Path(path) + + def _connect(self) -> sqlite3.Connection: + self.path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(self.path) + con.row_factory = sqlite3.Row + con.execute("PRAGMA journal_mode=WAL") + con.execute("PRAGMA foreign_keys=ON") + return con + + def initialize(self) -> None: + with self._connect() as con: + con.executescript( + """ + CREATE TABLE IF NOT EXISTS corpus_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS documents ( + document_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + source_path TEXT, + source_sha256 TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS segments ( + segment_id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(document_id) ON DELETE CASCADE, + ordinal TEXT NOT NULL, + text TEXT NOT NULL, + text_sha256 TEXT NOT NULL, + coordinate_scope TEXT NOT NULL, + page_number INTEGER, + paragraph_number INTEGER, + char_start INTEGER, + char_end INTEGER, + source_locator TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(document_id, text_sha256) + ); + CREATE INDEX IF NOT EXISTS idx_segments_document ON segments(document_id); + CREATE INDEX IF NOT EXISTS idx_segments_hash ON segments(text_sha256); + """ + ) + con.execute( + "INSERT OR REPLACE INTO corpus_meta(key, value) VALUES('schema_version', ?)", + (str(SCHEMA_VERSION),), + ) + + def upsert_document(self, record: DocumentRecord) -> None: + self.upsert_documents([record]) + + def upsert_documents(self, records: Iterable[DocumentRecord]) -> None: + self.initialize() + with self._connect() as con: + con.executemany( + """ + INSERT INTO documents(document_id,title,source_path,source_sha256,metadata_json) + VALUES(?,?,?,?,?) + ON CONFLICT(document_id) DO UPDATE SET + title=excluded.title, + source_path=COALESCE(excluded.source_path, documents.source_path), + source_sha256=COALESCE(excluded.source_sha256, documents.source_sha256), + metadata_json=excluded.metadata_json, + updated_at=CURRENT_TIMESTAMP + """, + [( + record.document_id, record.title, record.source_path, + record.source_sha256, + json.dumps(record.metadata, ensure_ascii=False, sort_keys=True), + ) for record in records], + ) + + def add_segments(self, records: Iterable[SegmentRecord]) -> tuple[int, int]: + """세그먼트를 추가하고 (inserted, duplicates) 반환.""" + self.initialize() + inserted = duplicates = 0 + with self._connect() as con: + for r in records: + before = con.total_changes + con.execute( + """ + INSERT OR IGNORE INTO segments( + segment_id,document_id,ordinal,text,text_sha256,coordinate_scope, + page_number,paragraph_number,char_start,char_end,source_locator,metadata_json + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + r.segment_id, + r.document_id, + r.ordinal, + r.text, + r.text_sha256, + r.coordinate_scope, + r.page_number, + r.paragraph_number, + r.char_start, + r.char_end, + r.source_locator, + json.dumps(r.metadata, ensure_ascii=False, sort_keys=True), + ), + ) + if con.total_changes > before: + inserted += 1 + else: + duplicates += 1 + return inserted, duplicates + + def iter_segments(self) -> Iterator[SegmentRecord]: + if not self.path.exists(): + return + with self._connect() as con: + rows = con.execute( + """ + SELECT s.*, d.title + FROM segments s JOIN documents d USING(document_id) + ORDER BY s.document_id, s.ordinal, s.segment_id + """ + ) + for row in rows: + metadata = json.loads(row["metadata_json"] or "{}") + metadata.setdefault("document_title", row["title"]) + yield SegmentRecord( + segment_id=row["segment_id"], + document_id=row["document_id"], + text=row["text"], + ordinal=row["ordinal"], + coordinate_scope=row["coordinate_scope"], + page_number=row["page_number"], + paragraph_number=row["paragraph_number"], + char_start=row["char_start"], + char_end=row["char_end"], + source_locator=row["source_locator"], + metadata=metadata, + ) + + def get_segments(self, segment_ids: Iterable[str]) -> dict[str, SegmentRecord]: + ids = list(dict.fromkeys(segment_ids)) + if not ids or not self.path.exists(): + return {} + result: dict[str, SegmentRecord] = {} + with self._connect() as con: + for start in range(0, len(ids), 500): + batch = ids[start : start + 500] + marks = ",".join("?" for _ in batch) + rows = con.execute( + f"""SELECT s.*, d.title FROM segments s + JOIN documents d USING(document_id) + WHERE segment_id IN ({marks})""", + batch, + ) + for row in rows: + metadata = json.loads(row["metadata_json"] or "{}") + metadata.setdefault("document_title", row["title"]) + result[row["segment_id"]] = SegmentRecord( + segment_id=row["segment_id"], document_id=row["document_id"], + text=row["text"], ordinal=row["ordinal"], + coordinate_scope=row["coordinate_scope"], + page_number=row["page_number"], paragraph_number=row["paragraph_number"], + char_start=row["char_start"], char_end=row["char_end"], + source_locator=row["source_locator"], metadata=metadata, + ) + return result + + def stats(self) -> dict[str, int]: + if not self.path.exists(): + return {"documents": 0, "segments": 0, "characters": 0} + with self._connect() as con: + documents = con.execute("SELECT COUNT(*) FROM documents").fetchone()[0] + segments, characters = con.execute( + "SELECT COUNT(*), COALESCE(SUM(LENGTH(text)),0) FROM segments" + ).fetchone() + return {"documents": documents, "segments": segments, "characters": characters} + + def document_count(self) -> int: + if not self.path.exists(): + return 0 + with self._connect() as con: + return int(con.execute("SELECT COUNT(*) FROM documents").fetchone()[0]) + + def list_documents(self) -> list[dict]: + if not self.path.exists(): + return [] + with self._connect() as con: + rows = con.execute( + """SELECT d.document_id, d.title, d.source_path, + COUNT(s.segment_id) AS segment_count, + COALESCE(SUM(LENGTH(s.text)),0) AS characters + FROM documents d LEFT JOIN segments s USING(document_id) + GROUP BY d.document_id ORDER BY d.title""" + ).fetchall() + return [dict(row) for row in rows] + + def delete_document(self, document_id: str) -> bool: + if not self.path.exists(): + return False + with self._connect() as con: + before = con.total_changes + con.execute("DELETE FROM documents WHERE document_id=?", (document_id,)) + return con.total_changes > before diff --git a/app/engine/source_extraction.py b/app/engine/source_extraction.py new file mode 100644 index 0000000..a674efb --- /dev/null +++ b/app/engine/source_extraction.py @@ -0,0 +1,106 @@ +"""PDF/DOCX 원문을 위치 좌표가 보존된 세그먼트로 변환한다.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + +from app.engine.provenance import DocumentRecord, SegmentRecord, stable_id + + +@dataclass(frozen=True) +class ExtractionResult: + document: DocumentRecord + segments: tuple[SegmentRecord, ...] + warnings: tuple[str, ...] = () + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def chunk_with_offsets(text: str, size: int = 1000, stride: int = 500) -> Iterator[tuple[int, int, str]]: + if size < 1 or stride < 1: + raise ValueError("size and stride must be positive") + text = text or "" + for start in range(0, len(text), stride): + end = min(len(text), start + size) + chunk = text[start:end] + if chunk.strip(): + yield start, end, chunk + if end >= len(text): + break + + +def extract_pdf(path: Path, size: int = 1000, stride: int = 500) -> ExtractionResult: + try: + from pypdf import PdfReader + except ImportError as exc: + raise RuntimeError("PDF 추출에는 pypdf가 필요합니다") from exc + sha = file_sha256(path) + document_id = stable_id("doc", sha) + document = DocumentRecord( + document_id=document_id, title=path.stem, source_path=str(path), source_sha256=sha, + metadata={"format": "pdf", "coordinate_scope": "page"}, + ) + segments: list[SegmentRecord] = [] + warnings: list[str] = [] + reader = PdfReader(str(path)) + for page_number, page in enumerate(reader.pages, 1): + text = page.extract_text() or "" + if not text.strip(): + warnings.append(f"page {page_number}: text 없음(OCR 필요 가능)") + continue + for local_index, (start, end, chunk) in enumerate(chunk_with_offsets(text, size, stride), 1): + segments.append(SegmentRecord( + segment_id=stable_id("seg", document_id, str(page_number), str(start), chunk), + document_id=document_id, text=chunk, + ordinal=f"p{page_number:05d}-{local_index:04d}", coordinate_scope="page", + page_number=page_number, char_start=start, char_end=end, + source_locator=f"{path.name}#page={page_number}&chars={start}-{end}", + metadata={"extractor": "pypdf", "page_text_coordinates": True}, + )) + return ExtractionResult(document, tuple(segments), tuple(warnings)) + + +def extract_docx(path: Path, size: int = 1000, stride: int = 500) -> ExtractionResult: + try: + from docx import Document + except ImportError as exc: + raise RuntimeError("DOCX 추출에는 python-docx가 필요합니다") from exc + sha = file_sha256(path) + document_id = stable_id("doc", sha) + document = DocumentRecord( + document_id=document_id, title=path.stem, source_path=str(path), source_sha256=sha, + metadata={"format": "docx", "coordinate_scope": "paragraph"}, + ) + segments: list[SegmentRecord] = [] + source = Document(str(path)) + for paragraph_number, paragraph in enumerate(source.paragraphs, 1): + text = paragraph.text or "" + for local_index, (start, end, chunk) in enumerate(chunk_with_offsets(text, size, stride), 1): + segments.append(SegmentRecord( + segment_id=stable_id("seg", document_id, str(paragraph_number), str(start), chunk), + document_id=document_id, text=chunk, + ordinal=f"para{paragraph_number:06d}-{local_index:04d}", + coordinate_scope="paragraph", paragraph_number=paragraph_number, + char_start=start, char_end=end, + source_locator=f"{path.name}#paragraph={paragraph_number}&chars={start}-{end}", + metadata={"extractor": "python-docx", "paragraph_text_coordinates": True}, + )) + return ExtractionResult(document, tuple(segments)) + + +def extract_source(path: Path, size: int = 1000, stride: int = 500) -> ExtractionResult: + suffix = path.suffix.lower() + if suffix == ".pdf": + return extract_pdf(path, size, stride) + if suffix == ".docx": + return extract_docx(path, size, stride) + raise ValueError(f"지원하지 않는 원본 형식: {path.suffix}") diff --git a/app/main.py b/app/main.py index 31ee423..2c18dc4 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,13 @@ from __future__ import annotations import logging +import hmac from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI +from fastapi import Request +from fastapi.responses import JSONResponse from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -19,6 +22,11 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() + if not settings.api_key.strip(): + logging.critical( + "API_KEY is empty: all /v1 endpoints are unauthenticated. " + "Do not expose unpublished manuscripts publicly until client key rollout is complete." + ) app.state.settings = settings app.state.detector = PlagiarismDetector(settings=settings) app.state.job_store = JobStore() @@ -55,6 +63,18 @@ app = FastAPI( app.include_router(api_router) + +@app.middleware("http") +async def optional_api_key_auth(request: Request, call_next): + """API_KEY가 설정된 운영 환경에서만 API 인증을 강제한다.""" + configured = _settings.api_key.strip() + public_paths = {"/", "/v1/health", "/docs", "/openapi.json", "/redoc"} + if configured and request.url.path not in public_paths and request.url.path.startswith("/v1"): + supplied = request.headers.get("x-api-key", "") + if not hmac.compare_digest(configured, supplied): + return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) + return await call_next(request) + _STATIC_DIR = Path(__file__).resolve().parent / "static" if _STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") diff --git a/data/precedents/README.md b/data/precedents/README.md new file mode 100644 index 0000000..b72ff31 --- /dev/null +++ b/data/precedents/README.md @@ -0,0 +1,10 @@ +# 판례 지식베이스 + +`precedents.jsonl`에는 검증 가능한 공식 출처가 있는 판례만 등록합니다. 현재 포함된 +레코드는 엔진과 스키마 검증용 최소 시드이며, 2,000여 건이 적재됐다는 의미가 아닙니다. + +필수 필드: `case_id`, `title`, `source_url`, `holding_summary`. +권장 필드: `work_types`, `legal_tags`, `criteria`, `outcome`. + +판례 원문/요약의 재배포 가능 범위와 전문가 라벨링 기준을 확인한 뒤 데이터를 늘려야 +합니다. 모델이나 LLM이 사건번호를 자유 생성하게 해서는 안 됩니다. diff --git a/data/precedents/precedents.jsonl b/data/precedents/precedents.jsonl new file mode 100644 index 0000000..9fb2f1d --- /dev/null +++ b/data/precedents/precedents.jsonl @@ -0,0 +1 @@ +{"case_id":"2012다73493","title":"저작물 복제·2차적저작물 작성 및 의거관계 판단 기준","source_url":"https://www.copyright.or.kr/information-materials/trend/precedents/view.do?brdclasscode=&brdctsno=16504&brdctsstatecode=&nationcode=&servicecode=06","work_types":["literary"],"legal_tags":["reproduction","derivative_work"],"criteria":["실질적 유사성","의거관계","보호되는 표현"],"holding_summary":"수정·증감 또는 변경이 있더라도 새로운 창작성이 더해지지 않은 정도라면 복제로 볼 수 있으며, 의거관계와 보호되는 표현의 실질적 유사성을 구분해 검토한다.","outcome":null} diff --git a/docker-compose.yml b/docker-compose.yml index 1342fed..c2285cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,10 @@ services: REFERENCE_CORPUS_DIR: /app/data/reference TAXONOMY_DIR: /app/data/taxonomy AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt + CORPUS_DB_PATH: /app/data/runtime/corpus.sqlite3 + PERSISTENT_INDEX_DIR: /app/data/runtime/index + PRECEDENTS_PATH: /app/data/precedents/precedents.jsonl + AI_DETECTOR_MODEL_PATH: /app/data/models/ai_detector.joblib volumes: - ./data:/app/data restart: unless-stopped diff --git a/docs/AI_DETECTION.md b/docs/AI_DETECTION.md new file mode 100644 index 0000000..3b0bc5e --- /dev/null +++ b/docs/AI_DETECTION.md @@ -0,0 +1,271 @@ +# 한국어 AI 생성 의심도 (ai_detector) + +> **이 기능이 산출하는 값은 "AI가 썼다"는 판정이 아니라, 사람 검토를 어디에 먼저 +> 배정할지 정하는 우선순위 점수입니다.** 저자 통보·계약 조치·출간 거부의 단독 +> 근거로 사용할 수 없습니다. 이유는 아래 [3. 한계와 오탐 위험](#3-한계와-오탐-위험)에 +> 정리했습니다. + +관련 파일: + +| 파일 | 역할 | +|---|---| +| `app/engine/ai_detector.py` | 특징 추출 + 점수 산출 + 구간 채점 | +| `scripts/build_ai_training_dataset.py` | xlsx(human) + JSONL/CSV(AI) → 학습셋 | +| `scripts/train_ai_detector.py` | CPU 학습 + 보정 + 지표/아티팩트 저장 | +| `tests/test_ai_detector.py` | 단위테스트 (외부 다운로드 불필요) | + +--- + +## 1. 동작 모드 — 점수를 지어내지 않는다 + +세 가지 모드가 있고, 결과 객체의 `is_stub` / `model_version` / `note` 로 **항상 +구분 가능**합니다. 기존 `detector.py:_dummy_ai_generation_signal` 처럼 문자 코드 +합으로 만든 임의 더미는 이 모듈에 없습니다. + +| 모드 | 조건 | `available` | `score` | `is_stub` | `model_version` | +|---|---|---|---|---|---| +| `unavailable` | 아티팩트 없음 + `allow_heuristic=False` (**기본**) | `False` | `None` | `False` | `"unavailable"` | +| `heuristic` | 아티팩트 없음 + `allow_heuristic=True` | `True` | 0~1 | `True` | `"heuristic-baseline-v1"` | +| `trained` | 학습 아티팩트 로드 성공 | `True` | 0~1 (보정됨) | `False` | 예: `logreg-nolen-kf-ko-v1-auroc0.912` | + +기본값이 `unavailable` 인 이유는, 검증되지 않은 점수가 조용히 운영 화면에 노출되는 +상황을 막기 위해서입니다. 휴리스틱을 켜려면 명시적으로 opt-in 해야 합니다. + +```bash +export AI_DETECTOR_MODEL_PATH=/mnt/data1/o2o/ai_detector/model.joblib +export AI_DETECTOR_ALLOW_HEURISTIC=false # 기본값 +``` + +### 휴리스틱 baseline 에 대한 경고 + +`heuristic` 모드는 문헌상 알려진 경향(문장 길이 균일성, 쉼표 과다 등)을 선형 +위치로 환산해 평균하는 규칙 계층입니다. **참조 구간 값은 아직 어떤 데이터로도 +캘리브레이션되지 않은 자리표시자입니다.** 79권 인간 저작 코퍼스로 FPR을 측정하기 +전까지는 데모·개발 편의용으로만 쓰고, 대외 산출물이나 사용자 화면에 노출하지 +마십시오. + +--- + +## 2. 특징 (`kf-ko-v1`) + +KatFishNet 계열의 한국어 표지를 결정적·설명 가능하게 구현했습니다. 총 +**54개**(기본 37 + 품사 17)이며 `FEATURE_NAMES` 순서가 곧 모델 입력 벡터의 +순서입니다. 이 순서는 +아티팩트에 저장되어 로드 시 대조되며, 불일치하면 **로드를 거부**합니다. + +| 계열 | 대표 특징 | 근거 | +|---|---|---| +| 띄어쓰기 | `mean_eojeol_len`, `cv_eojeol_len`, `long_eojeol_ratio` | 생성문의 어절 길이 분포가 더 균일 | +| 문장 | `cv_sentence_len`, `std_sentence_len`, `mean_sentence_len` | **burstiness** — 사람 글은 문장 길이 기복이 큼 | +| 쉼표·부호 | `comma_per_sentence`, `comma_ratio`, `punct_diversity` | 생성문의 쉼표 과다 사용 경향 | +| 종결 | `end_da_ratio`, `end_yo_ratio`, `end_noun_ratio` | 종결어미 분포의 정형성 | +| 반복 | `char3gram_repeat_ratio`, `word_bigram_repeat_ratio`, `hapax_ratio` | 표현 재사용 | +| 품사 | `pos_bigram_entropy`, `pos_trigram_repeat_ratio`, `josa_diversity`, `eomi_diversity` | 품사 배열의 정형성, 조사·어미 다양도 | +| 길이 | `char_count`, `sentence_count`, `morph_count` 등 | 보조 — 아래 경고 참조 | + +### 품사 특징 폴백 + +품사 특징은 `kiwipiepy` 가 있을 때만 산출됩니다. 미설치·초기화 실패·토큰화 예외 +어느 경우에도 **예외를 밖으로 던지지 않고** 품사 특징을 0.0 으로 두며 +`pos_available=0.0` 을 세웁니다. **특징 벡터의 길이와 순서는 절대 변하지 +않습니다.** 모델이 품사로 학습됐는데(`requires_pos=True`) 실행 환경에 kiwi 가 +없으면 `pos_required_by_model_but_missing` 경고가 붙습니다. + +### ⚠️ 길이 특징 편향 + +human 에피소드와 AI 생성 표본의 **분량 분포가 다르면, 모델은 문체가 아니라 +길이를 학습합니다.** 실제로 합성 데이터로 검증했을 때 상위 기여 특징이 +`sentence_count`, `char_count`, `morph_count` 로 채워졌고, +`--drop-length-features` 를 켜자 `comma_ratio`, `josa_ratio`, +`std_sentence_len` 같은 실제 문체 지표로 바뀌었습니다. + +**실데이터에서는 두 설정을 모두 돌려 성능 차이를 반드시 비교하십시오.** 길이를 +빼도 성능이 유지되면 문체를 배운 것이고, 크게 떨어지면 길이만 보고 있었던 +것입니다. + +--- + +## 3. 한계와 오탐 위험 + +이 기능을 도입하기 전에 반드시 합의해야 할 항목입니다. + +1. **자서전은 대필·윤문이 일상적입니다.** 편집자가 다듬은 원고는 문체가 정제되어 + AI 쪽으로 기울기 쉽습니다. 이 도메인에서 FP는 "저자가 AI로 썼다"는 통보로 + 이어질 수 있고, 그건 명예 문제입니다. +2. **학습에 쓰이지 않은 생성 모델에는 일반화가 잘 되지 않습니다.** 특정 모델의 + 출력으로 학습하면 그 모델만 잡습니다. +3. **가벼운 수정으로 회피됩니다.** 문장을 몇 개 쪼개고 쉼표만 지워도 주요 특징이 + 흔들립니다. +4. **짧은 글은 통계가 성립하지 않습니다.** `HARD_MIN_CHARS=120` 미만은 채점을 + 거부하고, `MIN_RELIABLE_CHARS=300` 미만은 `short_text_low_confidence` 경고를 + 붙입니다. +5. **표절 점수와 절대 합산하지 마십시오.** 세 점수(표절 / 침해위험 / AI의심)는 + 끝까지 독립 필드여야 합니다. + +### 평가 기준은 F1 이 아니라 FPR + +인간 저작을 AI로 오판하는 비용이 압도적으로 크므로, 임계값은 F1 최적점이 아니라 +**목표 FPR 상한**에서 잡습니다. `train_ai_detector.py` 의 `--target-fpr`(기본 +0.05, `low_cut`)과 `--high-fpr`(기본 0.01, `high_cut`)이 그 역할입니다. 모델은 +train에서 적합하고 임계값은 모델이 보지 않은 val에서 정하며, test는 최종 보고에만 +사용합니다. val human 표본이 100건 미만이면 FPR 컷이 불안정하다는 경고가 납니다. + +`low_cut == high_cut` 경고가 뜨면 medium 배지가 사라진 상태입니다. 표본이 너무 +쉽게 분리되거나 표본 수가 부족하다는 신호이니 데이터를 재점검하십시오. + +--- + +## 4. provenance (작성 경로 **추정**) + +확정이 아니라 검토자 참고용 분류입니다. 구간 점수 분포에서 유도합니다. + +| 값 | 규칙 | 검토자 해석 | +|---|---|---| +| `human` | 문서·구간 모두 low | 통상 검토 | +| `ai` | 문서 high, low 구간 없음 | 우선 검토 | +| `mixed` | high 구간과 low 구간이 공존 | **부분 삽입 의심** — 어느 구간인지 확인 | +| `edited` | 전 구간이 medium 에 몰림 | AI 초안 + 사람 윤문(또는 역) 가능성 | +| `unknown` | 채점 가능한 구간 없음 / 모델 미가용 | 판단 보류 | + +`mixed` 가 실무상 가장 유용합니다. 문서 전체 점수 하나로는 "30만 자 중 한 챕터만 +생성물"을 절대 못 잡지만, 구간 점수는 잡습니다. + +--- + +## 5. 학습 절차 + +### 5.1 데이터 준비 + +```bash +# ① 컬럼 확인 (아무것도 쓰지 않음) — 반드시 먼저 실행 +python scripts/build_ai_training_dataset.py --xlsx episodes.xlsx --inspect + +# ② 빌드 +python scripts/build_ai_training_dataset.py \ + --xlsx episodes.xlsx \ + --text-column "에피소드 본문" --book-column "도서명" \ + --ai-jsonl data/training/ai_samples.jsonl \ + --out data/training/ai_dataset.jsonl +``` + +**AI 샘플 JSONL 형식** (`--ai-text-field`/`--ai-generator-field` 로 변경 가능): + +```json +{"text": "생성된 본문 …", "generator": "gpt-4o-mini", "book": ""} +``` + +### 5.2 누출 방지 — 이 설계의 핵심 + +분할 단위는 개별 텍스트가 아니라 **`source_group`** 입니다. + +- human: `book:<도서명>` (도서 정보가 없으면 `sheet:<시트명>`) +- AI: `ai:` (또는 `--ai-group-field` 로 지정) + +같은 책의 에피소드가 train 과 test 에 동시에 들어가면 모델이 문체가 아니라 **그 +책을 외웁니다.** 빌더는 분할 후 그룹 중복을 검사해 발견 시 **exit 1** 로 +실패하고, 학습 CLI도 train/val/test 그룹 중복을 확인해 **exit 2** 로 중단합니다. + +분할은 해시 기반이라 결정적이며(`--seed`), 라벨별로 비율을 맞춥니다. 정규화 후 +완전 중복 텍스트는 제거합니다(`recovered.csv` 에서 9,633행 중복이 나온 전례). + +### 5.3 학습 + +```bash +python scripts/train_ai_detector.py \ + --data data/training/ai_dataset.jsonl \ + --model logreg \ + --drop-length-features \ + --target-fpr 0.05 --high-fpr 0.01 \ + --out data/models/ai_detector.joblib +``` + +- `--model logreg` — StandardScaler + LogisticRegression. **기본 권장.** 계수 × + 표준화값으로 기여도를 뽑을 수 있어 검토자에게 근거를 보여줄 수 있습니다. +- `--model hgb` — HistGradientBoosting. 성능이 나을 수 있으나 기여도를 못 뽑아 + `top_contributions` 가 빕니다(**지어내지 않습니다**). +- 두 경우 모두 `CalibratedClassifierCV`(sigmoid) + `StratifiedGroupKFold` 로 + 확률을 보정합니다. 보정 안 된 점수를 "의심도 %"로 띄우면 검토자가 과신합니다. +- GPU 불필요, 외부 API 호출 없음. + +**즉시 실패하는 조건** (조용히 넘어가지 않고 exit 2): +label 필드 없음 / 단일 클래스 / 소수 클래스 10건 미만 / `source_group` 4개 미만 / +train·val·test 그룹 중복 / 어느 split이 단일 클래스 / train 그룹이 CV fold 보다 적음. + +### 5.4 산출물 + +`model.joblib` 에는 estimator 와 함께 재현에 필요한 메타가 들어갑니다: +`feature_names`, `feature_set_version`, `model_version`, `requires_pos`, +`zeroed_features`, `low_cut`, `high_cut`, `metrics`, `sklearn_version`, +`trained_at`. `model.metrics.json` 에 AUROC / AUPRC / confusion matrix / FPR / +TPR / precision / recall / F1 이 train·validation·test 각각 저장됩니다. + +--- + +## 6. 통합 연결점 (반영 완료) + +아래 항목은 API 통합에 반영됐습니다. 변경 시 유지해야 할 계약으로 참고합니다. + +### 6.1 `app/core/config.py` + +```python +ai_detector_model_path: str = "./data/models/ai_detector.joblib" +ai_detector_allow_heuristic: bool = False +ai_detector_enabled: bool = True +``` + +Settings가 `get_ai_detector()`에 모델 경로·휴리스틱 허용·품사 사용 설정을 주입합니다. + +### 6.2 `app/api/schemas.py` — `AiGenerationSignal` 확장 + +`AiGenerationSignal`에는 아래 필드가 반영돼 있습니다. + +| 추가 필드 | 타입 | 비고 | +|---|---|---| +| `available` | `bool` | `False` 면 `score`/`suspicion_level` 이 `None` | +| `provenance` | `Literal["human","ai","mixed","edited","unknown"]` | 4절 | +| `model_version` | `str` | 감사 추적용 — 어느 모델이 낸 점수인지 | +| `feature_set_version` | `str` | `kf-ko-v1` | +| `warnings` | `list[str]` | `short_text_low_confidence` 등 | +| `segments` | `list[SegmentScore]` | 구간별 점수 | +| `top_contributions` | `list[{feature, contribution}]` | 설명 근거 | +| `pos_available` | `bool` | 품사 특징 사용 여부 | + +`score`는 `None`을 허용하고 `suspicion_level=unknown`으로 unavailable 상태를 표현합니다. + +### 6.3 `app/engine/detector.py` + +문자 코드 합 더미는 제거됐고 전처리 전 raw text를 탐지기에 전달합니다. + +```python +from app.engine.ai_detector import get_ai_detector + +ai_result = get_ai_detector().detect(text) +``` + +`ReviewSummary.ai_suspicion_level`은 `unknown`을 지원하므로 미학습 결과를 `low`로 +오독하지 않습니다. + +단건 탐지는 threadpool에서 실행하고, 배치 경로는 구간 채점을 생략해 CPU 부하를 줄입니다. + +### 6.4 바이칼 연동 문서 + +`docs/API_SPEC_BAIKAL.md` / `docs/API_GUIDE_BAIKAL.md`도 미학습 시 `unknown/null`을 +반환하도록 갱신했습니다. **`is_stub=false`여도 확정 판정은 아닙니다.** + +--- + +## 7. 테스트 + +```bash +python -m pytest tests/test_ai_detector.py -q +``` + +51건. **외부 모델 다운로드·네트워크 없이** 통과합니다. sklearn/kiwipiepy/joblib +미설치 환경에서도 핵심 경로는 전부 돌아가며(학습 모델 경로는 stub estimator 로 +검증), joblib 이 있어야만 의미 있는 파일 아티팩트 3건만 skip 됩니다. + +검증 항목: 특징 키 완전성·결정성·벡터 순서 계약 / 해시 더미가 아님(구조가 같으면 +구조 특징도 같음) / kiwi 부재·토큰화 예외 폴백 / 길이특징 마스킹의 학습·추론 +일치 / unavailable 시 점수 미산출 / 휴리스틱의 스텁 표기와 방향성 / +짧은 텍스트 거부·경고 / 구간 오프셋 유효성 / provenance 규칙 / 추론 실패 전파 / +특징 불일치 아티팩트 로드 거부 / JSON 직렬화. diff --git a/docs/API_GUIDE_BAIKAL.md b/docs/API_GUIDE_BAIKAL.md index b9b510d..d40bc44 100644 --- a/docs/API_GUIDE_BAIKAL.md +++ b/docs/API_GUIDE_BAIKAL.md @@ -64,7 +64,7 @@ | 저작권 · 유사도 2% | 2 | `review_summary.similarity_percent` | | "…3.5만 건과 대조한 결과 표절 의심 구간이 없습니다" | 35000 / false | `review_summary.compared_count`, `review_summary.has_suspicion` | | 유사 문장 0건 | 0 | `review_summary.similar_sentence_count` | -| AI 생성 의심도 낮음 | low | `review_summary.ai_suspicion_level` (`low/medium/high`=낮음/중간/높음) | +| AI 생성 의심도 | unknown | `review_summary.ai_suspicion_level` (`low/medium/high/unknown`) | | (표절 시) 일치 구간 하이라이트 | start~end | `matches[].evidence_spans[]` | | (표절 시) 침해 유형 배지 | 복제권 | `matches[].tags[].label_ko` + `case_id` | @@ -114,7 +114,7 @@ "has_suspicion": false, "ai_suspicion_level": "low" }, - "ai_generation": { "suspicion_level": "low", "score": 0.06, "is_stub": true, "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" }, + "ai_generation": { "suspicion_level": "unknown", "score": null, "available": false, "is_stub": false, "model_version": "unavailable", "note": "학습된 모델이 없어 채점하지 않음" }, "matches": [], "extracted_elements": { "characters": [], "motifs": ["비밀의 정원"], "genre": null, "keywords": ["숲","전설"] }, "ccl_basis": null, @@ -130,7 +130,7 @@ | 필드 | 타입 | 의미 | 화면 사용 | |---|---|---|---| | `doc_id` | string | 요청의 doc_id 그대로 | 응답 매칭 | -| `is_infringement` | bool | 표절 판정 여부(임계 초과 매칭 존재). `review_summary.has_suspicion`과 동일 | — | +| `is_infringement` | bool | 후방호환 필드. 법적 침해 확정이 아니라 임계 초과 매칭 존재 여부 | — | | `confidence` | float 0~1 | 최상위 매칭의 **결합 유사도 원값**. ⚠️ 임베딩 성분 때문에 무관한 글도 높게 나옴 → **화면 표시는 `review_summary` 사용, `confidence` 직접 표시 금지** | ✗ | | `review_summary` | object | **저작권 탭 UI 요약** (아래 상세) | ★ | | `ai_generation` | object | AI 생성 의심도 상세 (아래 상세) | 참고 | @@ -151,7 +151,7 @@ | `similar_sentence_count` | int | **유사 문장 건수** = 임계 초과 매칭 수 | | `compared_count` | int | **대조한 원본(코퍼스) 건수** | | `has_suspicion` | bool | **표절 의심 구간 존재 여부** | -| `ai_suspicion_level` | `low/medium/high` | **AI 생성 의심도**(낮음/중간/높음). ⚠️ 현재 더미 | +| `ai_suspicion_level` | `low/medium/high/unknown` | **AI 생성 의심도**. 미학습/채점 불가는 `unknown` | **`ai_generation` (AI 생성 의심도 상세)** @@ -159,11 +159,14 @@ |---|---|---| | `suspicion_level` | `low/medium/high` | 의심도 등급 | | `score` | float 0~1 | 참고 점수 | -| `is_stub` | bool | **`true`면 더미(미구현)**. 정식 구현 시 `false` | +| `available` | bool | 학습 모델 또는 명시적으로 활성화한 baseline으로 채점했는지 | +| `is_stub` | bool | `true`면 미검증 휴리스틱 baseline. 학습 모델은 `false` | +| `model_version` | string | 학습 아티팩트/특징 버전 추적 | | `note` | string | 안내 문구 | -> ⚠️ **AI 생성 의심도는 현재 스텁**이다. `is_stub=true`이며 요청마다 더미 값을 낸다. -> UI 배지는 붙여두되 **출판 승인 게이트로 쓰지 말 것**(참고용). +> 학습 모델이 없으면 점수를 임의 생성하지 않고 `available=false`, `score=null`, +> `suspicion_level=unknown`을 반환한다. 학습 후에도 **출판 승인 게이트나 저자 제재의 +> 단독 근거로 쓰지 말 것**(사람 검토 우선순위용). **`matches[]` (표절 의심 시 채워짐)** diff --git a/docs/API_SPEC_BAIKAL.md b/docs/API_SPEC_BAIKAL.md index 1e98c5e..e888013 100644 --- a/docs/API_SPEC_BAIKAL.md +++ b/docs/API_SPEC_BAIKAL.md @@ -32,9 +32,9 @@ | 표절 의심 구간 **없음** | false | `review_summary.has_suspicion` | | AI 생성 의심도 **낮음** | low | `review_summary.ai_suspicion_level` | -> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음 -> **⚠️ AI 생성 의심도는 현재 더미(스텁) 값입니다.** UI 연동 계약 확정용이며, 정식 판별 -> 로직(워터마킹+언어특징 분류) 적용 전까지 `ai_generation.is_stub=true` 로 반환됩니다. +> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음, +> `unknown`=미학습 또는 채점 불가. 학습 모델이 없으면 점수를 만들지 않고 +> `ai_generation.available=false`, `score=null`을 반환합니다. > UI는 지금 그대로 붙여두면 되고, 정식 구현 시 값만 실제로 바뀝니다. --- @@ -88,7 +88,9 @@ "suspicion_level": "low", "score": 0.06, "is_stub": true, - "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" + "available": false, + "model_version": "unavailable", + "note": "학습된 모델이 없어 채점하지 않음" }, "matches": [], "extracted_elements": { @@ -117,7 +119,7 @@ "has_suspicion": true, "ai_suspicion_level": "low" }, - "ai_generation": { "suspicion_level": "low", "score": 0.06, "is_stub": true, "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" }, + "ai_generation": { "suspicion_level": "unknown", "score": null, "available": false, "is_stub": false, "model_version": "unavailable", "note": "학습된 모델이 없어 채점하지 않음" }, "matches": [ { "source_doc": "auto-0003", @@ -151,10 +153,10 @@ | 필드 | 타입 | 설명 | |---|---|---| -| `is_infringement` | bool | 표절 판정 여부 (= `review_summary.has_suspicion`) | +| `is_infringement` | bool | 후방호환용 임계 초과 매칭 여부. 법적 침해 확정 아님 | | `confidence` | float | 최상위 매칭 결합 유사도 원값(0~1). **화면 표시는 `review_summary` 사용 권장** | | `review_summary` | object | **저작권 탭 UI 직접 매핑** (§1 표 참조) | -| `ai_generation` | object | AI 생성 의심도 상세. `is_stub=true`면 더미 | +| `ai_generation` | object | AI 생성 의심도 상세. 미학습이면 `available=false`, 휴리스틱이면 `is_stub=true` | | `matches[]` | array | 매칭된 원본별 상세 (표절 시). 태그·케이스·근거 구간 포함 | | `matches[].tags[]` | array | 법령 태그. `role`: `primary`(주)/`secondary`(보조), `label_ko` 한글 표기 | | `matches[].case_id` | string | 39종 침해 케이스 ID (예: A6) | @@ -196,7 +198,7 @@ FastAPI 표준. HTTP 상태코드 + `detail`. ## 5. 연동 시 주의 -1. **AI 생성 의심도는 현재 더미** — `is_stub` 로 판별 후, UI에는 표시하되 "참고용"으로 둘 것. +1. **AI 생성 의심도는 확정값이 아님** — `available`/`is_stub`/`model_version`을 확인하고 사람 검토 우선순위로만 사용할 것. 출판 승인/거절 게이트로 쓰지 말 것(오탐 리스크). 2. **화면 값은 `review_summary` 사용** — `confidence`(원값)는 임베딩 성분 때문에 무관한 글도 높게 나오므로 직접 표시 금지. 독창성 환산은 오투오가 `review_summary`에서 완료해 제공. diff --git a/docs/IMPLEMENTATION_RUNBOOK.md b/docs/IMPLEMENTATION_RUNBOOK.md new file mode 100644 index 0000000..40f6ab5 --- /dev/null +++ b/docs/IMPLEMENTATION_RUNBOOK.md @@ -0,0 +1,122 @@ +# O2O 표절·AI 의심도·판례 위험도 운영 런북 + +## 안전한 제품 경계 + +세 결과는 서로 다른 증거를 사용하며 합쳐서 하나의 `침해 확정` 값으로 만들지 않는다. + +1. **유사구간 검색**: 현재 등록 코퍼스 안에서 발견된 후보와 원문 위치 +2. **AI 생성 의심도**: 원문 문체 특징에 대한 검토 우선순위(확정 판정 금지) +3. **법적 위험도**: 등록 판례와 판단 요소에 대한 검토 보조(법률 자문 아님) + +후방 호환을 위해 `is_infringement` 필드는 유지하지만 실제 의미는 임계값을 넘은 +`has_similarity_match`와 같다. 신규 연동은 `has_similarity_match`, +`corpus_scope_note`, `legal_risk`를 사용한다. + +## 수령 데이터 실사 결과 + +- XLSX 본 데이터 34,105행, 원천 도서 79권 +- 고유 에피소드 31,560개, 같은 책 내부 중복 추가 행 2,545개 +- 고유 본문 약 2,946만 자 +- 페이지/문단 원문 좌표 없음: XLSX 적재 결과는 `coordinate_scope=episode` + +## King 서버 데이터 적재 + +원고 데이터와 학습 산출물은 Git에 커밋하지 않는다. `/app/data` Docker 볼륨 아래의 +`runtime`, `models`, `input`을 사용한다. + +```bash +python -m scripts.ingest_o2o_xlsx \ + data/input/o2o_episodes.xlsx \ + --database data/runtime/corpus.sqlite3 + +python -m scripts.build_persistent_index \ + --database data/runtime/corpus.sqlite3 \ + --index-dir data/runtime/index +``` + +같은 DB에 신규 세그먼트만 추가한 뒤 `build_persistent_index`를 다시 실행하면 결과의 +`mode`가 `append`이며 기존 행을 다시 벡터화하지 않는다. 삭제 또는 본문 변경이 +감지된 경우에만 `rebuild`한다. + +`.env`에서 다음을 설정하고 재기동한다. + +```dotenv +USE_PERSISTENT_INDEX=true +CORPUS_DB_PATH=/app/data/runtime/corpus.sqlite3 +PERSISTENT_INDEX_DIR=/app/data/runtime/index +PERSISTENT_SIMILARITY_THRESHOLD=0.65 +# 바이칼과 키 전달을 합의한 뒤 활성화 +API_KEY= +``` + +`0.65`는 영속 문자 n-gram 후보 검색의 보수적인 시작값일 뿐 운영 확정값이 아니다. +삽입 복제 테스트와 79권 상호비교 오탐 분포를 측정한 뒤 버전별로 보정한다. + +## PDF/DOCX 원문 위치 재구축 + +```bash +python -m scripts.extract_source_documents \ + /mnt/data2/demo/ai_publish/data/combooks \ + --database data/runtime/source_corpus.sqlite3 \ + --chunk-size 1000 --stride 500 +``` + +- PDF: 페이지 번호와 해당 페이지 추출 텍스트의 글자 offset 저장 +- DOCX: 문단 번호와 문단 내부 글자 offset 저장 +- 텍스트가 없는 PDF 페이지는 OCR 필요 경고 +- 구형 `.doc`은 먼저 DOCX 또는 PDF로 변환 필요 + +96개 raw와 79개 처리 도서의 대응표를 검수한 후, 위치 정보가 더 정확한 +`source_corpus.sqlite3`를 운영 코퍼스로 승격한다. + +## AI 탐지 학습 + +XLSX의 `에피소드` 열은 원문이 사람 작성임이 계약·생성 이력으로 확인된 경우에만 +human 라벨로 사용한다. AI 데이터는 모델·프롬프트·편집 유형별 provenance와 +`source_group`을 가져야 한다. 학습/검증은 행이 아니라 book/source group으로 나눈다. + +AI 모델 파일이 없거나 단일 클래스 데이터뿐이면 API는 실제 점수를 가장하지 않고 +미학습 상태를 표시해야 한다. 완전 AI, 사람 편집 AI, AI 윤문, 혼합 문서를 각각 +미학습 모델·미학습 도서로 평가한다. + +## 판례 적재 + +`data/precedents/precedents.jsonl`에는 공식 HTTPS 출처와 사건번호가 확인된 레코드만 +넣는다. + +```bash +python -m scripts.validate_precedents data/precedents/precedents.jsonl +``` + +현재 저장소의 판례는 스키마 동작을 확인하기 위한 최소 시드다. 2,000건 적재 완료로 +표현하면 안 된다. 재배포 허용 범위 확인과 저작권 전문가의 다음 항목 라벨링이 필요하다. + +- 보호되는 표현 / 아이디어·사실·상투적 표현 +- 의거관계 판단 근거 +- 실질적 유사성 인정·부정 이유 +- 저작물 유형과 결론 + +엔진은 등록된 사건번호만 반환하며 판례를 자유 생성하지 않는다. + +## 검증 게이트 + +- 데이터: 79권/31,560 고유 XLSX 세그먼트 적재 수 일치 +- 증분성: 신규 문서 추가 후 index sync `mode=append` +- 검색: 100/200/300/500자 복사·삽입 세트의 Recall@20 기록 +- 근거 위치: 반환한 query/source offset으로 원문 substring이 정확히 복원됨 +- 오탐: 책 단위 분리 및 자서전 공통표현 hard-negative 검수 +- AI: unseen book/model의 AUROC뿐 아니라 FPR, AUPRC, 혼합·편집 유형별 결과 기록 +- 법적 위험도: 미등록 사건번호 0건, 빠진 법적 사실을 항상 명시 +- API: CPU 작업 중 `/v1/health` event loop가 응답 가능 + +## 배포/롤백 + +1. 테스트 통과 및 Git SHA 기록 +2. King에서 `git pull --ff-only` +3. 데이터 적재/인덱싱/AI 학습은 호스트 또는 일회성 Compose 컨테이너에서 실행 +4. `docker compose up -d --build` +5. `/v1/health`, `/v1/plagiarism/detect`, 코퍼스 수, 모델 준비 상태 확인 +6. 문제 시 이전 Git SHA의 이미지를 다시 빌드하되 `data/runtime`은 보존 + +Ubuntu 18.04는 지원 종료 상태이므로 OS 업그레이드 전까지 외부 공개 범위를 최소화하고, +API 인증·방화벽·키 회전을 별도 운영 작업으로 완료해야 한다. diff --git a/requirements.txt b/requirements.txt index 78534c2..45bd908 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,8 @@ httpx>=0.27 kiwipiepy>=0.18 datasketch>=1.6 sentence-transformers>=3.0 +openpyxl>=3.1 +joblib>=1.4 +scipy>=1.13 +pypdf>=5.0 +python-docx>=1.1 diff --git a/scripts/build_ai_training_dataset.py b/scripts/build_ai_training_dataset.py new file mode 100644 index 0000000..24dcace --- /dev/null +++ b/scripts/build_ai_training_dataset.py @@ -0,0 +1,504 @@ +"""AI 생성 판별 학습셋 빌더 — human(xlsx) + AI(JSONL/CSV) 결합. + +목적: + 컴북스에서 받은 xlsx 의 '에피소드' 본문(= human 라벨)과, 별도로 준비한 AI + 생성 텍스트(JSONL/CSV)를 결합해 group-aware 분할이 가능한 학습셋을 만든다. + +⚠️ 외부 API 를 호출하지 않는다. AI 샘플은 **이미 생성되어 파일로 존재하는 것만** + 읽는다. 원고를 외부 서비스로 내보내는 경로를 이 스크립트에 만들지 말 것. + +누출(leakage) 방지: + · 분할 단위는 개별 텍스트가 아니라 **source_group**(기본: 책/도서 식별자)이다. + 같은 책의 에피소드가 train 과 test 에 동시에 들어가면, 모델이 문체가 아니라 + '그 책'을 외워 성능이 부풀려진다. + · AI 샘플도 생성기·프롬프트 출처 단위로 묶는다(`ai:` 기본). + · 한 group 에 human/AI 라벨이 섞이면 경고한다. 원본을 AI로 재작성한 페어라면 + **같은 group 으로 묶여야** 원본-생성물이 분할을 가로지르지 않는다. + · 정규화 후 완전 중복 텍스트는 제거한다(9,633행 중복 전례). + +사용: + # 1) 컬럼 확인만 (아무것도 쓰지 않음) + python scripts/build_ai_training_dataset.py --xlsx episodes.xlsx --inspect + + # 2) 실제 빌드 + python scripts/build_ai_training_dataset.py \ + --xlsx episodes.xlsx --text-column 본문 --book-column 도서명 \ + --ai-jsonl data/training/ai_samples.jsonl \ + --out data/training/ai_dataset.jsonl + +출력 JSONL 1행: + {"text": "...", "label": 0, "origin": "human", "source_group": "book:홍길동전", + "book": "홍길동전", "split": "train", "char_count": 812, "meta": {...}} + label: 0=human, 1=ai +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import logging +import re +import sys +import unicodedata +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") +logger = logging.getLogger("build-ai-dataset") + +LABEL_HUMAN = 0 +LABEL_AI = 1 + +#: 헤더 자동탐지 힌트 (부분일치, 소문자 비교). 실제 파일을 못 본 상태라 +#: 오탐 가능성이 있으니 --inspect 로 먼저 확인하고 --text-column 으로 고정할 것. +TEXT_HINTS = ("에피소드", "본문", "내용", "원고", "텍스트", "story", "text", "body", "content") +BOOK_HINTS = ("도서", "책", "서명", "제목", "book", "title", "작품") +ID_HINTS = ("id", "번호", "no", "식별") +AUTHOR_HINTS = ("저자", "작가", "author", "writer") + + +# --------------------------------------------------------------------------- +# 정규화 / 중복 제거 +# --------------------------------------------------------------------------- + +def normalize(text: str) -> str: + if not text: + return "" + t = unicodedata.normalize("NFKC", str(text)) + t = t.replace("\r\n", "\n").replace("\r", "\n") + t = re.sub(r"[ \t  ]+", " ", t) + t = re.sub(r"\n{3,}", "\n\n", t) + return t.strip() + + +def dedup_key(text: str) -> str: + """공백까지 제거한 형태의 해시 — 서식만 다른 중복을 잡는다.""" + compact = re.sub(r"\s+", "", text) + return hashlib.sha1(compact.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# 레코드 +# --------------------------------------------------------------------------- + +@dataclass +class Record: + text: str + label: int + origin: str # "human" | "ai" + source_group: str + book: str = "" + meta: dict = field(default_factory=dict) + split: str = "" + + @property + def char_count(self) -> int: + return len(self.text) + + def to_json(self) -> dict: + return { + "text": self.text, + "label": self.label, + "origin": self.origin, + "source_group": self.source_group, + "book": self.book, + "split": self.split, + "char_count": self.char_count, + "meta": self.meta, + } + + +# --------------------------------------------------------------------------- +# xlsx 로드 +# --------------------------------------------------------------------------- + +def _load_workbook(path: Path): + try: + from openpyxl import load_workbook + except ImportError: + raise SystemExit( + "openpyxl 이 필요합니다. `pip install openpyxl` 또는 " + "requirements.txt 설치 후 다시 실행하세요." + ) + return load_workbook(filename=str(path), read_only=True, data_only=True) + + +def _match_column(headers: list[str], hints: tuple[str, ...]) -> str | None: + """헤더 목록에서 힌트에 부분일치하는 첫 컬럼명.""" + lowered = [(h, (h or "").strip().lower()) for h in headers] + for hint in hints: + for original, low in lowered: + if hint in low: + return original + return None + + +def inspect_xlsx(path: Path, sheet: str | None = None) -> None: + """헤더와 표본을 출력만 한다. 컬럼 지정 전에 반드시 한 번 실행할 것.""" + wb = _load_workbook(path) + sheets = [sheet] if sheet else wb.sheetnames + for name in sheets: + ws = wb[name] + rows = ws.iter_rows(values_only=True) + try: + header = [str(c) if c is not None else "" for c in next(rows)] + except StopIteration: + logger.info("[%s] 빈 시트", name) + continue + logger.info("[시트 %s] 컬럼 %d개", name, len(header)) + for i, h in enumerate(header): + logger.info(" [%2d] %s", i, h) + logger.info( + " 자동탐지 → text=%s / book=%s / id=%s", + _match_column(header, TEXT_HINTS), + _match_column(header, BOOK_HINTS), + _match_column(header, ID_HINTS), + ) + for n, row in enumerate(rows): + if n >= 2: + break + preview = { + h: (str(v)[:60] + "…" if v is not None and len(str(v)) > 60 else v) + for h, v in zip(header, row) + } + logger.info(" 샘플%d: %s", n + 1, preview) + wb.close() + + +def load_human_from_xlsx( + path: Path, + text_column: str | None, + book_column: str | None, + id_column: str | None, + sheet: str | None, + min_chars: int, +) -> list[Record]: + wb = _load_workbook(path) + sheets = [sheet] if sheet else wb.sheetnames + records: list[Record] = [] + + for name in sheets: + ws = wb[name] + rows = ws.iter_rows(values_only=True) + try: + header = [str(c) if c is not None else "" for c in next(rows)] + except StopIteration: + continue + + tcol = text_column or _match_column(header, TEXT_HINTS) + if tcol is None or tcol not in header: + logger.warning( + "[%s] 본문 컬럼을 찾지 못했습니다(지정=%s). --inspect 로 확인 후 " + "--text-column 으로 지정하세요. 이 시트는 건너뜁니다.", + name, text_column, + ) + continue + bcol = book_column or _match_column(header, BOOK_HINTS) + icol = id_column or _match_column(header, ID_HINTS) + acol = _match_column(header, AUTHOR_HINTS) + + ti = header.index(tcol) + bi = header.index(bcol) if bcol in header else None + ii = header.index(icol) if icol in header else None + ai = header.index(acol) if acol in header else None + + logger.info( + "[%s] text=%r book=%r id=%r author=%r", name, tcol, bcol, icol, acol + ) + + for rownum, row in enumerate(rows, start=2): + if ti >= len(row): + continue + text = normalize(row[ti] if row[ti] is not None else "") + if len(text) < min_chars: + continue + book = "" + if bi is not None and bi < len(row) and row[bi] is not None: + book = str(row[bi]).strip() + # 책 정보가 없으면 시트명으로라도 묶는다. group 없는 분할은 금지. + group = f"book:{book}" if book else f"sheet:{name}" + meta = {"sheet": name, "row": rownum, "source_file": path.name} + if ii is not None and ii < len(row) and row[ii] is not None: + meta["row_id"] = str(row[ii]).strip() + if ai is not None and ai < len(row) and row[ai] is not None: + meta["author"] = str(row[ai]).strip() + records.append( + Record( + text=text, label=LABEL_HUMAN, origin="human", + source_group=group, book=book, meta=meta, + ) + ) + wb.close() + logger.info("human 레코드 %d건 (xlsx=%s)", len(records), path.name) + return records + + +# --------------------------------------------------------------------------- +# AI 샘플 (JSONL / CSV) +# --------------------------------------------------------------------------- + +def load_ai_records( + jsonl_paths: list[Path], + csv_paths: list[Path], + text_field: str, + group_field: str | None, + generator_field: str, + min_chars: int, +) -> list[Record]: + records: list[Record] = [] + + def _push(obj: dict, src: str, idx: int) -> None: + raw = obj.get(text_field) + if raw is None: + return + text = normalize(raw) + if len(text) < min_chars: + return + generator = str(obj.get(generator_field) or "unknown").strip() + if group_field and obj.get(group_field): + group = str(obj[group_field]).strip() + else: + # 생성기 단위 묶음. 같은 모델이 만든 글끼리는 문체가 닮아 + # 분할을 가로지르면 성능이 부풀려진다. + group = f"ai:{generator}" + meta = {k: v for k, v in obj.items() if k != text_field} + meta["source_file"] = src + meta.setdefault("row", idx) + records.append( + Record( + text=text, label=LABEL_AI, origin="ai", + source_group=group, book=str(obj.get("book") or ""), meta=meta, + ) + ) + + for p in jsonl_paths: + with p.open(encoding="utf-8") as fh: + for i, line in enumerate(fh, start=1): + line = line.strip() + if not line: + continue + try: + _push(json.loads(line), p.name, i) + except json.JSONDecodeError: + logger.warning("%s:%d JSON 파싱 실패 — 건너뜀", p.name, i) + + for p in csv_paths: + with p.open(encoding="utf-8", newline="") as fh: + for i, row in enumerate(csv.DictReader(fh), start=2): + _push(row, p.name, i) + + logger.info("ai 레코드 %d건", len(records)) + return records + + +# --------------------------------------------------------------------------- +# 중복 제거 + 그룹 분할 +# --------------------------------------------------------------------------- + +def deduplicate(records: list[Record]) -> tuple[list[Record], int]: + seen: dict[str, Record] = {} + dropped = 0 + for rec in records: + key = dedup_key(rec.text) + if key in seen: + dropped += 1 + continue + seen[key] = rec + return list(seen.values()), dropped + + +def assign_splits( + records: list[Record], + ratios: tuple[float, float, float], + seed: int, +) -> dict[str, str]: + """source_group 단위 결정적 분할. 같은 group 은 반드시 같은 split. + + sklearn 없이도 재현 가능하도록 해시 기반으로 자른다. 다만 라벨 비율이 + 한쪽으로 쏠리지 않도록, 라벨별로 group 을 나눠 각각 비율을 맞춘다. + """ + train_r, val_r, _ = ratios + by_label: dict[int, list[str]] = defaultdict(list) + group_labels: dict[str, set[int]] = defaultdict(set) + + for rec in records: + group_labels[rec.source_group].add(rec.label) + + for group, labels in group_labels.items(): + if len(labels) > 1: + logger.warning( + "group %r 에 human/AI 라벨이 함께 있습니다. 원본-생성물 페어라면 " + "의도된 것이며 분할 누출은 방지됩니다.", group + ) + # 대표 라벨(다수)로 비율 배분만 결정 + by_label[sorted(labels)[0]].append(group) + + assignment: dict[str, str] = {} + for label, groups in by_label.items(): + # 해시로 결정적 순서 부여 (입력 순서에 의존하지 않음) + ordered = sorted( + groups, + key=lambda g: hashlib.sha1(f"{seed}:{g}".encode("utf-8")).hexdigest(), + ) + n = len(ordered) + n_train = int(round(n * train_r)) + n_val = int(round(n * val_r)) + # 그룹이 적을 때 test 가 0이 되지 않도록 보정 + if n >= 3: + n_train = min(n_train, n - 2) + n_val = min(n_val, n - n_train - 1) + for i, g in enumerate(ordered): + if i < n_train: + assignment[g] = "train" + elif i < n_train + n_val: + assignment[g] = "val" + else: + assignment[g] = "test" + logger.info( + "label=%d groups=%d → train=%d val=%d test=%d", + label, n, n_train, n_val, n - n_train - n_val, + ) + return assignment + + +def summarize(records: list[Record]) -> dict: + by_split = Counter(r.split for r in records) + by_label = Counter(r.label for r in records) + per_split_label = Counter((r.split, r.label) for r in records) + groups = {r.source_group for r in records} + split_groups: dict[str, set[str]] = defaultdict(set) + for r in records: + split_groups[r.split].add(r.source_group) + + overlap: list[str] = [] + for a in ("train", "val", "test"): + for b in ("train", "val", "test"): + if a >= b: + continue + shared = split_groups[a] & split_groups[b] + if shared: + overlap.append(f"{a}∩{b}={len(shared)}") + + return { + "total": len(records), + "by_label": {str(k): v for k, v in sorted(by_label.items())}, + "by_split": dict(by_split), + "by_split_label": {f"{s}/{l}": c for (s, l), c in sorted(per_split_label.items())}, + "group_count": len(groups), + "mean_chars": round( + sum(r.char_count for r in records) / len(records), 1 + ) if records else 0, + "group_overlap_between_splits": overlap, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--xlsx", type=Path, help="human 에피소드 xlsx") + ap.add_argument("--sheet", default=None, help="시트명 (생략 시 전체)") + ap.add_argument("--inspect", action="store_true", help="헤더/표본만 출력하고 종료") + ap.add_argument("--text-column", default=None, help="본문 컬럼명 (미지정 시 자동탐지)") + ap.add_argument("--book-column", default=None, help="도서 컬럼명 (분할 그룹 기준)") + ap.add_argument("--id-column", default=None, help="행 식별자 컬럼명") + ap.add_argument("--ai-jsonl", type=Path, action="append", default=[], help="AI 샘플 JSONL (반복 가능)") + ap.add_argument("--ai-csv", type=Path, action="append", default=[], help="AI 샘플 CSV (반복 가능)") + ap.add_argument("--ai-text-field", default="text", help="AI 파일의 본문 필드명") + ap.add_argument("--ai-group-field", default=None, help="AI 파일의 그룹 필드명") + ap.add_argument("--ai-generator-field", default="generator", help="생성 모델 필드명") + ap.add_argument("--min-chars", type=int, default=200, help="이 미만 길이는 제외") + ap.add_argument("--train-ratio", type=float, default=0.7) + ap.add_argument("--val-ratio", type=float, default=0.15) + ap.add_argument("--seed", type=int, default=20260810) + ap.add_argument("--out", type=Path, default=Path("data/training/ai_dataset.jsonl")) + ap.add_argument("--report", type=Path, default=None, help="요약 JSON 경로 (기본: .summary.json)") + args = ap.parse_args() + + if args.inspect: + if not args.xlsx: + ap.error("--inspect 에는 --xlsx 가 필요합니다") + inspect_xlsx(args.xlsx, args.sheet) + return 0 + + records: list[Record] = [] + if args.xlsx: + if not args.xlsx.exists(): + logger.error("xlsx 없음: %s", args.xlsx) + return 2 + records += load_human_from_xlsx( + args.xlsx, args.text_column, args.book_column, + args.id_column, args.sheet, args.min_chars, + ) + + jsonl = [p for p in args.ai_jsonl if p.exists()] + csvs = [p for p in args.ai_csv if p.exists()] + for p in list(args.ai_jsonl) + list(args.ai_csv): + if not p.exists(): + logger.error("AI 샘플 파일 없음: %s", p) + return 2 + if jsonl or csvs: + records += load_ai_records( + jsonl, csvs, args.ai_text_field, args.ai_group_field, + args.ai_generator_field, args.min_chars, + ) + + if not records: + logger.error("레코드가 0건입니다. --xlsx 또는 --ai-jsonl/--ai-csv 를 확인하세요.") + return 2 + + records, dropped = deduplicate(records) + logger.info("중복 제거: %d건 제외 → %d건", dropped, len(records)) + + labels = {r.label for r in records} + if len(labels) < 2: + logger.warning( + "라벨이 한 종류(%s)뿐입니다. 학습은 불가하며, 평가/특징분석 용도로만 " + "쓸 수 있습니다. train_ai_detector.py 는 이 데이터로 실패합니다.", + labels, + ) + + ratios = (args.train_ratio, args.val_ratio, 1 - args.train_ratio - args.val_ratio) + if ratios[2] <= 0: + logger.error("train+val 비율이 1 이상입니다: %s", ratios) + return 2 + + assignment = assign_splits(records, ratios, args.seed) + for rec in records: + rec.split = assignment.get(rec.source_group, "train") + + args.out.parent.mkdir(parents=True, exist_ok=True) + with args.out.open("w", encoding="utf-8") as fh: + for rec in records: + fh.write(json.dumps(rec.to_json(), ensure_ascii=False) + "\n") + + summary = summarize(records) + summary["dropped_duplicates"] = dropped + summary["seed"] = args.seed + summary["min_chars"] = args.min_chars + report_path = args.report or args.out.with_suffix(".summary.json") + report_path.write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + logger.info("저장: %s (%d건)", args.out, len(records)) + logger.info("요약: %s", json.dumps(summary, ensure_ascii=False)) + if summary["group_overlap_between_splits"]: + logger.error( + "분할 간 그룹 중복이 발견되었습니다: %s — 누출 위험!", + summary["group_overlap_between_splits"], + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_persistent_index.py b/scripts/build_persistent_index.py new file mode 100644 index 0000000..4a84306 --- /dev/null +++ b/scripts/build_persistent_index.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""SQLite 코퍼스의 CPU 영속 후보 인덱스를 신규 구축하거나 증분 동기화한다.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.engine.persistent_index import PersistentCorpusIndex + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--database", type=Path, required=True) + p.add_argument("--index-dir", type=Path, required=True) + p.add_argument("--features", type=int, default=2**20) + args = p.parse_args() + if not args.database.exists(): + p.error(f"database does not exist: {args.database}") + result = PersistentCorpusIndex(args.database, args.index_dir).sync(args.features) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extract_source_documents.py b/scripts/extract_source_documents.py new file mode 100644 index 0000000..3dc0263 --- /dev/null +++ b/scripts/extract_source_documents.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""PDF/DOCX 원본을 페이지/문단 provenance와 함께 SQLite에 적재한다.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.engine.provenance import CorpusStore +from app.engine.source_extraction import extract_source + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("source_dir", type=Path) + p.add_argument("--database", type=Path, required=True) + p.add_argument("--chunk-size", type=int, default=1000) + p.add_argument("--stride", type=int, default=500) + args = p.parse_args() + if not args.source_dir.is_dir(): + p.error(f"source directory does not exist: {args.source_dir}") + store = CorpusStore(args.database) + files = sorted( + path for path in args.source_dir.rglob("*") + if path.is_file() and path.suffix.lower() in {".pdf", ".docx"} + ) + report = {"files": len(files), "documents": 0, "inserted": 0, "duplicates": 0, + "warnings": [], "failures": []} + for path in files: + try: + result = extract_source(path, args.chunk_size, args.stride) + store.upsert_document(result.document) + inserted, duplicates = store.add_segments(result.segments) + report["documents"] += 1 + report["inserted"] += inserted + report["duplicates"] += duplicates + report["warnings"].extend(f"{path.name}: {w}" for w in result.warnings) + except Exception as exc: + report["failures"].append({"file": str(path), "error": str(exc)}) + report["store"] = store.stats() + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 if report["failures"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ingest_o2o_xlsx.py b/scripts/ingest_o2o_xlsx.py new file mode 100644 index 0000000..0889a4b --- /dev/null +++ b/scripts/ingest_o2o_xlsx.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""수령한 O2O XLSX를 provenance SQLite 코퍼스로 적재한다.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord, stable_id + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("xlsx", type=Path) + p.add_argument("--database", type=Path, required=True) + p.add_argument("--sheet", default=None, help="기본값: 첫 번째 시트") + p.add_argument("--book-column", default="book_name") + p.add_argument("--text-column", default="에피소드") + p.add_argument("--index-column", default="episode_index") + p.add_argument("--path-column", default="json_path") + return p.parse_args() + + +def main() -> int: + args = parse_args() + try: + import openpyxl + except ImportError: + print("openpyxl이 필요합니다: pip install openpyxl", file=sys.stderr) + return 2 + if not args.xlsx.is_file(): + print(f"파일을 찾을 수 없습니다: {args.xlsx}", file=sys.stderr) + return 2 + + wb = openpyxl.load_workbook(args.xlsx, read_only=True, data_only=True) + ws = wb[args.sheet] if args.sheet else wb.worksheets[0] + rows = ws.iter_rows(values_only=True) + headers = [str(v).strip() if v is not None else "" for v in next(rows)] + positions = {name: i for i, name in enumerate(headers)} + required = [args.book_column, args.text_column, args.index_column] + missing = [name for name in required if name not in positions] + if missing: + print(f"필수 열 없음: {missing}; 실제 열={headers}", file=sys.stderr) + return 2 + + store = CorpusStore(args.database) + store.initialize() + skipped = 0 + book_counts: Counter[str] = Counter() + documents: dict[str, DocumentRecord] = {} + segments: list[SegmentRecord] = [] + + for row in rows: + book = str(row[positions[args.book_column]] or "").strip() + text = str(row[positions[args.text_column]] or "").strip() + ordinal = str(row[positions[args.index_column]] or "").strip() + if not book or not text: + skipped += 1 + continue + source_path = None + if args.path_column in positions: + source_path = str(row[positions[args.path_column]] or "").strip() or None + document_id = stable_id("doc", book) + documents[document_id] = DocumentRecord( + document_id=document_id, + title=book, + source_path=source_path, + metadata={"import_source": args.xlsx.name}, + ) + segments.append(SegmentRecord( + segment_id=stable_id("seg", document_id, text), + document_id=document_id, + text=text, + ordinal=ordinal, + coordinate_scope="episode", + char_start=0, + char_end=len(text), + source_locator=f"{source_path or args.xlsx.name}#episode={ordinal}", + metadata={ + "provenance_quality": "episode_only", + "page_offset_available": False, + }, + )) + book_counts[book] += 1 + + store.upsert_documents(documents.values()) + inserted, duplicates = store.add_segments(segments) + + report = { + "database": str(args.database), + "sheet": ws.title, + "inserted_segments": inserted, + "duplicate_segments": duplicates, + "skipped_rows": skipped, + "source_books": len(book_counts), + "store": store.stats(), + "location_warning": ( + "수령 XLSX에는 원본 페이지/문단 offset이 없어 episode 좌표만 저장했습니다. " + "PDF/DOCX 재추출 전에는 페이지 근거를 표시할 수 없습니다." + ), + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/train_ai_detector.py b/scripts/train_ai_detector.py new file mode 100644 index 0000000..eb926da --- /dev/null +++ b/scripts/train_ai_detector.py @@ -0,0 +1,417 @@ +"""AI 생성 판별기 CPU 학습 CLI (sklearn, 외부 API·GPU 불필요). + +입력: build_ai_training_dataset.py 산출 JSONL + {"text","label","source_group","split", ...} +출력: joblib 아티팩트 + metrics JSON + +모델: + --model logreg : StandardScaler + LogisticRegression (기본, 설명 가능) + --model hgb : HistGradientBoostingClassifier (비선형, 설명력 낮음) +두 경우 모두 CalibratedClassifierCV 로 확률 보정한다. 보정하지 않은 점수를 +'의심도 %'로 UI 에 띄우면 검토자가 값을 과신하게 된다. + +평가: + AUROC / AUPRC / confusion matrix / FPR·TPR / precision·recall + + **목표 FPR 기준 임계값**. 자서전 도메인에서는 인간 저작을 AI로 오판하는 + 비용이 압도적으로 크므로, F1 최적점이 아니라 FPR 상한으로 컷을 잡는다. + +실패 조건 (조용히 넘어가지 않고 종료 코드 2): + · label 필드 없음 + · 단일 클래스만 존재 + · 그룹 수가 CV fold 수보다 적음 + · 학습/평가 표본 부족 + +사용: + python scripts/train_ai_detector.py \ + --data data/training/ai_dataset.jsonl \ + --model logreg --target-fpr 0.05 \ + --out data/models/ai_detector.joblib +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") +logger = logging.getLogger("train-ai-detector") + +from app.engine.ai_detector import ( # noqa: E402 + FEATURE_NAMES, + FEATURE_SET_VERSION, + LENGTH_FEATURES, + extract_features, + features_to_vector, +) + + +# --------------------------------------------------------------------------- +# 데이터 +# --------------------------------------------------------------------------- + +def load_dataset(path: Path) -> list[dict]: + rows: list[dict] = [] + with path.open(encoding="utf-8") as fh: + for i, line in enumerate(fh, start=1): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning("%s:%d JSON 파싱 실패 — 건너뜀", path.name, i) + return rows + + +def validate(rows: list[dict]) -> tuple[list[dict], str | None]: + """학습 가능 여부 검증. 문제가 있으면 (rows, 사유) 를 돌려준다.""" + if not rows: + return rows, "데이터가 0건입니다." + + missing = [i for i, r in enumerate(rows) if "label" not in r] + if missing: + return rows, f"label 필드가 없는 행 {len(missing)}건 (예: index {missing[:3]})" + + usable = [] + for r in rows: + text = (r.get("text") or "").strip() + if not text: + continue + try: + label = int(r["label"]) + except (TypeError, ValueError): + return rows, f"label 을 정수로 해석할 수 없습니다: {r['label']!r}" + if label not in (0, 1): + return rows, f"label 은 0/1 이어야 합니다: {label}" + r["label"] = label + usable.append(r) + + if not usable: + return usable, "본문이 있는 행이 없습니다." + + counts = Counter(r["label"] for r in usable) + if len(counts) < 2: + return usable, ( + f"단일 클래스만 존재합니다({dict(counts)}). AI 샘플과 human 샘플이 " + "모두 필요합니다." + ) + if min(counts.values()) < 10: + return usable, f"소수 클래스 표본이 너무 적습니다: {dict(counts)} (최소 10건)" + + groups = {r.get("source_group") or "" for r in usable} + if len(groups) < 4: + return usable, ( + f"source_group 이 {len(groups)}개뿐입니다. group 분할 검증이 " + "불가능하며 성능이 부풀려집니다. 최소 4개 필요." + ) + return usable, None + + +def featurize( + rows: list[dict], use_pos: bool, zeroed: tuple[str, ...] = () +) -> tuple[list[list[float]], list[int], list[str]]: + X: list[list[float]] = [] + y: list[int] = [] + groups: list[str] = [] + for i, r in enumerate(rows): + feats = extract_features(r["text"], use_pos=use_pos) + X.append(features_to_vector(feats, zeroed)) + y.append(int(r["label"])) + groups.append(str(r.get("source_group") or f"row:{i}")) + if (i + 1) % 500 == 0: + logger.info("특징 추출 %d/%d", i + 1, len(rows)) + return X, y, groups + + +# --------------------------------------------------------------------------- +# 평가 +# --------------------------------------------------------------------------- + +def threshold_at_fpr(y_true, scores, target_fpr: float) -> tuple[float, dict]: + """목표 FPR 이하를 만족하는 최소 임계값과 그 지점의 지표.""" + from sklearn.metrics import roc_curve + + fpr, tpr, thr = roc_curve(y_true, scores) + chosen = None + for f, t, th in zip(fpr, tpr, thr): + if f <= target_fpr: + chosen = (float(f), float(t), float(th)) + if chosen is None: + chosen = (float(fpr[0]), float(tpr[0]), float(thr[0])) + return chosen[2], {"fpr": chosen[0], "tpr": chosen[1], "threshold": chosen[2]} + + +def evaluate(y_true, scores, threshold: float) -> dict: + from sklearn.metrics import ( + average_precision_score, + confusion_matrix, + roc_auc_score, + ) + + preds = [1 if s >= threshold else 0 for s in scores] + tn, fp, fn, tp = confusion_matrix(y_true, preds, labels=[0, 1]).ravel() + precision = tp / (tp + fp) if (tp + fp) else 0.0 + recall = tp / (tp + fn) if (tp + fn) else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 + return { + "threshold": round(float(threshold), 4), + "auroc": round(float(roc_auc_score(y_true, scores)), 4), + "auprc": round(float(average_precision_score(y_true, scores)), 4), + "confusion_matrix": {"tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp)}, + "fpr": round(fp / (fp + tn), 4) if (fp + tn) else 0.0, + "tpr": round(recall, 4), + "precision": round(precision, 4), + "recall": round(recall, 4), + "f1": round(f1, 4), + "n": len(y_true), + "positives": int(sum(y_true)), + } + + +# --------------------------------------------------------------------------- +# 학습 +# --------------------------------------------------------------------------- + +def build_estimator(kind: str, seed: int): + from sklearn.ensemble import HistGradientBoostingClassifier + from sklearn.linear_model import LogisticRegression + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import StandardScaler + + if kind == "logreg": + return Pipeline([ + ("scaler", StandardScaler()), + ("clf", LogisticRegression( + max_iter=2000, class_weight="balanced", random_state=seed + )), + ]) + if kind == "hgb": + return HistGradientBoostingClassifier( + max_iter=300, learning_rate=0.06, max_depth=6, random_state=seed + ) + raise ValueError(f"unknown model: {kind}") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--data", type=Path, required=True) + ap.add_argument("--model", choices=["logreg", "hgb"], default="logreg") + ap.add_argument("--out", type=Path, default=Path("data/models/ai_detector.joblib")) + ap.add_argument("--metrics-out", type=Path, default=None) + ap.add_argument("--target-fpr", type=float, default=0.05, + help="이 FPR 이하가 되도록 임계값을 잡는다 (인간 오판 억제)") + ap.add_argument("--high-fpr", type=float, default=0.01, + help="'높음' 배지용 더 보수적인 FPR") + ap.add_argument("--cv-folds", type=int, default=5) + ap.add_argument("--seed", type=int, default=20260810) + ap.add_argument("--no-pos", action="store_true", help="품사 특징 사용 안 함") + ap.add_argument( + "--drop-length-features", action="store_true", + help="길이 계열 특징을 0으로 눌러 제외. human/AI 표본의 분량 분포가 다르면 " + "모델이 문체 대신 길이를 학습하므로, 실데이터에서는 켜고 한 번 " + "돌려 성능 차이를 반드시 비교할 것.", + ) + ap.add_argument("--trained-at", default="", help="아티팩트에 기록할 학습 시각 문자열") + args = ap.parse_args() + + try: + import numpy as np + import sklearn + from sklearn.calibration import CalibratedClassifierCV + except ImportError as exc: + logger.error("scikit-learn/numpy 가 필요합니다: %s", exc) + return 2 + + if not args.data.exists(): + logger.error("데이터 없음: %s", args.data) + return 2 + + rows = load_dataset(args.data) + rows, reason = validate(rows) + if reason: + logger.error("학습 불가: %s", reason) + return 2 + + use_pos = not args.no_pos + zeroed: tuple[str, ...] = LENGTH_FEATURES if args.drop_length_features else () + logger.info( + "레코드 %d건, 품사특징=%s, 제외특징=%s", + len(rows), use_pos, list(zeroed) or "없음", + ) + + # split 필드가 있으면 그대로 신뢰 (빌더가 group 단위로 만든 것). + # train=모델 적합, val=임계값 선택, test=최종 보고로 역할을 섞지 않는다. + has_split = any(r.get("split") for r in rows) + if has_split: + train_rows = [r for r in rows if r.get("split") == "train"] + val_rows = [r for r in rows if r.get("split") == "val"] + test_rows = [r for r in rows if r.get("split") == "test"] + if not train_rows or not val_rows or not test_rows: + logger.error("train/val/test 중 빈 split이 있습니다. 빌더 분할을 확인하세요.") + return 2 + train_groups = {r.get("source_group") for r in train_rows} + val_groups = {r.get("source_group") for r in val_rows} + test_groups = {r.get("source_group") for r in test_rows} + overlap = (train_groups & val_groups) | (train_groups & test_groups) | (val_groups & test_groups) + if overlap: + logger.error( + "train/val/test 그룹 중복 %d건 — 누출입니다. 중단합니다: %s", + len(overlap), list(overlap)[:5], + ) + return 2 + else: + from sklearn.model_selection import GroupShuffleSplit + + all_groups = [str(r.get("source_group") or "") for r in rows] + gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=args.seed) + fit_idx, te_idx = next(gss.split(rows, [r["label"] for r in rows], all_groups)) + fit_rows = [rows[i] for i in fit_idx] + fit_groups = [all_groups[i] for i in fit_idx] + val_split = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=args.seed + 1) + tr_local, va_local = next(val_split.split( + fit_rows, [r["label"] for r in fit_rows], fit_groups + )) + train_rows = [fit_rows[i] for i in tr_local] + val_rows = [fit_rows[i] for i in va_local] + test_rows = [rows[i] for i in te_idx] + logger.warning("split 필드가 없어 GroupShuffleSplit 으로 train/val/test를 나눴습니다.") + + logger.info("train=%d / val=%d / test=%d", len(train_rows), len(val_rows), len(test_rows)) + for name, subset in (("train", train_rows), ("val", val_rows), ("test", test_rows)): + counts = Counter(r["label"] for r in subset) + if len(counts) < 2: + logger.error("%s 에 단일 클래스만 있습니다: %s", name, dict(counts)) + return 2 + logger.info("%s 라벨 분포: %s", name, dict(counts)) + + X_tr, y_tr, g_tr = featurize(train_rows, use_pos, zeroed) + X_val, y_val, _ = featurize(val_rows, use_pos, zeroed) + X_te, y_te, _ = featurize(test_rows, use_pos, zeroed) + + n_groups = len(set(g_tr)) + folds = min(args.cv_folds, n_groups) + if folds < 2: + logger.error("train 그룹이 %d개뿐이라 교차검증 보정이 불가합니다.", n_groups) + return 2 + if folds < args.cv_folds: + logger.warning("그룹 수가 적어 CV fold 를 %d → %d 로 줄입니다.", args.cv_folds, folds) + + base = build_estimator(args.model, args.seed) + try: + from sklearn.model_selection import StratifiedGroupKFold + + cv = StratifiedGroupKFold(n_splits=folds, shuffle=True, random_state=args.seed) + cv_split = list(cv.split(np.array(X_tr), np.array(y_tr), groups=g_tr)) + calibrated = CalibratedClassifierCV(base, method="sigmoid", cv=cv_split) + except Exception as exc: + logger.warning("StratifiedGroupKFold 사용 불가(%s) — 일반 CV 로 대체", exc) + calibrated = CalibratedClassifierCV(base, method="sigmoid", cv=folds) + + logger.info("학습 시작 (model=%s, folds=%d)", args.model, folds) + calibrated.fit(np.array(X_tr), np.array(y_tr)) + + scores_te = calibrated.predict_proba(np.array(X_te))[:, 1] + scores_tr = calibrated.predict_proba(np.array(X_tr))[:, 1] + scores_val = calibrated.predict_proba(np.array(X_val))[:, 1] + + # 임계값은 모델이 보지 않은 val에서 잡고 test는 최종 보고에만 사용한다. + low_cut, low_info = threshold_at_fpr(y_val, scores_val, args.target_fpr) + high_cut, high_info = threshold_at_fpr(y_val, scores_val, args.high_fpr) + val_negatives = y_val.count(0) + if val_negatives < 100: + logger.warning( + "val human 표본이 %d건뿐이라 FPR %.3f 컷 추정이 불안정합니다(최소 100, 권장 300).", + val_negatives, args.high_fpr, + ) + if high_cut < low_cut: + high_cut = low_cut + if high_cut <= low_cut: + logger.warning( + "low_cut 과 high_cut 이 같습니다(%.4f). 'medium' 배지가 사라져 " + "모든 결과가 low/high 로만 갈립니다. 표본이 너무 쉽게 분리되거나 " + "표본 수가 부족하다는 신호이니, 실데이터에서 이 경고가 뜨면 " + "--target-fpr/--high-fpr 을 벌리고 데이터를 재점검하세요.", + low_cut, + ) + + metrics = { + "model": args.model, + "feature_set_version": FEATURE_SET_VERSION, + "use_pos": use_pos, + "zeroed_features": list(zeroed), + "n_train": len(y_tr), + "n_val": len(y_val), + "n_test": len(y_te), + "n_train_groups": n_groups, + "cv_folds": folds, + "cuts": { + "low_cut": round(float(low_cut), 4), + "high_cut": round(float(high_cut), 4), + "target_fpr": args.target_fpr, + "high_fpr": args.high_fpr, + "validation_low_point": low_info, + "validation_high_point": high_info, + }, + "test": evaluate(y_te, scores_te, low_cut), + "test_at_high_cut": evaluate(y_te, scores_te, high_cut), + "validation": evaluate(y_val, scores_val, low_cut), + "train": evaluate(y_tr, scores_tr, low_cut), + } + + logger.info("test AUROC=%.4f AUPRC=%.4f FPR=%.4f TPR=%.4f", + metrics["test"]["auroc"], metrics["test"]["auprc"], + metrics["test"]["fpr"], metrics["test"]["tpr"]) + logger.info("confusion(test): %s", metrics["test"]["confusion_matrix"]) + + suffix = "-nolen" if zeroed else "" + model_version = ( + f"{args.model}{suffix}-{FEATURE_SET_VERSION}" + f"-auroc{metrics['test']['auroc']:.3f}" + ) + payload = { + "estimator": calibrated, + "feature_names": tuple(FEATURE_NAMES), + "feature_set_version": FEATURE_SET_VERSION, + "model_version": model_version, + "requires_pos": use_pos, + "low_cut": float(low_cut), + "high_cut": float(high_cut), + "metrics": metrics, + "zeroed_features": tuple(zeroed), + "trained_at": args.trained_at, + "sklearn_version": sklearn.__version__, + "notes": ( + "AI 생성 '의심도' 모델. 확정 판정이 아니며 사람 검토 보조용. " + "임계값은 target FPR 기준으로 산출됨." + ), + } + + import joblib + + args.out.parent.mkdir(parents=True, exist_ok=True) + joblib.dump(payload, args.out) + metrics_path = args.metrics_out or args.out.with_suffix(".metrics.json") + metrics_path.write_text( + json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + logger.info("아티팩트 저장: %s (version=%s)", args.out, model_version) + logger.info("지표 저장: %s", metrics_path) + + if metrics["test"]["auroc"] < 0.7: + logger.warning( + "AUROC %.3f — 실용 수준이 아닙니다. 이 모델을 운영에 붙이지 마세요.", + metrics["test"]["auroc"], + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_precedents.py b/scripts/validate_precedents.py new file mode 100644 index 0000000..345d226 --- /dev/null +++ b/scripts/validate_precedents.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""판례 JSONL 스키마와 사건번호/출처 중복을 검증한다.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.engine.legal_risk import load_precedents + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("jsonl", type=Path) + args = p.parse_args() + precedents = load_precedents(args.jsonl) + print(json.dumps({"valid": True, "precedent_count": len(precedents)}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ai_detector.py b/tests/test_ai_detector.py new file mode 100644 index 0000000..69fb157 --- /dev/null +++ b/tests/test_ai_detector.py @@ -0,0 +1,513 @@ +"""AI 생성 의심도 모듈 단위테스트. + +원칙: 외부 모델 다운로드·네트워크·sklearn 설치 없이 전부 통과해야 한다. +학습 모델이 필요한 경로는 stub estimator 로 대체해 검증한다. +""" + +from __future__ import annotations + +import json + +import pytest + +from app.engine import ai_detector as ad +from app.engine.ai_detector import ( + FEATURE_NAMES, + HARD_MIN_CHARS, + AiGenerationDetector, + ModelArtifact, + extract_features, + features_to_vector, + load_artifact, + normalize_text, + split_paragraphs, + split_sentences, +) + + +# --------------------------------------------------------------------------- +# 샘플 텍스트 +# --------------------------------------------------------------------------- + +# 문장 길이가 균일하고 쉼표가 많은 글 (AI 경향 쪽) +UNIFORM_TEXT = ( + "그날의 기억은 오래도록 남아, 지금까지도 선명하게 떠오르는 장면이 되었다. " + "아침의 공기는 서늘했고, 골목을 지나는 사람들의 발걸음은 조용히 이어졌다. " + "어머니는 부엌에서 국을 끓였고, 그 냄새는 온 집안을 천천히 채워 나갔다. " + "나는 책상에 앉아 공책을 펼쳤고, 연필을 쥔 손에는 힘이 들어가 있었다. " + "창밖으로는 햇빛이 들어왔고, 마당의 나무는 잎을 조금씩 흔들고 있었다. " + "그 시절의 하루는 언제나, 비슷한 순서로 조용하게 흘러가고 있었다." +) + +# 문장 길이가 들쭉날쭉하고 쉼표가 적은 글 (사람 경향 쪽) +BURSTY_TEXT = ( + "비가 왔다. " + "나는 그날 아침에 학교에 가지 않았고 대신 뒷산에 올라가서 온종일 아무것도 하지 않은 채로 " + "그냥 젖은 흙냄새를 맡으며 앉아 있었는데 지금 생각하면 그게 무슨 의미였는지 잘 모르겠다. " + "춥지는 않았다. " + "형이 나를 찾으러 왔다. " + "우리는 말없이 내려왔고 집에 도착했을 때 어머니는 아무 말도 하지 않으셨다. " + "저녁을 먹었다. " + "그게 전부였다." +) + +LONG_TEXT = "\n\n".join([UNIFORM_TEXT, BURSTY_TEXT, UNIFORM_TEXT, BURSTY_TEXT]) + + +# --------------------------------------------------------------------------- +# 정규화 / 분해 +# --------------------------------------------------------------------------- + +def test_normalize_collapses_whitespace(): + assert normalize_text("가나 다\r\n라") == "가나 다\n라" + assert normalize_text("") == "" + assert normalize_text(" \n ") == "" + + +def test_split_sentences_and_paragraphs(): + assert len(split_sentences("첫 문장이다. 둘째 문장이다! 셋째인가?")) == 3 + assert len(split_paragraphs("문단 하나.\n\n문단 둘.")) == 2 + # 빈 입력에도 죽지 않아야 한다 + assert split_sentences("") == [] + assert split_paragraphs("") == [] + + +# --------------------------------------------------------------------------- +# 특징 추출 +# --------------------------------------------------------------------------- + +def test_features_cover_all_names_and_are_finite(): + feats = extract_features(UNIFORM_TEXT) + assert set(feats) == set(FEATURE_NAMES), "특징 키 누락/초과" + for name, val in feats.items(): + assert isinstance(val, float), f"{name} 이 float 이 아님" + assert val == val, f"{name} 이 NaN" + + +def test_feature_vector_length_and_order_stable(): + feats = extract_features(UNIFORM_TEXT) + vec = features_to_vector(feats) + assert len(vec) == len(FEATURE_NAMES) + # 순서 계약: 벡터 i번째는 FEATURE_NAMES[i] 값 + for i, name in enumerate(FEATURE_NAMES): + assert vec[i] == pytest.approx(feats[name]) + + +def test_zeroed_features_are_masked_but_length_preserved(): + """길이 특징 제외 시에도 벡터 길이는 불변이어야 한다 (모델 입력 계약).""" + feats = extract_features(UNIFORM_TEXT) + full = features_to_vector(feats) + masked = features_to_vector(feats, ad.LENGTH_FEATURES) + assert len(masked) == len(full) == len(FEATURE_NAMES) + for i, name in enumerate(FEATURE_NAMES): + if name in ad.LENGTH_FEATURES: + assert masked[i] == 0.0, f"{name} 이 0으로 눌리지 않음" + else: + assert masked[i] == pytest.approx(full[i]) + assert full != masked, "길이 특징이 원래 0이면 이 테스트가 무의미해진다" + + +def test_detector_applies_artifact_zeroed_features(): + """학습 때 제외한 특징이 추론에서도 동일하게 제외되어야 한다.""" + det = _stub_detector() + # 모든 특징에 균등 계수를 줘야 '제외되지 않으면 상위에 뜬다'가 성립한다 + det.artifact.estimator.coef_ = [[1.0] * len(FEATURE_NAMES)] + det.artifact.zeroed_features = ad.LENGTH_FEATURES + res = det.detect(UNIFORM_TEXT, with_segments=False) + names = {n for n, _ in res.top_contributions} + assert names, "기여도가 비어 있으면 검증이 성립하지 않는다" + assert not (names & set(ad.LENGTH_FEATURES)), ( + "제외된 길이 특징이 기여도에 나타나면 학습/추론 불일치" + ) + + +def test_extract_features_is_deterministic(): + a = extract_features(UNIFORM_TEXT) + b = extract_features(UNIFORM_TEXT) + assert a == b + + +def test_empty_text_yields_zero_vector(): + feats = extract_features("") + assert all(v == 0.0 for v in feats.values()) + assert len(features_to_vector(feats)) == len(FEATURE_NAMES) + + +def test_uniform_text_has_lower_sentence_variance_than_bursty(): + """문장 길이 변동계수는 AI 탐지의 핵심 표지 — 방향이 맞는지 확인.""" + uniform = extract_features(UNIFORM_TEXT) + bursty = extract_features(BURSTY_TEXT) + assert uniform["cv_sentence_len"] < bursty["cv_sentence_len"] + assert uniform["comma_per_sentence"] > bursty["comma_per_sentence"] + + +def test_features_depend_on_structure_not_characters(): + """해시 더미가 아님을 보증 — 구조가 같으면 구조 특징도 같아야 한다.""" + base = "가가가 가가가 가가가다. 가가가 가가가 가가가다. 가가가 가가가 가가가다." + swapped = base.replace("가", "나") + fa, fb = extract_features(base), extract_features(swapped) + for key in ("cv_sentence_len", "mean_sentence_len", "mean_eojeol_len", + "space_ratio", "sentence_count", "eojeol_count"): + assert fa[key] == pytest.approx(fb[key]), key + + +def test_pos_features_absent_when_kiwi_unavailable(monkeypatch): + monkeypatch.setattr(ad, "_get_kiwi", lambda: None) + feats = extract_features(UNIFORM_TEXT) + assert feats["pos_available"] == 0.0 + assert feats["morph_count"] == 0.0 + # 벡터 길이는 그대로여야 한다 (모델 입력 계약) + assert len(features_to_vector(feats)) == len(FEATURE_NAMES) + + +def test_pos_extraction_survives_tokenizer_exception(monkeypatch): + class Boom: + def tokenize(self, text): + raise RuntimeError("tokenizer exploded") + + monkeypatch.setattr(ad, "_get_kiwi", lambda: Boom()) + feats = extract_features(UNIFORM_TEXT) # 예외가 새어 나오면 실패 + assert feats["pos_available"] == 0.0 + assert feats["char_count"] > 0 + + +# --------------------------------------------------------------------------- +# 미가용 모드 — 점수를 지어내지 않는다 +# --------------------------------------------------------------------------- + +def test_unavailable_when_no_model(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=False) + assert det.mode == "unavailable" + res = det.detect(UNIFORM_TEXT) + assert res.available is False + assert res.score is None + assert res.suspicion_level is None + assert res.provenance == "unknown" + assert res.model_version == "unavailable" + assert "model_not_trained" in res.warnings + assert res.is_stub is False # 더미를 준 게 아니라 아예 안 준 것 + + +def test_load_artifact_missing_file_returns_none(tmp_path): + assert load_artifact(tmp_path / "absent.joblib") is None + + +# --------------------------------------------------------------------------- +# 휴리스틱 baseline 모드 — 반드시 스텁임이 드러나야 한다 +# --------------------------------------------------------------------------- + +def test_heuristic_mode_is_flagged_as_stub(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + assert det.mode == "heuristic" + res = det.detect(UNIFORM_TEXT) + assert res.available is True + assert res.is_stub is True + assert res.model_version == "heuristic-baseline-v1" + assert "휴리스틱" in res.note + assert 0.0 <= res.score <= 1.0 + assert res.suspicion_level in ("low", "medium", "high") + + +def test_heuristic_is_deterministic(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + first = det.detect(UNIFORM_TEXT) + second = det.detect(UNIFORM_TEXT) + assert first.score == second.score + assert first.provenance == second.provenance + + +def test_heuristic_ranks_uniform_above_bursty(tmp_path): + """규칙이 실제로 언어특징을 반영하는지 (방향성 검증).""" + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + uniform = det.detect(UNIFORM_TEXT, with_segments=False) + bursty = det.detect(BURSTY_TEXT, with_segments=False) + assert uniform.score > bursty.score + + +def test_heuristic_score_is_pure_function_of_features(): + low = dict.fromkeys(FEATURE_NAMES, 0.0) + low.update({"cv_sentence_len": 0.75, "comma_per_sentence": 0.60, + "hapax_ratio": 0.78, "mean_sentence_len": 38.0}) + high = dict.fromkeys(FEATURE_NAMES, 0.0) + high.update({"cv_sentence_len": 0.35, "comma_per_sentence": 1.40, + "hapax_ratio": 0.60, "mean_sentence_len": 58.0}) + assert ad._heuristic_score(high) > ad._heuristic_score(low) + assert ad._heuristic_score(low) == ad._heuristic_score(dict(low)) + + +def test_heuristic_contributions_are_reported(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + res = det.detect(UNIFORM_TEXT, with_segments=False) + assert res.top_contributions, "설명 근거가 비어 있으면 안 된다" + names = {n for n, _ in res.top_contributions} + assert names <= set(FEATURE_NAMES) + + +# --------------------------------------------------------------------------- +# 짧은 텍스트 처리 +# --------------------------------------------------------------------------- + +def test_too_short_text_refuses_to_score(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + res = det.detect("짧은 글이다. 이건 통계가 안 나온다.") + assert res.available is False + assert res.score is None + assert "text_too_short" in res.warnings + assert str(HARD_MIN_CHARS) in res.note + + +def test_short_but_scorable_text_gets_warning(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + text = "오늘은 비가 내렸다. " * 12 # HARD_MIN 초과, MIN_RELIABLE 미만 구간 + assert HARD_MIN_CHARS <= len(text.strip()) < ad.MIN_RELIABLE_CHARS + res = det.detect(text) + assert res.available is True + assert "short_text_low_confidence" in res.warnings + + +# --------------------------------------------------------------------------- +# 구간(segment) 채점 +# --------------------------------------------------------------------------- + +def test_segments_have_valid_offsets(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + res = det.detect(LONG_TEXT) + assert res.segments, "긴 글은 구간이 나와야 한다" + norm = normalize_text(LONG_TEXT) + for seg in res.segments: + assert 0 <= seg.start <= seg.end <= len(norm) + assert seg.char_count > 0 + if seg.scored: + assert seg.score is not None and 0.0 <= seg.score <= 1.0 + assert seg.suspicion_level in ("low", "medium", "high") + else: + assert seg.score is None + + +def test_segments_can_be_disabled(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + res = det.detect(LONG_TEXT, with_segments=False) + assert res.segments == [] + + +# --------------------------------------------------------------------------- +# provenance +# --------------------------------------------------------------------------- + +def test_provenance_is_valid_value(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + for text in (UNIFORM_TEXT, BURSTY_TEXT, LONG_TEXT): + res = det.detect(text) + assert res.provenance in ("human", "ai", "mixed", "edited", "unknown") + + +def test_provenance_mixed_when_segments_span_both_ends(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + segs = [ + ad.SegmentScore(index=0, start=0, end=10, char_count=600, score=0.9, + suspicion_level="high", scored=True), + ad.SegmentScore(index=1, start=10, end=20, char_count=600, score=0.1, + suspicion_level="low", scored=True), + ] + assert det._infer_provenance(0.5, segs) == "mixed" + + +def test_provenance_human_when_all_low(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + segs = [ + ad.SegmentScore(index=i, start=0, end=10, char_count=600, score=0.1, + suspicion_level="low", scored=True) + for i in range(3) + ] + assert det._infer_provenance(0.1, segs) == "human" + + +def test_provenance_edited_when_all_medium(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True) + segs = [ + ad.SegmentScore(index=i, start=0, end=10, char_count=600, score=0.55, + suspicion_level="medium", scored=True) + for i in range(3) + ] + assert det._infer_provenance(0.55, segs) == "edited" + + +# --------------------------------------------------------------------------- +# 학습 모델 경로 (stub estimator — sklearn/joblib 불필요) +# --------------------------------------------------------------------------- + +class _StubEstimator: + """coef_ 를 가진 최소 선형 분류기 흉내. 첫 특징만 보고 확률을 낸다.""" + + def __init__(self, probability: float = 0.82): + self.probability = probability + self.coef_ = [[0.0] * len(FEATURE_NAMES)] + self.coef_[0][FEATURE_NAMES.index("cv_sentence_len")] = -1.5 + self.coef_[0][FEATURE_NAMES.index("comma_per_sentence")] = 1.2 + + def predict_proba(self, X): + return [[1.0 - self.probability, self.probability] for _ in X] + + +def _stub_detector(probability: float = 0.82, requires_pos: bool = False): + det = AiGenerationDetector(model_path="/nonexistent", allow_heuristic=False) + det.artifact = ModelArtifact( + estimator=_StubEstimator(probability), + feature_names=FEATURE_NAMES, + feature_set_version=ad.FEATURE_SET_VERSION, + model_version="stub-test-v1", + requires_pos=requires_pos, + low_cut=0.40, + high_cut=0.70, + metrics={}, + ) + return det + + +def test_trained_mode_reports_not_stub(): + det = _stub_detector(0.82) + assert det.mode == "trained" + res = det.detect(UNIFORM_TEXT) + assert res.available is True + assert res.is_stub is False + assert res.model_version == "stub-test-v1" + assert res.score == pytest.approx(0.82) + assert res.suspicion_level == "high" + assert "확정 판정이 아닙니다" in res.note + + +def test_trained_mode_uses_artifact_cuts(): + assert _stub_detector(0.10).detect(UNIFORM_TEXT).suspicion_level == "low" + assert _stub_detector(0.55).detect(UNIFORM_TEXT).suspicion_level == "medium" + assert _stub_detector(0.95).detect(UNIFORM_TEXT).suspicion_level == "high" + + +def test_linear_contributions_extracted_from_estimator(): + det = _stub_detector() + res = det.detect(UNIFORM_TEXT, with_segments=False) + names = {n for n, _ in res.top_contributions} + # 계수가 0이 아닌 특징이 상위에 올라와야 한다 + assert {"cv_sentence_len", "comma_per_sentence"} & names + + +def test_pos_mismatch_warning_when_model_requires_pos(monkeypatch): + monkeypatch.setattr(ad, "_get_kiwi", lambda: None) + det = _stub_detector(requires_pos=True) + res = det.detect(UNIFORM_TEXT, with_segments=False) + assert "pos_required_by_model_but_missing" in res.warnings + assert res.pos_available is False + + +def test_inference_failure_is_reported_not_swallowed(): + class Exploding: + def predict_proba(self, X): + raise RuntimeError("boom") + + det = _stub_detector() + det.artifact.estimator = Exploding() + res = det.detect(UNIFORM_TEXT) + assert res.available is False + assert res.score is None + assert "inference_failed" in res.warnings + + +# --------------------------------------------------------------------------- +# 직렬화 +# --------------------------------------------------------------------------- + +def test_to_dict_is_json_serializable(): + det = _stub_detector() + payload = det.detect(LONG_TEXT).to_dict() + encoded = json.dumps(payload, ensure_ascii=False) + restored = json.loads(encoded) + assert restored["model_version"] == "stub-test-v1" + assert restored["is_stub"] is False + assert restored["provenance"] in ("human", "ai", "mixed", "edited", "unknown") + assert isinstance(restored["segments"], list) + + +def test_to_dict_of_unavailable_result(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=False) + payload = det.detect(UNIFORM_TEXT).to_dict() + json.dumps(payload, ensure_ascii=False) # 예외 없으면 통과 + assert payload["available"] is False + assert payload["score"] is None + + +# --------------------------------------------------------------------------- +# 싱글턴 접근자 +# --------------------------------------------------------------------------- + +def test_get_ai_detector_respects_env(monkeypatch, tmp_path): + ad.reset_detector_cache() + monkeypatch.setenv("AI_DETECTOR_ALLOW_HEURISTIC", "true") + monkeypatch.setenv(ad.ENV_MODEL_PATH, str(tmp_path / "none.joblib")) + det = ad.get_ai_detector() + assert det.allow_heuristic is True + assert det.mode == "heuristic" + ad.reset_detector_cache() + + +def test_get_ai_detector_defaults_to_unavailable(monkeypatch, tmp_path): + ad.reset_detector_cache() + monkeypatch.delenv("AI_DETECTOR_ALLOW_HEURISTIC", raising=False) + monkeypatch.setenv(ad.ENV_MODEL_PATH, str(tmp_path / "none.joblib")) + det = ad.get_ai_detector() + assert det.allow_heuristic is False + assert det.mode == "unavailable" + ad.reset_detector_cache() + + +# --------------------------------------------------------------------------- +# 아티팩트 파일 검증 (joblib 있을 때만) +# --------------------------------------------------------------------------- + +try: + import joblib +except ImportError: # joblib 미설치 환경에서도 나머지 테스트는 돌아야 한다 + joblib = None + +requires_joblib = pytest.mark.skipif( + joblib is None, reason="joblib 미설치 — 파일 아티팩트 검증 생략" +) + + +@requires_joblib +def test_malformed_artifact_returns_none(tmp_path): + path = tmp_path / "bad.joblib" + joblib.dump({"not_an_estimator": 1}, path) + assert load_artifact(path) is None + + +@requires_joblib +def test_feature_mismatch_artifact_is_rejected(tmp_path): + path = tmp_path / "mismatch.joblib" + joblib.dump( + {"estimator": _StubEstimator(), "feature_names": ("only_one_feature",)}, + path, + ) + assert load_artifact(path) is None, "특징 불일치 아티팩트는 로드 거부되어야 한다" + + +@requires_joblib +def test_valid_artifact_roundtrip(tmp_path): + path = tmp_path / "ok.joblib" + joblib.dump( + { + "estimator": _StubEstimator(0.66), + "feature_names": tuple(FEATURE_NAMES), + "feature_set_version": ad.FEATURE_SET_VERSION, + "model_version": "roundtrip-v1", + "requires_pos": False, + "low_cut": 0.4, + "high_cut": 0.7, + "metrics": {"test": {"auroc": 0.9}}, + }, + path, + ) + art = load_artifact(path) + assert art is not None + assert art.model_version == "roundtrip-v1" + det = AiGenerationDetector(model_path=path, allow_heuristic=False) + assert det.mode == "trained" + assert det.detect(UNIFORM_TEXT, with_segments=False).score == pytest.approx(0.66) diff --git a/tests/test_legal_risk.py b/tests/test_legal_risk.py new file mode 100644 index 0000000..4d49eed --- /dev/null +++ b/tests/test_legal_risk.py @@ -0,0 +1,39 @@ +from app.engine.legal_risk import LegalRiskEngine, Precedent, load_precedents + + +def _case() -> Precedent: + return Precedent( + case_id="case-1", + title="테스트 판례", + source_url="https://example.test/case-1", + work_types=("literary",), + legal_tags=("reproduction",), + criteria=("실질적 유사성",), + holding_summary="테스트 요약", + ) + + +def test_empty_precedents_never_claim_legal_conclusion(): + result = LegalRiskEngine([]).assess( + max_similarity=0.95, coverage=0.8, longest_span=300, + legal_tags=["reproduction"], + ) + assert result.status == "insufficient_precedent_data" + assert result.risk_level is None + + +def test_risk_requires_missing_facts_and_only_registered_case_ids(): + result = LegalRiskEngine([_case()]).assess( + max_similarity=0.9, coverage=0.5, longest_span=200, + legal_tags=["reproduction"], + ) + assert result.status == "review_required" + assert result.risk_level == "high" + assert result.precedent_ids == ("case-1",) + assert len(result.missing_factors) == 3 + + +def test_repository_seed_is_valid(): + cases = load_precedents("data/precedents/precedents.jsonl") + assert cases + assert cases[0].case_id == "2012다73493" diff --git a/tests/test_provenance_index.py b/tests/test_provenance_index.py new file mode 100644 index 0000000..2dbd579 --- /dev/null +++ b/tests/test_provenance_index.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from app.engine.persistent_index import PersistentCorpusIndex +from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord + + +def _segment(segment_id: str, text: str) -> SegmentRecord: + return SegmentRecord( + segment_id=segment_id, + document_id="doc-1", + text=text, + ordinal=segment_id, + char_start=0, + char_end=len(text), + source_locator=f"book.json#{segment_id}", + ) + + +def test_store_deduplicates_same_text_per_document(tmp_path): + store = CorpusStore(tmp_path / "corpus.sqlite3") + store.upsert_document(DocumentRecord(document_id="doc-1", title="책")) + first = _segment("seg-1", "같은 원문입니다.") + second = _segment("seg-2", "같은 원문입니다.") + assert store.add_segments([first, second]) == (1, 1) + assert store.stats()["segments"] == 1 + + +def test_index_sync_appends_without_rebuilding(tmp_path): + db = tmp_path / "corpus.sqlite3" + index_dir = tmp_path / "index" + store = CorpusStore(db) + store.upsert_document(DocumentRecord(document_id="doc-1", title="책")) + store.add_segments([_segment("seg-1", "나는 어린 시절 바닷가 마을에서 살았다.")]) + index = PersistentCorpusIndex(db, index_dir) + assert index.sync()["mode"] == "rebuild" + store.add_segments([_segment("seg-2", "학교를 졸업하고 서울로 올라왔다.")]) + result = index.sync() + assert result == {"mode": "append", "total": 2, "added": 1} + + +def test_index_returns_provenance_and_evidence(tmp_path): + db = tmp_path / "corpus.sqlite3" + store = CorpusStore(db) + store.upsert_document(DocumentRecord(document_id="doc-1", title="나의 자서전")) + text = "나는 어린 시절 바닷가 마을에서 살았고 매일 파도 소리를 들었다." + store.add_segments([_segment("seg-1", text)]) + index = PersistentCorpusIndex(db, tmp_path / "index") + index.sync() + hit = index.query("바닷가 마을에서 살았고 매일 파도 소리를 들었다.", top_k=1)[0] + assert hit.segment_id == "seg-1" + assert hit.title == "나의 자서전" + assert hit.source_locator == "book.json#seg-1" + assert hit.longest_span >= 12 + assert hit.coverage > 0.5 + + +def test_long_query_finds_middle_copy_without_dilution(tmp_path): + db = tmp_path / "corpus.sqlite3" + store = CorpusStore(db) + store.upsert_document(DocumentRecord(document_id="doc-1", title="원본")) + copied = "바닷가 마을에서 파도 소리를 들으며 자란 기억이 아직도 선명하다. " * 5 + store.add_segments([_segment("seg-1", copied)]) + index = PersistentCorpusIndex(db, tmp_path / "index") + index.sync() + query = ("전혀 다른 앞부분입니다. " * 200) + copied + ("다른 뒷부분입니다. " * 200) + hit = index.query(query, top_k=1)[0] + assert hit.segment_id == "seg-1" + assert hit.longest_span >= 100 + assert hit.evidence[0]["start"] > 1000 diff --git a/tests/test_source_extraction.py b/tests/test_source_extraction.py new file mode 100644 index 0000000..6fe508a --- /dev/null +++ b/tests/test_source_extraction.py @@ -0,0 +1,16 @@ +import pytest + +from app.engine.source_extraction import chunk_with_offsets + + +def test_chunk_offsets_and_overlap_are_exact(): + text = "가" * 1200 + chunks = list(chunk_with_offsets(text, size=500, stride=250)) + assert chunks[0] == (0, 500, text[0:500]) + assert chunks[1] == (250, 750, text[250:750]) + assert chunks[-1][1] == len(text) + + +def test_chunk_parameters_must_be_positive(): + with pytest.raises(ValueError): + list(chunk_with_offsets("본문", size=0, stride=1))