From ce50e890898f2e0d89dcd4655398ca1f35635946 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 10 Aug 2026 16:12:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?3.5=EB=A7=8C=20=EC=97=90=ED=94=BC=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=B0=98=20=ED=91=9C=EC=A0=88=C2=B7AI?= =?UTF-8?q?=C2=B7=ED=8C=90=EB=A1=80=20=EB=B6=84=EC=84=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 7 +- .env.example | 16 + .gitignore | 7 + Dockerfile | 10 +- app/api/routes.py | 75 +- app/api/schemas.py | 75 +- app/core/config.py | 24 + app/engine/ai_detector.py | 1065 ++++++++++++++++++++++++++ app/engine/detector.py | 273 ++++++- app/engine/legal_risk.py | 133 ++++ app/engine/persistent_index.py | 267 +++++++ app/engine/provenance.py | 268 +++++++ app/engine/source_extraction.py | 106 +++ app/main.py | 20 + data/precedents/README.md | 10 + data/precedents/precedents.jsonl | 1 + docker-compose.yml | 4 + docs/AI_DETECTION.md | 271 +++++++ docs/API_GUIDE_BAIKAL.md | 17 +- docs/API_SPEC_BAIKAL.md | 18 +- docs/IMPLEMENTATION_RUNBOOK.md | 122 +++ requirements.txt | 5 + scripts/build_ai_training_dataset.py | 504 ++++++++++++ scripts/build_persistent_index.py | 31 + scripts/extract_source_documents.py | 51 ++ scripts/ingest_o2o_xlsx.py | 113 +++ scripts/train_ai_detector.py | 417 ++++++++++ scripts/validate_precedents.py | 27 + tests/test_ai_detector.py | 513 +++++++++++++ tests/test_legal_risk.py | 39 + tests/test_provenance_index.py | 69 ++ tests/test_source_extraction.py | 16 + 32 files changed, 4505 insertions(+), 69 deletions(-) create mode 100644 app/engine/ai_detector.py create mode 100644 app/engine/legal_risk.py create mode 100644 app/engine/persistent_index.py create mode 100644 app/engine/provenance.py create mode 100644 app/engine/source_extraction.py create mode 100644 data/precedents/README.md create mode 100644 data/precedents/precedents.jsonl create mode 100644 docs/AI_DETECTION.md create mode 100644 docs/IMPLEMENTATION_RUNBOOK.md create mode 100644 scripts/build_ai_training_dataset.py create mode 100644 scripts/build_persistent_index.py create mode 100644 scripts/extract_source_documents.py create mode 100644 scripts/ingest_o2o_xlsx.py create mode 100644 scripts/train_ai_detector.py create mode 100644 scripts/validate_precedents.py create mode 100644 tests/test_ai_detector.py create mode 100644 tests/test_legal_risk.py create mode 100644 tests/test_provenance_index.py create mode 100644 tests/test_source_extraction.py 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)) From 8b0a7e45acad54d14c4e49523805951ad01691aa Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 10 Aug 2026 16:15:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?CPU=20=EB=B0=B0=ED=8F=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=97=90=EC=84=9C=20CUDA=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 ++- app/core/config.py | 2 +- docs/IMPLEMENTATION_RUNBOOK.md | 7 +++++++ requirements.txt | 1 - 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index cc0bb23..8e4563f 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,8 @@ AI_DETECTOR_USE_POS=true SIMILARITY_THRESHOLD=0.85 # KoSimCSE / KoSBERT (PDF VII-3 권장 - 한국어 오픈소스 임베딩, 자체 산출물) -USE_KOSIMCSE=true +# CPU 기본 이미지는 torch를 포함하지 않습니다. 레거시 KoSimCSE를 별도 설치한 경우만 true. +USE_KOSIMCSE=false KOSIMCSE_MODEL=BM-K/KoSimCSE-roberta-multitask KOSIMCSE_MAX_LENGTH=512 diff --git a/app/core/config.py b/app/core/config.py index 3598f78..c77c3c0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -35,7 +35,7 @@ class Settings(BaseSettings): similarity_threshold: float = 0.85 # KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩 - use_kosimcse: bool = True + use_kosimcse: bool = False kosimcse_model: str = "BM-K/KoSimCSE-roberta-multitask" kosimcse_max_length: int = 512 diff --git a/docs/IMPLEMENTATION_RUNBOOK.md b/docs/IMPLEMENTATION_RUNBOOK.md index 40f6ab5..96ca605 100644 --- a/docs/IMPLEMENTATION_RUNBOOK.md +++ b/docs/IMPLEMENTATION_RUNBOOK.md @@ -120,3 +120,10 @@ python -m scripts.validate_precedents data/precedents/precedents.jsonl Ubuntu 18.04는 지원 종료 상태이므로 OS 업그레이드 전까지 외부 공개 범위를 최소화하고, API 인증·방화벽·키 회전을 별도 운영 작업으로 완료해야 한다. + +## 선택적 KoSimCSE + +기본 CPU 이미지는 새 영속 문자 인덱스를 사용하며 `torch`와 `sentence-transformers`를 +포함하지 않는다. 일반 PyPI의 최신 torch가 CUDA 런타임 수 GB를 함께 설치할 수 있기 +때문이다. 레거시 KoSimCSE가 반드시 필요한 별도 이미지에서만 해당 Python 버전에 맞는 +공식 CPU 전용 torch wheel을 먼저 설치한 뒤 sentence-transformers를 추가한다. diff --git a/requirements.txt b/requirements.txt index 45bd908..d9cf133 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ openai>=1.55 httpx>=0.27 kiwipiepy>=0.18 datasketch>=1.6 -sentence-transformers>=3.0 openpyxl>=3.1 joblib>=1.4 scipy>=1.13 From 725e44e9992b7c73c1e1437918cd7da4efed9b7d Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 10 Aug 2026 16:42:17 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Claude=20=EB=A6=AC=EB=B7=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9A=B4=EC=98=81=20=EC=95=88=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + Dockerfile | 1 + app/api/schemas.py | 70 +++++- app/core/config.py | 15 +- app/engine/detector.py | 126 +++++++++-- app/engine/persistent_index.py | 135 +++++++++-- app/engine/provenance.py | 112 ++++++--- app/main.py | 57 ++++- docs/IMPLEMENTATION_RUNBOOK.md | 72 ++++++ scripts/build_persistent_index.py | 42 ++++ tests/test_ai_training_split.py | 167 ++++++++++++++ tests/test_api.py | 66 ++++++ tests/test_auth_middleware.py | 144 ++++++++++++ tests/test_coverage_and_features.py | 337 ++++++++++++++++++++++++++++ tests/test_review_regressions.py | 144 ++++++++++++ 15 files changed, 1427 insertions(+), 67 deletions(-) create mode 100644 tests/test_ai_training_split.py create mode 100644 tests/test_auth_middleware.py create mode 100644 tests/test_coverage_and_features.py create mode 100644 tests/test_review_regressions.py diff --git a/.env.example b/.env.example index 8e4563f..cd522ff 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,12 @@ RELOAD=false ROOT_PATH= # 설정하면 /v1/health를 제외한 API 요청에 X-API-Key가 필요합니다. API_KEY= +# 운영 fail-closed. true인데 API_KEY가 비면 앱이 기동에 실패합니다. +# ⚠️ King 실제 활성화는 바이칼 측 키 전달 후 진행 (docs/AI_DETECTION.md 참조). +REQUIRE_API_KEY=false +# 인증 없이 열어둘 경로 제어 +PUBLIC_HEALTH=true +PUBLIC_DOCS=true ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse REFERENCE_CORPUS_DIR=./data/reference diff --git a/Dockerfile b/Dockerfile index 8e94b13..28529dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app \ HOST=0.0.0.0 \ PORT=8000 diff --git a/app/api/schemas.py b/app/api/schemas.py index 576a487..fbc7057 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -55,11 +55,33 @@ class DetectOptions(BaseModel): ) +class LegalContext(BaseModel): + """사람이 확인한 법적 사실. 제공하지 않으면 missing_factors 로 남는다 (#10). + + 엔진은 이 값들을 **추론하지 않는다.** 텍스트만으로는 알 수 없는 사실이므로, + 검토자가 확인한 경우에만 전달받아 판례 매칭과 위험도 판단에 반영한다. + """ + work_type: str = Field( + default="literary", description="저작물 유형 (literary/musical/visual 등)" + ) + access_evidence: bool | None = Field( + default=None, + description="원저작물 접근·의거 가능성이 확인되었는지. None이면 미제공.", + ) + protected_expression_reviewed: bool = Field( + default=False, description="보호되는 창작적 표현인지 사람이 검토했는지", + ) + rights_verified: bool = Field( + default=False, description="저작권 귀속·이용허락·인용 요건이 확인되었는지", + ) + + class DetectRequest(BaseModel): doc_id: str text: str = Field(..., min_length=1) metadata: DocumentMetadata | None = None options: DetectOptions = Field(default_factory=DetectOptions) + legal_context: LegalContext | None = None class EvidenceSpan(BaseModel): @@ -117,8 +139,18 @@ class MatchResult(BaseModel): 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) + matched_coverage: float = Field( + default=0.0, ge=0.0, le=1.0, + description="이 세그먼트 일치 구간이 질의 전체 길이에서 차지하는 비율", + ) longest_span: int = Field(default=0, ge=0) + match_reasons: list[str] = Field( + default_factory=list, + description=( + "이 후보가 채택된 이유 (#9). score_threshold=결합점수가 임계 초과, " + "exact_span=연속 일치 길이 조건 충족, coverage=커버리지 조건 충족." + ), + ) class ExtractedElements(BaseModel): @@ -142,6 +174,41 @@ class LegalRiskSignal(BaseModel): disclaimer: str +class ScoreSemantics(BaseModel): + """점수와 임계값의 의미를 명시 (#9). + + ``confidence``/``similarity`` 는 hashing 어휘 점수와 lemma 겹침을 설정 + 가중치로 섞은 **검색 랭킹 점수**이며, 침해 확률도 법적 판정도 아니다. + 임계값 역시 실데이터 캘리브레이션 전이라 provisional 이다. + """ + combined_score: float = Field(..., ge=0.0, le=1.0) + score_kind: str = Field( + default="lexical_lemma_blend", + description="점수 구성. 확률값이 아니며 서로 다른 코퍼스 간 비교 불가.", + ) + threshold_used: float = Field(..., ge=0.0, le=1.0) + threshold_source: Literal["request_override", "server_default"] + threshold_calibrated: bool = Field( + default=False, description="실데이터 FP 분포로 캘리브레이션되었는지", + ) + provisional: bool = Field( + default=True, description="True면 임계값이 잠정값이라 판정 근거로 쓸 수 없음", + ) + union_coverage: float = Field( + default=0.0, ge=0.0, le=1.0, + description="정밀 비교 후보 전체의 비중복 일치 구간 / 질의 길이 (#3)", + ) + covered_chars: int = Field(default=0, ge=0) + query_chars: int = Field(default=0, ge=0) + evidence_truncated: bool = Field( + default=False, + description="True면 CPU 상한으로 일부 후보는 정밀 비교하지 않음", + ) + note: str = ( + "검색 랭킹 점수이며 침해 확률이 아닙니다. 임계값은 캘리브레이션 전 잠정값입니다." + ) + + class AiSegmentSignal(BaseModel): index: int start: int @@ -203,6 +270,7 @@ class DetectResponse(BaseModel): has_similarity_match: bool | None = None corpus_scope_note: str | None = None legal_risk: LegalRiskSignal | None = None + score_semantics: ScoreSemantics | None = None autobiography_mode: bool = False candidates_before_filter: int | None = None engine_version: str diff --git a/app/core/config.py b/app/core/config.py index c77c3c0..faf4466 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -13,7 +13,15 @@ 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 필수 + + # --- 인증 (#1) --- + # api_key 만으로는 "빈 값 = 무인증"이 조용히 성립한다. 운영에서는 + # require_api_key=true 로 명시적 fail-closed 를 걸어, 키가 없으면 앱이 + # 아예 뜨지 않게 한다. + api_key: str = "" # 설정 시 X-API-Key 필수 + require_api_key: bool = False # true 인데 api_key 가 비면 기동 실패 + public_health: bool = True # /v1/health 를 인증 없이 공개할지 + public_docs: bool = True # /docs, /openapi.json, /redoc 공개 여부 engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2" reference_corpus_dir: str = "./data/reference" @@ -25,6 +33,8 @@ class Settings(BaseSettings): persistent_similarity_threshold: float = 0.65 persistent_min_exact_span: int = 80 persistent_min_coverage: float = 0.30 + # 상위 N개 후보만 SequenceMatcher 정밀 비교 + union coverage 에 참여시킨다. + # 이 값이 곧 요청당 O(질의길이 × 세그먼트길이) 연산의 상한이다 (#3/#5). persistent_rerank_top_k: int = 20 precedents_path: str = "./data/precedents/precedents.jsonl" ai_detector_model_path: str = "./data/models/ai_detector.joblib" @@ -33,6 +43,9 @@ class Settings(BaseSettings): # PDF VII-4 권장: 정밀도 우선 보수적 임계값 similarity_threshold: float = 0.85 + # 임계값이 실데이터로 캘리브레이션되었는지. false 면 API 응답에 + # provisional=true 로 노출된다 (#9). 79권 FP 분포 측정 후 true 로 전환. + similarity_threshold_calibrated: bool = False # KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩 use_kosimcse: bool = False diff --git a/app/engine/detector.py b/app/engine/detector.py index 4b34c31..1459708 100644 --- a/app/engine/detector.py +++ b/app/engine/detector.py @@ -23,13 +23,16 @@ from app.api.schemas import ( DetectRequest, DetectResponse, DocumentMetadata, + ExtractedElements, InfringementTag, InfringementType, + LegalContext, LegalRiskSignal, MatchResult, PartialPlagiarismSignal, ReviewSummary, ScoreBreakdown, + ScoreSemantics, ) from app.core.config import Settings, get_settings from app.engine.autobiography_filter import preprocess_for_autobiography @@ -54,8 +57,12 @@ logger = logging.getLogger(__name__) class PlagiarismDetector: + #: 참조 특징 프로세스 캐시 상한. 초과하면 통째로 비운다(단순 LRU 대용). + _FEATURE_CACHE_MAX = 20_000 + def __init__(self, settings: Settings | None = None, extractor: Extractor | None = None): self.settings = settings or get_settings() + self._feature_cache: dict[str, tuple[list[str], "ExtractedElements"]] = {} 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)) @@ -174,6 +181,7 @@ class PlagiarismDetector: metadata: DocumentMetadata | None = None, options: DetectOptions | None = None, include_ai_segments: bool = True, + legal_context: LegalContext | None = None, ) -> DetectResponse: opts = options or DetectOptions() default_threshold = ( @@ -202,18 +210,30 @@ class PlagiarismDetector: # 영속 CPU 인덱스: 전량 행렬곱으로 후보를 구하고 상위 후보만 증거 비교한다. persistent_hits: list[PersistentHit] = [] + union_coverage = 0.0 + covered_chars = 0 + evidence_truncated = False + document_coverage: dict[str, float] = {} if self._persistent: persistent_query_lemmas = extract_lemmas(text) - persistent_hits = self._persistent.query( + result = self._persistent.search( text, top_k=max(opts.top_k, self.settings.persistent_rerank_top_k), + evidence_limit=self.settings.persistent_rerank_top_k, ) + persistent_hits = result.hits + union_coverage = result.union_coverage + covered_chars = result.covered_chars + evidence_truncated = result.evidence_truncated + document_coverage = result.document_coverage + # 정밀 특징 비교도 rerank 대상(reranked=True)에만 수행한다 (#5). hits = [ self._persistent_to_similarity_hit(h, persistent_query_lemmas, elements) - for h in persistent_hits + for h in persistent_hits if h.reranked ] + self._backfill_segment_features(persistent_hits) hits.sort(key=lambda h: h.score, reverse=True) - candidates_count = len(hits) + candidates_count = len(persistent_hits) lsh_jaccards: dict[str, float] = {} else: hits = [] @@ -240,14 +260,22 @@ class PlagiarismDetector: persistent_by_id = {h.segment_id: h for h in persistent_hits} matches = [] for h in hits: + if len(matches) >= opts.top_k: + break 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: + # 채택 이유를 명시적으로 남긴다 (#9). 임계 초과가 아니라 연속 일치나 + # 커버리지 때문에 올라온 후보를 검토자가 구분할 수 있어야 한다. + reasons: list[str] = [] + if h.score >= threshold: + reasons.append("score_threshold") + if provenance_hit: + if provenance_hit.longest_span >= self.settings.persistent_min_exact_span: + reasons.append("exact_span") + # 커버리지 조건은 문서 단위 union 으로 판단한다. 세그먼트 단독 + # 비율은 긴 원고에서 구조적으로 작아 조건이 성립하지 않는다 (#3). + if document_coverage.get(provenance_hit.document_id, 0.0) >= self.settings.persistent_min_coverage: + reasons.append("coverage") + if not reasons: continue match = self._to_match( h, opts.return_evidence, lsh_jaccards.get(h.doc_id), @@ -255,20 +283,25 @@ class PlagiarismDetector: ) if provenance_hit: match = self._add_provenance(match, provenance_hit) - matches.append(match) + matches.append(match.model_copy(update={"match_reasons": reasons})) confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0) is_infringement = bool(matches) # 후방호환 필드. 법적 확정이 아니라 임계 초과 매칭. ccl_basis = self._build_ccl_basis(matches) if is_infringement else None - 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] + ctx = legal_context or LegalContext() legal = self._legal_engine.assess( max_similarity=matches[0].similarity if matches else 0.0, - coverage=top_coverage, + # 문서 단위 union coverage 를 쓴다. 세그먼트 최대값을 쓰면 긴 원고에서 + # 항상 0에 가까워 strong_copy 판정이 성립하지 않았다 (#3). + coverage=union_coverage, longest_span=top_longest, legal_tags=legal_tags, - work_type="literary", + work_type=ctx.work_type, + access_evidence=ctx.access_evidence, + protected_expression_reviewed=ctx.protected_expression_reviewed, + rights_verified=ctx.rights_verified, ) # AI 탐지는 전처리 전 raw text에서만 실행한다. @@ -329,6 +362,19 @@ class PlagiarismDetector: precedent_ids=list(legal.precedent_ids), disclaimer=legal.disclaimer, ), + score_semantics=ScoreSemantics( + combined_score=round(confidence, 4), + threshold_used=threshold, + threshold_source=( + "request_override" if opts.threshold is not None else "server_default" + ), + threshold_calibrated=self.settings.similarity_threshold_calibrated, + provisional=not self.settings.similarity_threshold_calibrated, + union_coverage=round(union_coverage, 4), + covered_chars=covered_chars, + query_chars=len(text), + evidence_truncated=evidence_truncated, + ), autobiography_mode=autobio_mode, candidates_before_filter=candidates_count, engine_version=self.settings.engine_version, @@ -336,7 +382,53 @@ class PlagiarismDetector: ) def detect_request(self, req: DetectRequest) -> DetectResponse: - return self.detect(req.doc_id, req.text, req.metadata, req.options) + return self.detect( + req.doc_id, req.text, req.metadata, req.options, + legal_context=req.legal_context, + ) + + def reference_features(self, hit: PersistentHit) -> tuple[list[str], ExtractedElements]: + """참조 세그먼트의 lemma/요소. 인덱스 캐시 → 프로세스 캐시 → 계산 순 (#5). + + 인덱싱 때 채워둔 값이 있으면 형태소 분석을 아예 하지 않는다. v1 DB 나 + API 업로드분처럼 캐시가 없으면 계산하되 프로세스 캐시에 담고, 호출자가 + DB 로 백필한다. + """ + from app.api.schemas import ExtractedElements as _EE + + if hit.reference_lemmas is not None and hit.reference_elements is not None: + return hit.reference_lemmas, _EE(**hit.reference_elements) + + cached = self._feature_cache.get(hit.segment_id) + if cached is not None: + return cached + + lemmas = extract_lemmas(hit.reference_text) + elements = self._extractor.extract(hit.reference_text) + if len(self._feature_cache) >= self._FEATURE_CACHE_MAX: + self._feature_cache.clear() + self._feature_cache[hit.segment_id] = (lemmas, elements) + return lemmas, elements + + def _backfill_segment_features(self, hits: list[PersistentHit]) -> None: + """질의 중 계산한 참조 특징을 DB 에 되돌려 다음 요청부터 재사용.""" + if not self._persistent: + return + pending = [ + (h.segment_id, *self._feature_cache[h.segment_id]) + for h in hits + if h.reranked + and h.reference_lemmas is None + and h.segment_id in self._feature_cache + ] + if not pending: + return + try: + self._persistent.store.update_segment_features( + (sid, lemmas, elements.model_dump()) for sid, lemmas, elements in pending + ) + except Exception as exc: # 백필 실패가 탐지 응답을 막아서는 안 된다 + logger.warning("Segment feature backfill failed: %s", exc) def _persistent_to_similarity_hit( self, hit: PersistentHit, query_lemmas: list[str], query_elements, @@ -345,9 +437,9 @@ class PlagiarismDetector: from app.engine.similarity import _element_similarities from app.engine.structural import lemma_overlap_ratio - reference_elements = self._extractor.extract(hit.reference_text) + reference_lemmas, reference_elements = self.reference_features(hit) element_sim = _element_similarities(query_elements, reference_elements) - lemma_sim = lemma_overlap_ratio(query_lemmas, extract_lemmas(hit.reference_text)) + lemma_sim = lemma_overlap_ratio(query_lemmas, reference_lemmas) s = self.settings combined = ( s.weight_text_sim * hit.score diff --git a/app/engine/persistent_index.py b/app/engine/persistent_index.py index b8fd12f..3f0fbe3 100644 --- a/app/engine/persistent_index.py +++ b/app/engine/persistent_index.py @@ -28,6 +28,29 @@ VECTORIZER_CONFIG = { } +@dataclass(frozen=True) +class PersistentQueryResult: + """질의 1건의 후보와 **문서 단위 union coverage**. + + coverage 정의 (#3): + · ``PersistentHit.coverage`` — 이 세그먼트 하나가 질의 전체 길이에서 + 차지하는 비율. 분모가 질의 전체라 긴 원고에서는 필연적으로 작다. + · ``PersistentQueryResult.union_coverage`` — 정밀 비교 대상 후보 + 전체(evidence_limit 개)의 일치 구간을 **질의 좌표에서 합집합**으로 + 묶어 계산한 비율. 중복 구간을 두 번 세지 않는다. + "이 원고의 몇 %가 등록 코퍼스와 겹치는가"에 답하는 값은 이쪽이며, + 법적 위험도 판단에는 반드시 이 값을 쓴다. + """ + + hits: list["PersistentHit"] + union_coverage: float + covered_chars: int + query_chars: int + evidence_limit: int + evidence_truncated: bool + document_coverage: dict[str, float] + + @dataclass(frozen=True) class PersistentHit: segment_id: str @@ -44,6 +67,12 @@ class PersistentHit: source_char_start: int | None source_char_end: int | None reference_text: str + #: 인덱싱 때 캐시된 참조 특징 (#5). None 이면 호출자가 계산·백필해야 한다. + reference_lemmas: list[str] | None = None + reference_elements: dict | None = None + #: SequenceMatcher 정밀 비교를 실제로 수행했는지. False 면 evidence/coverage/ + #: longest_span 은 미계산(0) 이며 score 만 의미가 있다. + reranked: bool = True def _vectorizer(n_features: int, config: dict | None = None): @@ -61,10 +90,17 @@ def _vectorizer(n_features: int, config: dict | None = None): ) -def _evidence_spans(query: str, reference: str, min_match: int = 12, limit: int = 10) -> tuple[list[dict], float, int]: - """원문 query 좌표의 공통 연속 구간과 coverage를 반환.""" +def _evidence_spans( + query: str, reference: str, min_match: int = 12, limit: int = 10 +) -> tuple[list[dict], list[tuple[int, int]], int]: + """원문 query 좌표의 공통 연속 구간을 반환. + + 반환: (표시용 상위 span, coverage 계산용 전체 구간 [start,end), 최장 일치 길이) + 두 번째 값은 union coverage 를 위해 **잘리지 않은 전체 구간**이다. 표시용은 + limit 개로 줄이지만 coverage 는 전량으로 계산해야 과소평가되지 않는다. + """ if not query or not reference: - return [], 0.0, 0 + return [], [], 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)) @@ -79,15 +115,35 @@ def _evidence_spans(query: str, reference: str, min_match: int = 12, limit: int } 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) + intervals = [(b.a, b.a + b.size) for b in useful] + return spans, intervals, max((b.size for b in useful), default=0) + + +def _merge_intervals(intervals: list[tuple[int, int]]) -> list[tuple[int, int]]: + """겹치는 구간을 합쳐 비중복 구간 목록으로. set(range(...)) 는 긴 원고에서 + 메모리를 크게 먹으므로 구간 병합으로 처리한다.""" + if not intervals: + return [] + ordered = sorted(intervals) + merged = [ordered[0]] + for start, end in ordered[1:]: + last_start, last_end = merged[-1] + if start <= last_end: + merged[-1] = (last_start, max(last_end, end)) + else: + merged.append((start, end)) + return merged + + +def _covered_length(intervals: list[tuple[int, int]]) -> int: + return sum(end - start for start, end in _merge_intervals(intervals)) class PersistentCorpusIndex: MATRIX_FILE = "lexical.npz" META_FILE = "index.json" + #: 정밀 비교(SequenceMatcher) 기본 상한. 호출자가 설정값으로 덮어쓴다. + DEFAULT_EVIDENCE_LIMIT = 20 def __init__(self, store_path: str | Path, index_dir: str | Path): self.store = CorpusStore(store_path) @@ -195,11 +251,28 @@ class PersistentCorpusIndex: 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]: + def query(self, text: str, top_k: int = 50, min_score: float = 0.0, + evidence_limit: int | None = None) -> list[PersistentHit]: + """후보만 필요할 때 쓰는 얇은 래퍼. union coverage 가 필요하면 search().""" + return self.search(text, top_k, min_score, evidence_limit).hits + + def search( + self, + text: str, + top_k: int = 50, + min_score: float = 0.0, + evidence_limit: int | None = None, + ) -> PersistentQueryResult: + """후보 검색 + 상위 evidence_limit 개에 대해서만 정밀 비교. + + SequenceMatcher 는 O(질의청크 × 세그먼트) 라 후보 전체에 돌리면 요청당 + 수십 초가 된다. 정밀 비교 대상을 evidence_limit 로 제한하는 것이 CPU + 상한이며, 나머지 후보는 score 만 채워 reranked=False 로 표시한다. + """ if self._matrix is None: self.load() if not text.strip() or self._matrix is None or self._matrix.shape[0] == 0: - return [] + return PersistentQueryResult([], 0.0, 0, len(text), evidence_limit or 0, False, {}) vectorizer = _vectorizer( int(self._meta["n_features"]), self._meta["vectorizer_config"] ) @@ -228,7 +301,14 @@ class PersistentCorpusIndex: 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) + + limit = self.DEFAULT_EVIDENCE_LIMIT if evidence_limit is None else evidence_limit + limit = max(0, limit) hits: list[PersistentHit] = [] + union_intervals: list[tuple[int, int]] = [] + document_intervals: dict[str, list[tuple[int, int]]] = {} + reranked_count = 0 + for i in indexes: score = float(scores[int(i)]) if score < min_score: @@ -237,18 +317,42 @@ class PersistentCorpusIndex: record = records.get(segment_id) if not record: continue + + if reranked_count >= limit: + # CPU 상한. 정밀 비교 없이 score 만 채운다. + hits.append(self._to_hit(record, score, [], 0.0, 0, reranked=False)) + continue + chunk_start, chunk_text = query_chunks[int(best_chunk[int(i)])] - evidence, _, longest = _evidence_spans(chunk_text, record.text) + evidence, intervals, 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))) + shifted = [(s + chunk_start, e + chunk_start) for s, e in intervals] + union_intervals.extend(shifted) + document_intervals.setdefault(record.document_id, []).extend(shifted) + # 이 세그먼트 단독 기여분 (질의 전체 길이 대비) + coverage = min(1.0, _covered_length(shifted) / max(1, len(text))) hits.append(self._to_hit(record, score, evidence, coverage, longest)) - return hits + reranked_count += 1 + + covered_chars = _covered_length(union_intervals) + return PersistentQueryResult( + hits=hits, + union_coverage=min(1.0, covered_chars / max(1, len(text))), + covered_chars=covered_chars, + query_chars=len(text), + evidence_limit=limit, + evidence_truncated=any(not h.reranked for h in hits), + document_coverage={ + document_id: min(1.0, _covered_length(intervals) / max(1, len(text))) + for document_id, intervals in document_intervals.items() + }, + ) @staticmethod - def _to_hit(record: SegmentRecord, score: float, evidence: list[dict], coverage: float, longest: int) -> PersistentHit: + def _to_hit(record: SegmentRecord, score: float, evidence: list[dict], + coverage: float, longest: int, reranked: bool = True) -> PersistentHit: return PersistentHit( segment_id=record.segment_id, document_id=record.document_id, @@ -264,4 +368,7 @@ class PersistentCorpusIndex: source_char_start=record.char_start, source_char_end=record.char_end, reference_text=record.text, + reference_lemmas=record.lemmas, + reference_elements=record.elements, + reranked=reranked, ) diff --git a/app/engine/provenance.py b/app/engine/provenance.py index 34be0d4..089ec29 100644 --- a/app/engine/provenance.py +++ b/app/engine/provenance.py @@ -15,7 +15,9 @@ from pathlib import Path from typing import Iterable, Iterator -SCHEMA_VERSION = 1 +#: v2 — 참조 lemma/요소 특징 캐시 컬럼 추가 (#5). 기존 v1 DB 는 ALTER TABLE 로 +#: 자동 승격되며, 캐시가 비어 있으면 질의 시 계산 후 백필된다. +SCHEMA_VERSION = 2 def text_sha256(text: str) -> str: @@ -49,6 +51,11 @@ class SegmentRecord: char_end: int | None = None source_locator: str | None = None metadata: dict = field(default_factory=dict) + #: 인덱싱 시점에 계산해 둔 참조 lemma 열 (#5). None 이면 미계산 상태이며, + #: 질의 경로가 계산 후 백필한다. 형태소 분석을 요청마다 반복하지 않기 위한 캐시. + lemmas: list[str] | None = None + #: 인물/모티프 등 최소 요소 특징. ExtractedElements 를 dict 로 직렬화한 형태. + elements: dict | None = None @property def text_sha256(self) -> str: @@ -110,11 +117,20 @@ class CorpusStore: CREATE INDEX IF NOT EXISTS idx_segments_hash ON segments(text_sha256); """ ) + self._migrate(con) con.execute( "INSERT OR REPLACE INTO corpus_meta(key, value) VALUES('schema_version', ?)", (str(SCHEMA_VERSION),), ) + @staticmethod + def _migrate(con: sqlite3.Connection) -> None: + """기존 DB 를 파괴 없이 승격. v1 → v2 는 컬럼 추가만 필요하다.""" + existing = {row["name"] for row in con.execute("PRAGMA table_info(segments)")} + for column in ("lemmas_json", "elements_json"): + if column not in existing: + con.execute(f"ALTER TABLE segments ADD COLUMN {column} TEXT") + def upsert_document(self, record: DocumentRecord) -> None: self.upsert_documents([record]) @@ -150,8 +166,9 @@ class CorpusStore: """ 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(?,?,?,?,?,?,?,?,?,?,?,?) + page_number,paragraph_number,char_start,char_end,source_locator, + metadata_json,lemmas_json,elements_json + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( r.segment_id, @@ -166,6 +183,9 @@ class CorpusStore: r.char_end, r.source_locator, json.dumps(r.metadata, ensure_ascii=False, sort_keys=True), + json.dumps(r.lemmas, ensure_ascii=False) if r.lemmas is not None else None, + json.dumps(r.elements, ensure_ascii=False, sort_keys=True) + if r.elements is not None else None, ), ) if con.total_changes > before: @@ -186,21 +206,66 @@ class CorpusStore: """ ) 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, - ) + yield self._row_to_segment(row) + + @staticmethod + def _row_to_segment(row: sqlite3.Row) -> SegmentRecord: + metadata = json.loads(row["metadata_json"] or "{}") + metadata.setdefault("document_title", row["title"]) + keys = row.keys() + lemmas = None + elements = None + # v1 DB 를 그대로 읽는 경로에서도 죽지 않도록 컬럼 존재를 확인한다. + if "lemmas_json" in keys and row["lemmas_json"]: + lemmas = json.loads(row["lemmas_json"]) + if "elements_json" in keys and row["elements_json"]: + elements = json.loads(row["elements_json"]) + return 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, + lemmas=lemmas, + elements=elements, + ) + + def update_segment_features( + self, features: Iterable[tuple[str, list[str], dict]] + ) -> int: + """(segment_id, lemmas, elements) 를 백필한다. 반환값은 갱신된 행 수.""" + rows = [ + ( + json.dumps(lemmas, ensure_ascii=False), + json.dumps(elements, ensure_ascii=False, sort_keys=True), + segment_id, + ) + for segment_id, lemmas, elements in features + ] + if not rows: + return 0 + self.initialize() + with self._connect() as con: + con.executemany( + "UPDATE segments SET lemmas_json=?, elements_json=? WHERE segment_id=?", + rows, + ) + return con.total_changes + + def count_missing_features(self) -> int: + if not self.path.exists(): + return 0 + self.initialize() + with self._connect() as con: + return int(con.execute( + "SELECT COUNT(*) FROM segments WHERE lemmas_json IS NULL" + ).fetchone()[0]) def get_segments(self, segment_ids: Iterable[str]) -> dict[str, SegmentRecord]: ids = list(dict.fromkeys(segment_ids)) @@ -218,16 +283,7 @@ class CorpusStore: 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, - ) + result[row["segment_id"]] = self._row_to_segment(row) return result def stats(self) -> dict[str, int]: diff --git a/app/main.py b/app/main.py index 2c18dc4..14e7034 100644 --- a/app/main.py +++ b/app/main.py @@ -19,12 +19,60 @@ from app.jobs.store import JobStore logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +class AuthConfigurationError(RuntimeError): + """운영 인증 설정이 모순될 때 기동을 막는다.""" + + +def validate_auth_settings(settings) -> None: + """REQUIRE_API_KEY=true 인데 키가 없으면 기동 실패 (fail-closed). + + '키를 깜빡해서 무인증으로 떠 있었다'가 가능한 구성을 없애는 것이 목적이다. + 미출간 원고를 다루는 서버라 조용한 무인증이 가장 위험하다. + """ + if settings.require_api_key and not settings.api_key.strip(): + raise AuthConfigurationError( + "REQUIRE_API_KEY=true 인데 API_KEY 가 비어 있습니다. " + "키를 설정하거나 REQUIRE_API_KEY=false 로 두십시오." + ) + + +def public_paths(settings) -> set[str]: + """인증 없이 접근 가능한 경로. 설정으로 좁힐 수 있다.""" + paths = {"/"} + if settings.public_health: + paths.add("/v1/health") + if settings.public_docs: + paths.update({"/docs", "/openapi.json", "/redoc"}) + return paths + + +def is_authorized(settings, path: str, supplied: str) -> bool: + """요청 허용 여부. 순수 함수라 앱 기동 없이 테스트할 수 있다. + + api_key 가 비어 있으면(개발 기본값) 인증을 걸지 않는다. 이 경우 기동 시 + critical 경고가 남는다. + """ + configured = settings.api_key.strip() + if not configured: + return True + if path in public_paths(settings): + return True + protected_docs = {"/docs", "/openapi.json", "/redoc"} + if not path.startswith("/v1") and path not in protected_docs: + return True + # compare_digest 는 비ASCII str 에서 TypeError 를 던진다. 한글/이모지 키를 + # 넣으면 전 요청이 500 이 되므로 반드시 bytes 로 비교한다. + return hmac.compare_digest(configured.encode("utf-8"), (supplied or "").encode("utf-8")) + + @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() + validate_auth_settings(settings) if not settings.api_key.strip(): logging.critical( "API_KEY is empty: all /v1 endpoints are unauthenticated. " + "Set REQUIRE_API_KEY=true with a key before exposing this server. " "Do not expose unpublished manuscripts publicly until client key rollout is complete." ) app.state.settings = settings @@ -67,12 +115,9 @@ 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"}) + settings = getattr(request.app.state, "settings", None) or _settings + if not is_authorized(settings, request.url.path, request.headers.get("x-api-key", "")): + return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) return await call_next(request) _STATIC_DIR = Path(__file__).resolve().parent / "static" diff --git a/docs/IMPLEMENTATION_RUNBOOK.md b/docs/IMPLEMENTATION_RUNBOOK.md index 96ca605..caec4d1 100644 --- a/docs/IMPLEMENTATION_RUNBOOK.md +++ b/docs/IMPLEMENTATION_RUNBOOK.md @@ -47,6 +47,7 @@ PERSISTENT_INDEX_DIR=/app/data/runtime/index PERSISTENT_SIMILARITY_THRESHOLD=0.65 # 바이칼과 키 전달을 합의한 뒤 활성화 API_KEY= +REQUIRE_API_KEY=true ``` `0.65`는 영속 문자 n-gram 후보 검색의 보수적인 시작값일 뿐 운영 확정값이 아니다. @@ -127,3 +128,74 @@ API 인증·방화벽·키 회전을 별도 운영 작업으로 완료해야 한 포함하지 않는다. 일반 PyPI의 최신 torch가 CUDA 런타임 수 GB를 함께 설치할 수 있기 때문이다. 레거시 KoSimCSE가 반드시 필요한 별도 이미지에서만 해당 Python 버전에 맞는 공식 CPU 전용 torch wheel을 먼저 설치한 뒤 sentence-transformers를 추가한다. + +## 인증 fail-closed (#1) + +| 설정 | 기본 | 의미 | +|---|---|---| +| `API_KEY` | 빈 값 | 비어 있으면 **인증이 걸리지 않는다**. 기동 시 critical 로그가 남는다. | +| `REQUIRE_API_KEY` | `false` | `true` 인데 `API_KEY` 가 비면 **앱이 기동에 실패**한다(`AuthConfigurationError`). | +| `PUBLIC_HEALTH` | `true` | `/v1/health` 를 무인증 공개할지. 모니터링이 키를 못 넣으면 `true` 유지. | +| `PUBLIC_DOCS` | `true` | `/docs`, `/openapi.json`, `/redoc` 공개 여부. | + +- **King 실제 활성화는 바이칼 측 키 전달 후로 보류**한다. 그때까지 `REQUIRE_API_KEY=false` + 로 두되, 서버를 외부에 노출하지 않는다. 키를 받으면 `API_KEY` 설정과 동시에 + `REQUIRE_API_KEY=true` 로 올려 "키를 깜빡한 채 무인증으로 떠 있는" 상태를 원천 차단한다. +- **운영 키는 반드시 ASCII 로 발급**한다. HTTP 헤더는 비ASCII 를 전송할 수 없어 + 한글 키는 인증 자체가 불가능하다(서버는 500 대신 401 을 반환한다). +- `PUBLIC_DOCS=false` 이면 `/docs`, `/openapi.json`, `/redoc`에도 API 키가 + 필요하다. 외부 노출 환경에서는 리버스 프록시 차단도 함께 적용하는 편이 안전하다. + +## coverage 정의와 CPU 상한 (#3/#5) + +두 가지 coverage 를 구분한다. 혼동하면 긴 원고에서 위험도가 항상 낮게 나온다. + +- `matches[].matched_coverage` — **세그먼트 1건**의 일치 구간 / 질의 전체 길이. + 분모가 원고 전체라 30만 자 원고에서는 한 세그먼트가 최대 수천분의 1에 그친다. + 개별 후보의 기여도를 볼 때만 쓴다. +- `score_semantics.union_coverage` — **정밀 비교한 후보 전체**의 일치 구간을 질의 + 좌표에서 **합집합**으로 묶은 비율(중복 구간 1회만 계산). "이 원고의 몇 %가 등록 + 코퍼스와 겹치는가"에 답하는 값이며, `PERSISTENT_MIN_COVERAGE` 게이트와 판례 + 위험도(`legal_risk`)는 **이 값만** 사용한다. + +CPU 상한은 `PERSISTENT_RERANK_TOP_K`(기본 20) 하나로 통제한다. 후보 검색은 전량 +행렬곱으로 하되, `SequenceMatcher` 정밀 비교는 상위 N건에만 돌린다. 나머지 후보는 +`reranked=false` 로 반환되며 `evidence`/`coverage`/`longest_span` 이 0 이고 score 만 +의미가 있다. 잘림이 발생하면 `score_semantics.evidence_truncated=true` 로 노출된다. +이 값을 올리면 요청당 지연이 선형으로 증가한다. + +참조 lemma/요소는 `scripts/build_persistent_index.py` 가 인덱싱 때 DB 에 사전계산해 +둔다(`--skip-precompute` 로 생략 가능). 캐시가 없으면 질의 시 계산 후 자동 백필된다. +500 세그먼트×937자 기준 사전계산 시 요청 지연 0.499s → 0.415s (약 17%). + +## 점수 의미와 잠정 임계값 (#9) + +`confidence` / `matches[].similarity` 는 hashing 어휘 점수와 lemma 겹침을 설정 +가중치로 섞은 **검색 랭킹 점수**이며 침해 확률이 아니다. 응답의 `score_semantics` +가 이를 명시한다. + +- `threshold_source` — `server_default` / `request_override` +- `threshold_calibrated` — `SIMILARITY_THRESHOLD_CALIBRATED` 설정값. 79권 상호비교 + FP 분포를 측정하기 전까지 `false` 로 두고, `provisional=true` 로 노출된다. +- `matches[].match_reasons` — 후보가 채택된 이유. `score_threshold`(결합점수 초과), + `exact_span`(연속 일치 ≥ `PERSISTENT_MIN_EXACT_SPAN`), `coverage`(union coverage + ≥ `PERSISTENT_MIN_COVERAGE`). 이때 후보 채택용 coverage는 해당 출처 문서의 + 세그먼트끼리만 합산한다. 서로 다른 출처의 일치를 합쳐 개별 후보를 통과시키지 + 않는다. 임계값이 아니라 연속 일치 때문에 올라온 후보도 구분할 수 있다. + +임계값 수치는 캘리브레이션 전까지 **임의로 바꾸지 않는다.** 기존 값을 유지하고 +provisional 플래그로만 알린다. + +## 법적 맥락 입력 (#10) + +`POST /v1/plagiarism/detect` 의 `legal_context` 는 선택 필드다. 엔진은 이 사실들을 +**추론하지 않으며**, 미제공 시 `legal_risk.missing_factors` 에 그대로 남는다. + +```json +{"doc_id":"x","text":"...","legal_context":{ + "work_type":"literary","access_evidence":true, + "protected_expression_reviewed":true,"rights_verified":true}} +``` + +판례는 등록된 것만 반환한다(`precedent_ids`). 랭킹은 ① 태그 교집합 수 ② +`work_type` 일치 ③ 사건번호 순이며, 생성형 인용은 어떤 경로로도 발생하지 않는다. diff --git a/scripts/build_persistent_index.py b/scripts/build_persistent_index.py index 4a84306..0d54046 100644 --- a/scripts/build_persistent_index.py +++ b/scripts/build_persistent_index.py @@ -12,6 +12,36 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.engine.persistent_index import PersistentCorpusIndex +from app.engine.provenance import CorpusStore + + +def precompute_features(store: CorpusStore, batch: int = 500) -> int: + """참조 lemma/요소를 미리 계산해 DB 에 저장 (#5). + + 이 작업을 인덱싱 때 1회 해두면, 탐지 요청마다 후보 세그먼트를 형태소 + 분석하던 비용이 사라진다. 이미 채워진 세그먼트는 건너뛴다. + """ + from app.engine.extractor import get_extractor + from app.engine.structural import extract_lemmas + + extractor = get_extractor() + pending: list[tuple[str, list[str], dict]] = [] + updated = 0 + for segment in store.iter_segments(): + if segment.lemmas is not None and segment.elements is not None: + continue + pending.append(( + segment.segment_id, + extract_lemmas(segment.text), + extractor.extract(segment.text).model_dump(), + )) + if len(pending) >= batch: + updated += store.update_segment_features(pending) + print(f" 특징 계산 {updated}건…", flush=True) + pending = [] + if pending: + updated += store.update_segment_features(pending) + return updated def main() -> int: @@ -19,10 +49,22 @@ def main() -> int: 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) + p.add_argument( + "--skip-precompute", action="store_true", + help="참조 lemma/요소 사전계산을 건너뛴다(질의 시 계산 후 백필됨)", + ) args = p.parse_args() if not args.database.exists(): p.error(f"database does not exist: {args.database}") + + store = CorpusStore(args.database) + precomputed = 0 + if not args.skip_precompute: + precomputed = precompute_features(store) + result = PersistentCorpusIndex(args.database, args.index_dir).sync(args.features) + result["precomputed_features"] = precomputed + result["missing_features"] = store.count_missing_features() print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 diff --git a/tests/test_ai_training_split.py b/tests/test_ai_training_split.py new file mode 100644 index 0000000..4bade16 --- /dev/null +++ b/tests/test_ai_training_split.py @@ -0,0 +1,167 @@ +"""AI 탐지기 학습 데이터 분리 검증 (#F). + +핵심 불변식 두 가지: + 1) fit=train / threshold=val / report=test 로 역할이 섞이지 않는다. + 2) 어떤 source_group 도 두 split 에 동시에 나타나지 않는다(누출 금지). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.build_ai_training_dataset import Record, assign_splits, summarize # noqa: E402 + +sklearn = pytest.importorskip("sklearn", reason="scikit-learn 미설치") +pytest.importorskip("joblib", reason="joblib 미설치") + + +# --------------------------------------------------------------------------- +# 분할 자체의 불변식 (sklearn 불필요 부분) +# --------------------------------------------------------------------------- + +def _records(n_groups_per_label: int = 6, per_group: int = 6) -> list[Record]: + out: list[Record] = [] + for label in (0, 1): + origin = "human" if label == 0 else "ai" + for g in range(n_groups_per_label): + group = f"{origin}:group-{g}" + for i in range(per_group): + out.append(Record( + text=f"{origin} 문단 {g}-{i}", label=label, origin=origin, + source_group=group, book=group, + )) + return out + + +def test_assign_splits_never_shares_a_group(): + records = _records() + assignment = assign_splits(records, (0.7, 0.15, 0.15), seed=7) + for rec in records: + rec.split = assignment[rec.source_group] + + by_split: dict[str, set[str]] = {} + for rec in records: + by_split.setdefault(rec.split, set()).add(rec.source_group) + splits = list(by_split) + for i, a in enumerate(splits): + for b in splits[i + 1:]: + assert not (by_split[a] & by_split[b]), f"{a}/{b} 그룹 중복 = 누출" + + assert summarize(records)["group_overlap_between_splits"] == [] + + +def test_assign_splits_is_deterministic_for_same_seed(): + a = assign_splits(_records(), (0.7, 0.15, 0.15), seed=11) + b = assign_splits(_records(), (0.7, 0.15, 0.15), seed=11) + assert a == b + + +def test_assign_splits_covers_all_three_splits(): + assignment = assign_splits(_records(), (0.7, 0.15, 0.15), seed=3) + assert set(assignment.values()) == {"train", "val", "test"} + + +# --------------------------------------------------------------------------- +# 학습 CLI end-to-end +# --------------------------------------------------------------------------- + +def _write_dataset(path: Path) -> None: + human = "비가 왔다. 나는 그날 학교에 가지 않았고 대신 뒷산에 올라가 온종일 앉아 있었다. 춥지는 않았다. 형이 왔다." + ai = "그날의 기억은 오래도록 남아, 지금까지도 선명하게 떠오르는 장면이 되었다. 아침의 공기는 서늘했고, 발걸음은 조용히 이어졌다." + rows = [] + for label, body, origin in ((0, human, "human"), (1, ai, "ai")): + for g in range(6): + split = "train" if g < 4 else ("val" if g == 4 else "test") + for i in range(8): + rows.append({ + "text": (body + f" 변형 {g}-{i}. ") * 3, + "label": label, "origin": origin, + "source_group": f"{origin}:g{g}", "book": f"{origin}-{g}", + "split": split, + }) + path.write_text( + "\n".join(json.dumps(r, ensure_ascii=False) for r in rows), encoding="utf-8" + ) + + +def _run_trainer(data: Path, out: Path, *extra: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "scripts/train_ai_detector.py", "--data", str(data), + "--out", str(out), *extra], + cwd=ROOT, capture_output=True, text=True, + ) + + +def test_trainer_uses_train_val_test_roles(tmp_path): + data = tmp_path / "ds.jsonl" + out = tmp_path / "model.joblib" + _write_dataset(data) + + proc = _run_trainer(data, out) + assert proc.returncode == 0, proc.stderr[-2000:] + + metrics = json.loads((tmp_path / "model.metrics.json").read_text(encoding="utf-8")) + # 세 역할이 모두 기록되어야 한다 + assert metrics["n_train"] > 0 and metrics["n_val"] > 0 and metrics["n_test"] > 0 + assert {"train", "validation", "test"} <= set(metrics) + # 임계값은 val 에서 뽑혔음이 지표에 남아야 한다 + assert "validation_low_point" in metrics["cuts"] + assert "validation_high_point" in metrics["cuts"] + # 학습에 쓰인 표본 수와 보고 표본 수가 서로 다른 집합이어야 한다 + assert metrics["n_train"] != metrics["n_test"] or metrics["n_val"] != metrics["n_test"] + + +def test_trainer_rejects_group_overlap_between_splits(tmp_path): + data = tmp_path / "leaky.jsonl" + out = tmp_path / "model.joblib" + _write_dataset(data) + + rows = [json.loads(line) for line in data.read_text(encoding="utf-8").splitlines()] + for row in rows: + # train 그룹 하나를 test 에도 등장시켜 누출을 주입 + if row["source_group"] == "human:g0" and row["split"] == "train": + row["split"] = "test" + break + data.write_text( + "\n".join(json.dumps(r, ensure_ascii=False) for r in rows), encoding="utf-8" + ) + + proc = _run_trainer(data, out) + assert proc.returncode == 2, "그룹 누출은 학습을 중단시켜야 한다" + assert "누출" in proc.stderr or "중복" in proc.stderr + + +def test_trainer_fails_on_single_class(tmp_path): + data = tmp_path / "one.jsonl" + _write_dataset(data) + rows = [json.loads(line) for line in data.read_text(encoding="utf-8").splitlines()] + kept = [r for r in rows if r["label"] == 0] + data.write_text( + "\n".join(json.dumps(r, ensure_ascii=False) for r in kept), encoding="utf-8" + ) + proc = _run_trainer(data, tmp_path / "m.joblib") + assert proc.returncode == 2 + assert "단일 클래스" in proc.stderr + + +def test_trainer_fails_when_a_split_is_empty(tmp_path): + data = tmp_path / "noval.jsonl" + _write_dataset(data) + rows = [json.loads(line) for line in data.read_text(encoding="utf-8").splitlines()] + for row in rows: + if row["split"] == "val": + row["split"] = "train" + data.write_text( + "\n".join(json.dumps(r, ensure_ascii=False) for r in rows), encoding="utf-8" + ) + proc = _run_trainer(data, tmp_path / "m.joblib") + assert proc.returncode == 2 + assert "빈 split" in proc.stderr diff --git a/tests/test_api.py b/tests/test_api.py index 0368144..a34d643 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -56,3 +56,69 @@ def test_batch_flow(): job_id = resp.json()["job_id"] status = client.get(f"/v1/plagiarism/batch/{job_id}") assert status.status_code == 200 + + +def test_detect_exposes_score_semantics(): + """#9 — 점수/임계값의 의미가 응답에 명시되어야 한다.""" + with TestClient(app) as client: + resp = client.post( + "/v1/plagiarism/detect", + json={"doc_id": "s-1", "text": "어린왕자는 작은 별에서 온 소년이다."}, + ) + assert resp.status_code == 200 + sem = resp.json()["score_semantics"] + assert sem["threshold_source"] == "server_default" + assert sem["threshold_calibrated"] is False + assert sem["provisional"] is True, "캘리브레이션 전에는 잠정값으로 노출" + assert 0.0 <= sem["union_coverage"] <= 1.0 + assert sem["query_chars"] > 0 + assert "침해 확률이 아닙니다" in sem["note"] + + +def test_detect_reports_request_threshold_override(): + with TestClient(app) as client: + resp = client.post( + "/v1/plagiarism/detect", + json={"doc_id": "s-2", "text": "앤 셜리는 초록 지붕 집에 입양된 소녀다.", + "options": {"threshold": 0.4}}, + ) + sem = resp.json()["score_semantics"] + assert sem["threshold_source"] == "request_override" + assert sem["threshold_used"] == 0.4 + + +def test_detect_accepts_legal_context(): + """#10 — 사람이 확인한 사실을 전달하면 missing_factors 에서 빠진다.""" + with TestClient(app) as client: + base = client.post( + "/v1/plagiarism/detect", + json={"doc_id": "l-1", "text": "홍길동은 활빈당을 만들어 재물을 나누었다."}, + ).json()["legal_risk"] + assert base["access_evidence"] == "not_provided" + assert len(base["missing_factors"]) == 3 + + supplied = client.post( + "/v1/plagiarism/detect", + json={ + "doc_id": "l-2", "text": "홍길동은 활빈당을 만들어 재물을 나누었다.", + "legal_context": { + "work_type": "literary", "access_evidence": True, + "protected_expression_reviewed": True, "rights_verified": True, + }, + }, + ).json()["legal_risk"] + assert supplied["access_evidence"] == "provided" + assert supplied["protected_expression"] == "reviewed" + assert supplied["missing_factors"] == [] + + +def test_matches_carry_match_reasons(): + with TestClient(app) as client: + resp = client.post( + "/v1/plagiarism/detect", + json={"doc_id": "r-1", "text": "어린왕자는 작은 별에서 온 소년이다. 그는 여우를 만난다.", + "options": {"threshold": 0.01}}, + ) + for match in resp.json()["matches"]: + assert match["match_reasons"], "채택 이유가 비어 있으면 안 된다" + assert set(match["match_reasons"]) <= {"score_threshold", "exact_span", "coverage"} diff --git a/tests/test_auth_middleware.py b/tests/test_auth_middleware.py new file mode 100644 index 0000000..49d18f8 --- /dev/null +++ b/tests/test_auth_middleware.py @@ -0,0 +1,144 @@ +"""API 키 인증 미들웨어 (#1). + +앱 전체를 띄우지 않고 순수 함수로 검증한다. 엔진 기동(코퍼스 인덱싱)을 타면 +테스트가 느려지고 인증 로직과 무관한 이유로 깨진다. +""" + +from __future__ import annotations + +import pytest + +from app.core.config import Settings +from app.main import ( + AuthConfigurationError, + is_authorized, + public_paths, + validate_auth_settings, +) + + +def _settings(**overrides) -> Settings: + base = {"api_key": "", "require_api_key": False, + "public_health": True, "public_docs": True} + base.update(overrides) + return Settings(**base) + + +# --------------------------------------------------------------------------- +# fail-closed 기동 검증 +# --------------------------------------------------------------------------- + +def test_require_api_key_without_key_fails_startup(): + with pytest.raises(AuthConfigurationError) as exc: + validate_auth_settings(_settings(require_api_key=True, api_key="")) + assert "REQUIRE_API_KEY" in str(exc.value) + + +def test_require_api_key_with_whitespace_only_key_fails(): + with pytest.raises(AuthConfigurationError): + validate_auth_settings(_settings(require_api_key=True, api_key=" ")) + + +def test_require_api_key_with_key_starts_fine(): + validate_auth_settings(_settings(require_api_key=True, api_key="secret")) + + +def test_default_settings_start_without_key(): + """기본값(개발)에서는 기동을 막지 않는다 — 기존 동작 보존.""" + validate_auth_settings(_settings()) + + +# --------------------------------------------------------------------------- +# 기존 기본 동작 보존 +# --------------------------------------------------------------------------- + +def test_no_key_configured_allows_everything(): + s = _settings(api_key="") + assert is_authorized(s, "/v1/plagiarism/detect", "") is True + assert is_authorized(s, "/v1/corpus", "") is True + + +def test_key_configured_rejects_missing_and_wrong_key(): + s = _settings(api_key="secret") + assert is_authorized(s, "/v1/plagiarism/detect", "") is False + assert is_authorized(s, "/v1/plagiarism/detect", "wrong") is False + assert is_authorized(s, "/v1/plagiarism/detect", "secret") is True + + +def test_corpus_write_paths_are_protected(): + s = _settings(api_key="secret") + for path in ("/v1/corpus", "/v1/corpus/file", "/v1/corpus/doc-1", "/v1/plagiarism/batch"): + assert is_authorized(s, path, "") is False, path + + +# --------------------------------------------------------------------------- +# 공개 경로 설정 +# --------------------------------------------------------------------------- + +def test_health_public_by_default(): + s = _settings(api_key="secret") + assert "/v1/health" in public_paths(s) + assert is_authorized(s, "/v1/health", "") is True + + +def test_health_can_be_protected(): + s = _settings(api_key="secret", public_health=False) + assert "/v1/health" not in public_paths(s) + assert is_authorized(s, "/v1/health", "") is False + assert is_authorized(s, "/v1/health", "secret") is True + + +def test_docs_public_by_default_and_can_be_closed(): + s = _settings(api_key="secret") + assert "/openapi.json" in public_paths(s) + closed = _settings(api_key="secret", public_docs=False) + assert "/openapi.json" not in public_paths(closed) + for path in ("/docs", "/openapi.json", "/redoc"): + assert is_authorized(closed, path, "") is False + assert is_authorized(closed, path, "secret") is True + + +def test_root_console_stays_public(): + s = _settings(api_key="secret") + assert is_authorized(s, "/", "") is True + + +# --------------------------------------------------------------------------- +# 회귀: 비ASCII 키가 TypeError 로 500 을 내지 않아야 한다 +# --------------------------------------------------------------------------- + +def test_non_ascii_key_does_not_raise(): + s = _settings(api_key="비밀키-한글🔑") + assert is_authorized(s, "/v1/plagiarism/detect", "비밀키-한글🔑") is True + assert is_authorized(s, "/v1/plagiarism/detect", "틀린키") is False + assert is_authorized(s, "/v1/plagiarism/detect", "") is False + + +def test_non_ascii_configured_key_returns_401_not_500(): + """서버에 한글 키를 설정해도 500 이 아니라 401 이어야 한다. + + HTTP 헤더는 비ASCII 를 전송할 수 없으므로 이런 키는 사실상 인증 불가지만, + 최소한 서버가 TypeError 로 터지면 안 된다. (운영 키는 ASCII 로 발급할 것) + """ + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse + from fastapi.testclient import TestClient + + from app.main import is_authorized as guard + + settings = _settings(api_key="한글키") + app = FastAPI() + + @app.middleware("http") + async def auth(request: Request, call_next): + if not guard(settings, request.url.path, request.headers.get("x-api-key", "")): + return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) + return await call_next(request) + + @app.get("/v1/thing") + async def thing(): + return {"ok": True} + + with TestClient(app) as client: + assert client.get("/v1/thing").status_code == 401 + assert client.get("/v1/thing", headers={"x-api-key": "ascii-guess"}).status_code == 401 diff --git a/tests/test_coverage_and_features.py b/tests/test_coverage_and_features.py new file mode 100644 index 0000000..263aa82 --- /dev/null +++ b/tests/test_coverage_and_features.py @@ -0,0 +1,337 @@ +"""union coverage (#3), 참조 특징 캐시 (#5), 점수 의미 (#9), 법적 맥락 (#10). + +scipy/sklearn 이 없으면 인덱스 관련 테스트는 skip 된다. +""" + +from __future__ import annotations + +import pytest + +from app.engine.legal_risk import LegalRiskEngine, Precedent +from app.engine.persistent_index import _covered_length, _merge_intervals +from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord + +scipy = pytest.importorskip("scipy", reason="scipy 미설치") + +from app.engine.persistent_index import PersistentCorpusIndex # noqa: E402 + + +def _segment(segment_id: str, text: str, **kw) -> SegmentRecord: + return SegmentRecord( + segment_id=segment_id, document_id=kw.pop("document_id", "doc-1"), + text=text, ordinal=segment_id, char_start=0, char_end=len(text), + source_locator=f"book.json#{segment_id}", **kw, + ) + + +def _index_with(tmp_path, segments: list[SegmentRecord], title="원본"): + db = tmp_path / "corpus.sqlite3" + store = CorpusStore(db) + for doc_id in {s.document_id for s in segments}: + store.upsert_document(DocumentRecord(document_id=doc_id, title=title)) + store.add_segments(segments) + index = PersistentCorpusIndex(db, tmp_path / "index") + index.sync() + return store, index + + +# --------------------------------------------------------------------------- +# 구간 병합 (순수 함수) +# --------------------------------------------------------------------------- + +def test_merge_intervals_deduplicates_overlap(): + assert _merge_intervals([(0, 10), (5, 20), (30, 40)]) == [(0, 20), (30, 40)] + assert _covered_length([(0, 10), (5, 20)]) == 20 + assert _covered_length([(0, 10), (0, 10)]) == 10, "중복 구간을 두 번 세면 안 된다" + assert _covered_length([]) == 0 + + +def test_merge_intervals_handles_adjacent_and_nested(): + assert _merge_intervals([(0, 10), (10, 20)]) == [(0, 20)] + assert _merge_intervals([(0, 100), (10, 20)]) == [(0, 100)] + + +# --------------------------------------------------------------------------- +# #3 union coverage +# --------------------------------------------------------------------------- + +def test_union_coverage_sums_multiple_segments(tmp_path): + """서로 다른 세그먼트가 질의의 다른 부분과 일치하면 coverage 가 합산된다.""" + part_a = "바닷가 마을에서 파도 소리를 들으며 자란 기억이 아직도 선명하게 남아 있다. " * 3 + part_b = "군에 입대하던 날 아버지는 아무 말 없이 내 어깨를 두드려 주셨던 기억이 난다. " * 3 + _, index = _index_with(tmp_path, [ + _segment("seg-a", part_a), _segment("seg-b", part_b), + ]) + query = part_a + "완전히 무관한 중간 문단입니다. " * 20 + part_b + result = index.search(query, top_k=10) + + assert result.union_coverage > 0.4, "두 구간이 모두 반영되어야 한다" + assert result.covered_chars >= len(part_a) + assert result.query_chars == len(query) + # 개별 세그먼트 coverage 는 각자 union 보다 작다 + per_hit = [h.coverage for h in result.hits if h.reranked] + assert max(per_hit) < result.union_coverage + + +def test_document_coverage_does_not_leak_between_sources(tmp_path): + part_a = "바닷가 마을에서 파도 소리를 들으며 자랐다. " * 3 + part_b = "군에 입대하던 날 아버지가 내 어깨를 두드렸다. " * 3 + _, index = _index_with(tmp_path, [ + _segment("seg-a", part_a, document_id="doc-a"), + _segment("seg-b", part_b, document_id="doc-b"), + ]) + query = part_a + ("서로 무관한 중간 문장입니다. " * 20) + part_b + result = index.search(query, top_k=10) + + assert result.union_coverage > result.document_coverage["doc-a"] + assert result.union_coverage > result.document_coverage["doc-b"] + assert result.document_coverage["doc-a"] < 0.30 + assert result.document_coverage["doc-b"] < 0.30 + + +def test_union_coverage_low_for_long_unrelated_document(tmp_path): + _, index = _index_with(tmp_path, [_segment("seg-1", "바닷가 마을의 파도 소리를 기억한다.")]) + query = "전혀 다른 주제의 글입니다. 오늘 회의에서 분기 실적을 논의했습니다. " * 100 + result = index.search(query, top_k=5) + assert result.union_coverage < 0.1 + + +def test_short_full_copy_reaches_high_coverage(tmp_path): + text = "나는 어린 시절 바닷가 마을에서 살았고 매일 파도 소리를 들으며 잠들었다." + _, index = _index_with(tmp_path, [_segment("seg-1", text)]) + result = index.search(text, top_k=5) + assert result.union_coverage > 0.8 + + +def test_long_document_partial_copy_is_not_structurally_zero(tmp_path): + """긴 원고 안의 부분 복사가 coverage 에 실제로 잡히는지 (#3 회귀).""" + copied = "바닷가 마을에서 파도 소리를 들으며 자란 기억이 선명하다. " * 10 + _, index = _index_with(tmp_path, [_segment("seg-1", copied)]) + filler = "무관한 문장입니다. " * 300 + query = filler + copied + filler + result = index.search(query, top_k=5) + assert result.covered_chars >= len(copied) * 0.5 + assert result.union_coverage > 0.0 + + +# --------------------------------------------------------------------------- +# CPU 상한 +# --------------------------------------------------------------------------- + +def test_evidence_limit_caps_precise_comparison(tmp_path): + segments = [ + _segment(f"seg-{i}", f"바닷가 마을 이야기 {i}번 문단입니다. 파도 소리를 들었다. " * 3) + for i in range(8) + ] + _, index = _index_with(tmp_path, segments) + result = index.search("바닷가 마을 이야기 파도 소리를 들었다.", top_k=8, evidence_limit=3) + + reranked = [h for h in result.hits if h.reranked] + assert len(reranked) == 3 + assert result.evidence_truncated is True + for hit in result.hits: + if not hit.reranked: + assert hit.evidence == [] and hit.coverage == 0.0 and hit.longest_span == 0 + + +def test_query_wrapper_still_returns_hits(tmp_path): + """기존 호출부 호환 — query() 는 여전히 list[PersistentHit].""" + _, index = _index_with(tmp_path, [_segment("seg-1", "바닷가 마을의 파도 소리.")]) + hits = index.query("바닷가 마을의 파도 소리.", top_k=1) + assert isinstance(hits, list) and hits[0].segment_id == "seg-1" + + +# --------------------------------------------------------------------------- +# #5 참조 특징 캐시 + 마이그레이션 +# --------------------------------------------------------------------------- + +def test_v1_database_migrates_without_data_loss(tmp_path): + """lemmas_json/elements_json 없는 기존 DB 도 그대로 열려야 한다.""" + import sqlite3 + + db = tmp_path / "old.sqlite3" + con = sqlite3.connect(db) + con.executescript( + """ + CREATE TABLE corpus_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE 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 DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE segments ( + segment_id TEXT PRIMARY KEY, document_id TEXT NOT NULL, + 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 DEFAULT CURRENT_TIMESTAMP, + UNIQUE(document_id, text_sha256)); + INSERT INTO documents(document_id,title) VALUES('doc-1','옛 책'); + INSERT INTO segments VALUES('seg-1','doc-1','1','옛 본문입니다.','h','episode', + NULL,NULL,0,7,NULL,'{}',CURRENT_TIMESTAMP); + """ + ) + con.commit() + con.close() + + store = CorpusStore(db) + store.initialize() # 마이그레이션 + segments = list(store.iter_segments()) + assert len(segments) == 1 + assert segments[0].text == "옛 본문입니다." + assert segments[0].lemmas is None + assert store.count_missing_features() == 1 + + +def test_feature_roundtrip_and_backfill(tmp_path): + store = CorpusStore(tmp_path / "corpus.sqlite3") + store.upsert_document(DocumentRecord(document_id="doc-1", title="책")) + store.add_segments([_segment("seg-1", "홍길동은 활빈당을 만들었다.")]) + assert store.count_missing_features() == 1 + + store.update_segment_features([("seg-1", ["홍길동", "활빈당"], {"characters": ["홍길동"]})]) + assert store.count_missing_features() == 0 + loaded = store.get_segments(["seg-1"])["seg-1"] + assert loaded.lemmas == ["홍길동", "활빈당"] + assert loaded.elements == {"characters": ["홍길동"]} + + +def test_precomputed_features_reach_the_hit(tmp_path): + store = CorpusStore(tmp_path / "corpus.sqlite3") + store.upsert_document(DocumentRecord(document_id="doc-1", title="책")) + store.add_segments([_segment( + "seg-1", "나는 어린 시절 바닷가 마을에서 살았다.", + lemmas=["바닷가", "마을", "살다"], + elements={"characters": [], "motifs": [], "genre": None, "keywords": ["바닷가"]}, + )]) + index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index") + index.sync() + hit = index.query("나는 어린 시절 바닷가 마을에서 살았다.", top_k=1)[0] + assert hit.reference_lemmas == ["바닷가", "마을", "살다"] + assert hit.reference_elements["keywords"] == ["바닷가"] + + +def test_detector_uses_cache_instead_of_recomputing(monkeypatch, tmp_path): + """캐시가 있으면 형태소 분석을 호출하지 않아야 한다 (#5 호출횟수 테스트).""" + from app.api.schemas import ExtractedElements + from app.engine import detector as det_module + from app.engine.persistent_index import PersistentHit + + calls = {"lemmas": 0, "extract": 0} + + def counting_lemmas(text, *a, **kw): + calls["lemmas"] += 1 + return ["x"] + + monkeypatch.setattr(det_module, "extract_lemmas", counting_lemmas) + + detector = det_module.PlagiarismDetector.__new__(det_module.PlagiarismDetector) + detector._feature_cache = {} + detector._persistent = None + + class _Extractor: + def extract(self, text): + calls["extract"] += 1 + return ExtractedElements() + + detector._extractor = _Extractor() + + cached_hit = PersistentHit( + segment_id="seg-1", document_id="doc-1", title="책", score=0.9, + evidence=[], coverage=0.0, longest_span=0, source_locator=None, + coordinate_scope="episode", page_number=None, paragraph_number=None, + source_char_start=None, source_char_end=None, reference_text="본문", + reference_lemmas=["미리", "계산"], + reference_elements={"characters": [], "motifs": [], "genre": None, "keywords": []}, + ) + lemmas, _ = detector.reference_features(cached_hit) + assert lemmas == ["미리", "계산"] + assert calls == {"lemmas": 0, "extract": 0}, "캐시가 있는데 재계산했다" + + uncached = PersistentHit( + segment_id="seg-2", document_id="doc-1", title="책", score=0.9, + evidence=[], coverage=0.0, longest_span=0, source_locator=None, + coordinate_scope="episode", page_number=None, paragraph_number=None, + source_char_start=None, source_char_end=None, reference_text="본문", + ) + detector.reference_features(uncached) + assert calls == {"lemmas": 1, "extract": 1} + # 두 번째 호출은 프로세스 캐시로 처리 + detector.reference_features(uncached) + assert calls == {"lemmas": 1, "extract": 1} + + +# --------------------------------------------------------------------------- +# #10 법적 맥락 + 판례 랭킹 +# --------------------------------------------------------------------------- + +def _precedents() -> list[Precedent]: + return [ + Precedent("2020다1", "가", "https://x/1", ("literary",), + ("reproduction",), (), "요지1"), + Precedent("2019다2", "나", "https://x/2", ("literary",), + ("reproduction", "derivative_work"), (), "요지2"), + Precedent("2018다3", "다", "https://x/3", ("musical",), + ("reproduction",), (), "요지3"), + ] + + +def test_precedent_ranking_prefers_more_tag_overlap(): + engine = LegalRiskEngine(_precedents()) + out = engine.assess( + max_similarity=0.9, coverage=0.5, longest_span=200, + legal_tags=["reproduction", "derivative_work"], work_type="literary", + ) + assert out.precedent_ids[0] == "2019다2", "태그 교집합이 큰 판례가 먼저" + assert "2018다3" not in out.precedent_ids, "work_type 이 다른 판례는 제외" + + +def test_only_registered_precedents_are_returned(): + engine = LegalRiskEngine(_precedents()) + out = engine.assess( + max_similarity=0.9, coverage=0.5, longest_span=200, + legal_tags=["reproduction"], work_type="literary", + ) + registered = {p.case_id for p in _precedents()} + assert set(out.precedent_ids) <= registered + + +def test_empty_precedent_db_reports_insufficient(): + out = LegalRiskEngine([]).assess( + max_similarity=0.9, coverage=0.9, longest_span=500, legal_tags=["reproduction"], + ) + assert out.status == "insufficient_precedent_data" + assert out.risk_level is None + assert out.precedent_ids == () + + +def test_legal_context_clears_missing_factors(): + engine = LegalRiskEngine(_precedents()) + default = engine.assess(max_similarity=0.5, coverage=0.1, longest_span=20, + legal_tags=["reproduction"]) + assert len(default.missing_factors) == 3 + assert default.access_evidence == "not_provided" + + supplied = engine.assess( + max_similarity=0.5, coverage=0.1, longest_span=20, legal_tags=["reproduction"], + access_evidence=True, protected_expression_reviewed=True, rights_verified=True, + ) + assert supplied.missing_factors == () + assert supplied.access_evidence == "provided" + assert supplied.protected_expression == "reviewed" + + +def test_legal_context_flows_through_detect_request(): + from app.api.schemas import DetectRequest, LegalContext + + req = DetectRequest( + doc_id="d", text="본문", + legal_context=LegalContext(work_type="musical", access_evidence=False, + protected_expression_reviewed=True), + ) + assert req.legal_context.work_type == "musical" + assert req.legal_context.access_evidence is False + assert req.legal_context.rights_verified is False + # 미제공이 기본 + assert DetectRequest(doc_id="d", text="본문").legal_context is None diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py new file mode 100644 index 0000000..b7ac068 --- /dev/null +++ b/tests/test_review_regressions.py @@ -0,0 +1,144 @@ +"""이미 반영된 리뷰 항목의 회귀 방지 (#2 #4 #6 #7 #8).""" + +from __future__ import annotations + +import json + +import pytest + +from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord + +pytest.importorskip("scipy", reason="scipy 미설치") + +from app.engine.persistent_index import ( # noqa: E402 + VECTORIZER_CONFIG, + PersistentCorpusIndex, +) + + +def _store_with(tmp_path, texts: list[str]) -> CorpusStore: + store = CorpusStore(tmp_path / "corpus.sqlite3") + store.upsert_document(DocumentRecord(document_id="doc-1", title="책")) + store.add_segments([ + SegmentRecord(segment_id=f"seg-{i}", document_id="doc-1", text=t, + ordinal=str(i), char_start=0, char_end=len(t)) + for i, t in enumerate(texts) + ]) + return store + + +# --- #2 원자적 교체 ------------------------------------------------------- + +def test_matrix_file_is_generation_scoped(tmp_path): + """세대별 파일명이라 교체 중 이전 인덱스가 덮어써지지 않는다.""" + store = _store_with(tmp_path, ["첫 번째 세그먼트 본문입니다."]) + index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index") + index.sync() + first = json.loads((tmp_path / "index" / "index.json").read_text())["matrix_file"] + + store.add_segments([SegmentRecord( + segment_id="seg-9", document_id="doc-1", text="두 번째 세그먼트 본문입니다.", + ordinal="9", char_start=0, char_end=10, + )]) + index.sync() + second = json.loads((tmp_path / "index" / "index.json").read_text())["matrix_file"] + + assert first != second, "세대가 바뀌면 파일명도 바뀌어야 한다" + assert (tmp_path / "index" / first).exists(), "이전 세대 파일이 살아 있어야 한다" + + +def test_replaced_index_object_is_self_consistent(tmp_path): + """load() 한 객체는 matrix 행수와 segment_ids 길이가 항상 일치한다.""" + _store_with(tmp_path, [f"세그먼트 {i} 본문입니다. 파도 소리." for i in range(5)]) + index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index") + index.sync() + fresh = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").load() + assert fresh._matrix.shape[0] == len(fresh._meta["segment_ids"]) == 5 + + +# --- #8 vectorizer 설정 고정 --------------------------------------------- + +def test_index_with_different_vectorizer_config_is_rejected(tmp_path): + _store_with(tmp_path, ["본문입니다. 파도 소리를 들었다."]) + index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index") + index.sync() + + meta_path = tmp_path / "index" / "index.json" + meta = json.loads(meta_path.read_text()) + meta["vectorizer_config"] = {**VECTORIZER_CONFIG, "ngram_range": [2, 6]} + meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") + + with pytest.raises(ValueError, match="재빌드"): + PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").load() + + +def test_config_change_forces_rebuild_not_append(tmp_path): + _store_with(tmp_path, ["본문입니다. 파도 소리를 들었다."]) + index_dir = tmp_path / "index" + index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", index_dir) + index.sync() + # n_features 가 달라지면 append 가 성립하지 않아야 한다 + result = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", index_dir).sync(n_features=2**16) + assert result["mode"] == "rebuild" + + +# --- #6 document_count 캐시 ---------------------------------------------- + +def test_document_count_served_from_meta_without_table_scan(tmp_path, monkeypatch): + _store_with(tmp_path, ["본문입니다."]) + index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index") + index.sync() + loaded = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").load() + + def explode(): + raise AssertionError("document_count 가 매 요청 DB 를 스캔하고 있다") + + monkeypatch.setattr(loaded.store, "document_count", explode) + assert loaded.document_count == 1 + + +# --- #7 API 업로드 청킹 --------------------------------------------------- + +def test_api_upload_is_chunked_not_single_segment(tmp_path, monkeypatch): + from app.core.config import Settings + from app.engine.detector import PlagiarismDetector + + settings = Settings( + use_persistent_index=True, + corpus_db_path=str(tmp_path / "corpus.sqlite3"), + persistent_index_dir=str(tmp_path / "index"), + precedents_path=str(tmp_path / "none.jsonl"), + ai_detector_model_path=str(tmp_path / "none.joblib"), + use_clustering=False, + use_lsh_filter=False, + ) + _store_with(tmp_path, ["씨앗 세그먼트입니다."]) + PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").sync() + + detector = PlagiarismDetector(settings=settings) + assert detector.uses_persistent_index + + long_text = "긴 원고 문장입니다. 계속 이어집니다. " * 200 # 약 4,000자 + doc_id = detector.add_persistent_document(None, "업로드 원고", long_text) + + segments = [s for s in detector._persistent.store.iter_segments() + if s.document_id == doc_id] + assert len(segments) > 1, "문서 전체가 세그먼트 1개로 저장되면 부분 표절을 못 잡는다" + assert all(len(s.text) <= 1000 for s in segments) + # 오프셋이 원문을 정확히 복원해야 한다 + for seg in segments: + assert long_text.strip()[seg.char_start:seg.char_end] == seg.text + + +# --- #4 detect 가 이벤트 루프를 막지 않는지 ------------------------------- + +def test_detect_route_runs_in_threadpool(): + """라우트가 동기 detect 를 threadpool 로 넘기는지 (소스 계약 확인).""" + import inspect + + from app.api import routes + + source = inspect.getsource(routes.detect) + assert "run_in_threadpool" in source + for name in ("corpus_upload_json", "corpus_upload_file", "corpus_delete"): + assert "run_in_threadpool" in inspect.getsource(getattr(routes, name)), name From f4461f39c7e44551cf0991eddd646a8c24db1f33 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 11 Aug 2026 09:02:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20AI=20=EC=83=9D=EC=84=B1=20=EC=9D=98=EC=8B=AC?= =?UTF-8?q?=EB=8F=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/index.html | 96 +++++++++++++++++++++++++++++++++++++++-- tests/test_static_ui.py | 24 +++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/test_static_ui.py diff --git a/app/static/index.html b/app/static/index.html index 1f4fb3e..eade67a 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -92,6 +92,24 @@ .scorebar-row .label { color: var(--muted); } .scorebar-row .value { text-align: right; font-variant-numeric: tabular-nums; } + .ai-card { + background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; + padding: 14px; margin-top: 8px; + } + .ai-card.ai-high { border-color: var(--danger); background: rgba(248, 81, 73, 0.08); } + .ai-card.ai-medium { border-color: var(--warning); background: rgba(210, 153, 34, 0.08); } + .ai-card.ai-low { border-color: var(--success); background: rgba(63, 185, 80, 0.08); } + .ai-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .ai-title { font-size: 14px; font-weight: 600; } + .ai-score { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; } + .ai-note { color: var(--muted); font-size: 11px; margin-top: 8px; line-height: 1.6; } + .ai-warning { color: var(--warning); font-size: 11px; margin-top: 6px; } + .ai-segment { + margin-top: 8px; padding: 8px 10px; background: var(--bg); border-radius: 4px; + font-size: 11px; border-left: 3px solid var(--warning); + } + .ai-segment .meta { color: var(--muted); margin-bottom: 3px; } + .match-card { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 14px; margin-top: 10px; @@ -281,6 +299,9 @@

점수 분석 (삼중 유사도 결합)

+

AI 생성 의심도

+
+

매칭된 레퍼런스

@@ -468,18 +489,21 @@ async function runDetect() { function renderResult(data, originalText) { const verdict = document.getElementById("verdict"); + const displaySimilarity = data.review_summary && Number.isFinite(data.review_summary.similarity_percent) + ? data.review_summary.similarity_percent + : Math.round(data.confidence * 100); if (data.is_infringement) { verdict.className = "verdict infringement"; verdict.innerHTML = `

⚠ 저작권 침해 가능성 확인

-
결합 유사도
-
${(data.confidence * 100).toFixed(2)}%
`; +
보정 유사도
+
${displaySimilarity}%
`; } else { verdict.className = "verdict clean"; verdict.innerHTML = `

✓ 침해 신호 없음

-
최상위 유사도
-
${(data.confidence * 100).toFixed(2)}%
`; +
보정 유사도 · 검색 후보 점수는 침해 확률이 아님
+
${displaySimilarity}%
`; } document.getElementById("result-body").style.display = "block"; @@ -497,6 +521,8 @@ function renderResult(data, originalText) { breakdownEl.innerHTML = '
매칭된 레퍼런스가 없어 점수 분석을 표시할 수 없습니다.
'; } + renderAiGeneration(data.ai_generation, originalText); + // 매칭 카드 const matchesEl = document.getElementById("matches"); if (!data.matches || data.matches.length === 0) { @@ -541,6 +567,68 @@ function renderResult(data, originalText) { document.getElementById("raw-json").textContent = JSON.stringify(data, null, 2); } +function renderAiGeneration(ai, originalText) { + const el = document.getElementById("ai-generation"); + if (!ai || !ai.available) { + const note = ai && ai.note + ? ai.note + : "학습된 AI 생성 탐지 모델이 없어 의심도를 산출하지 않습니다."; + const version = ai && ai.model_version ? ai.model_version : "unavailable"; + el.innerHTML = ` +
+
+ 판정 불가 · 모델 준비 전 + ${escapeHtml(version)} +
+
${escapeHtml(note)}
+ ${renderAiWarnings(ai && ai.warnings)} +
`; + return; + } + + const level = ["low", "medium", "high"].includes(ai.suspicion_level) + ? ai.suspicion_level + : "unknown"; + const labels = { low: "낮음", medium: "중간", high: "높음", unknown: "판정 불가" }; + const score = Number.isFinite(ai.score) ? `${(ai.score * 100).toFixed(1)}%` : "—"; + const stub = ai.is_stub + ? '미검증 휴리스틱' + : `${escapeHtml(ai.model_version || "model")}`; + const provenance = ai.provenance && ai.provenance !== "unknown" + ? ` · 추정 유형 ${escapeHtml(ai.provenance)}` + : ""; + const segments = (ai.segments || []) + .filter(s => s.scored && Number.isFinite(s.score)) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map(s => { + const start = Math.max(0, Number(s.start) || 0); + const end = Math.max(start, Number(s.end) || start); + const snippet = originalText.substring(start, end).trim(); + return `
+
구간 ${start}–${end}자 · 의심도 ${(s.score * 100).toFixed(1)}%
+ ${escapeHtml(snippet.length > 160 ? snippet.slice(0, 160) + "…" : snippet)} +
`; + }).join(""); + + el.innerHTML = ` +
+
+
AI 생성 의심도 ${labels[level]}${stub}
+ ${score} +
+
검토 우선순위 신호이며 AI 작성 여부를 확정하지 않습니다.${provenance}
+ ${ai.note ? `
${escapeHtml(ai.note)}
` : ""} + ${renderAiWarnings(ai.warnings)} + ${segments} +
`; +} + +function renderAiWarnings(warnings) { + if (!warnings || warnings.length === 0) return ""; + return warnings.map(w => `
⚠ ${escapeHtml(w)}
`).join(""); +} + function renderEvidence(originalText, spans) { if (!spans || spans.length === 0) return ""; // span 정렬 후 마킹 diff --git a/tests/test_static_ui.py b/tests/test_static_ui.py new file mode 100644 index 0000000..541809b --- /dev/null +++ b/tests/test_static_ui.py @@ -0,0 +1,24 @@ +from pathlib import Path + + +HTML = (Path(__file__).resolve().parents[1] / "app" / "static" / "index.html").read_text( + encoding="utf-8" +) + + +def test_ai_generation_result_has_visible_frontend_section(): + assert 'id="ai-generation"' in HTML + assert "renderAiGeneration(data.ai_generation, originalText)" in HTML + assert "판정 불가 · 모델 준비 전" in HTML + assert "AI 생성 의심도" in HTML + + +def test_ai_generation_ui_does_not_present_score_as_a_certain_verdict(): + assert "AI 작성 여부를 확정하지 않습니다" in HTML + assert "미검증 휴리스틱" in HTML + assert "ai.available" in HTML + + +def test_clean_verdict_uses_calibrated_review_summary_percentage(): + assert "data.review_summary.similarity_percent" in HTML + assert "검색 후보 점수는 침해 확률이 아님" in HTML From a530d2139fe34a51d37bb13fc133119ac6162db1 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 11 Aug 2026 09:05:48 +0900 Subject: [PATCH 5/5] =?UTF-8?q?AI=20=EC=9D=98=EC=8B=AC=EB=8F=84=20?= =?UTF-8?q?=EB=B0=B1=EB=B6=84=EC=9C=84=20=EC=BA=98=EB=A6=AC=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20(=ED=95=99=EC=8A=B5=C2=B7?= =?UTF-8?q?=EC=A7=80=ED=91=9C=20=EC=97=86=EC=9D=B4=20=EC=9A=B4=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 생성 표본이 없어 정확도를 측정할 수 없는 상태에서, 규칙 기반 휴리스틱을 실제로 쓸 수 있게 만든다. 점수의 의미를 "AI일 확률"에서 "등록 코퍼스의 인간 저작물 대비 문체 이례도"로 재정의해, 라벨 없이도 참인 진술이 되도록 했다. 덤으로 high 배지 비율이 정의상 고정되어 검토 부하가 예측 가능해진다. - ai_detector: low_cut/high_cut 주입 지원. 둘 다 주어질 때만 적용하고, 적용되면 model_version 에 +corpus-percentile 을 붙여 컷 출처를 드러낸다. is_stub 은 여전히 true — 컷을 맞췄을 뿐 학습된 모델이 아니다. - calibrate_cuts/percentile/score_text_heuristic 순수 함수 추가. - scripts/calibrate_ai_detector_cuts.py: 코퍼스 표본의 점수 분포에서 백분위 컷을 산출하고 .env 두 줄을 출력한다. - 규칙 점수가 상단에서 clip 되어 p90=p98 이 되면 그 컷은 high 배지를 영원히 0건으로 만든다. 이 경우 .env 를 출력하지 않고 exit 3 으로 중단하며 saturated_share/distinct_scores 진단을 남긴다. 유효 표본 100건 미만은 exit 2. - kiwipiepy 유무가 점수를 바꾸므로 실제 사용 여부를 리포트에 기록하고 경고한다. 기본값은 그대로 비활성(AI_DETECTOR_ALLOW_HEURISTIC=false)이라 켜지 않으면 동작이 바뀌지 않는다. 컷 미설정 시에는 자리표시자임을 note 로 경고한다. 테스트 180건 통과 (신규 13건). Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 + app/core/config.py | 5 + app/engine/ai_detector.py | 104 ++++++++++++- app/engine/detector.py | 2 + docs/AI_DETECTION.md | 58 ++++++++ scripts/calibrate_ai_detector_cuts.py | 205 ++++++++++++++++++++++++++ tests/test_ai_detector.py | 144 ++++++++++++++++++ 7 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 scripts/calibrate_ai_detector_cuts.py diff --git a/.env.example b/.env.example index cd522ff..6710943 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,13 @@ 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 +# 학습 모델이 없을 때 규칙 기반 점수를 낼지. true 면 is_stub=true 로 노출된다. AI_DETECTOR_ALLOW_HEURISTIC=false +# 휴리스틱 컷. scripts/calibrate_ai_detector_cuts.py 출력값을 넣는다. +# 비워두면 근거 없는 자리표시자(0.40/0.70)가 쓰이므로 배지가 무의미해진다. +# 값이 없으면 아래 두 줄은 주석 처리한 채로 둘 것 (빈 값은 파싱 오류). +#AI_DETECTOR_LOW_CUT= +#AI_DETECTOR_HIGH_CUT= AI_DETECTOR_USE_POS=true # PDF VII-4 권장 보수적 임계값 (정밀도 우선) diff --git a/app/core/config.py b/app/core/config.py index faf4466..00fdc11 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -40,6 +40,11 @@ class Settings(BaseSettings): ai_detector_model_path: str = "./data/models/ai_detector.joblib" ai_detector_allow_heuristic: bool = False ai_detector_use_pos: bool = True + # 휴리스틱 모드 의심도 구간. scripts/calibrate_ai_detector_cuts.py 가 등록 + # 코퍼스(전부 인간 저작)의 점수 분포에서 백분위로 산출한다. 둘 다 설정해야 + # 적용되며, 없으면 근거 없는 자리표시자(0.40/0.70)가 쓰인다. + ai_detector_low_cut: float | None = None + ai_detector_high_cut: float | None = None # PDF VII-4 권장: 정밀도 우선 보수적 임계값 similarity_threshold: float = 0.85 diff --git a/app/engine/ai_detector.py b/app/engine/ai_detector.py index 9ec3ead..7585672 100644 --- a/app/engine/ai_detector.py +++ b/app/engine/ai_detector.py @@ -59,10 +59,17 @@ ENV_MODEL_PATH = "AI_DETECTOR_MODEL_PATH" DEFAULT_MODEL_PATH = "./data/models/ai_detector.joblib" #: 잠정 의심도 구간. 학습 시 target FPR 기준으로 재산출되어 아티팩트에 저장되며, -#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 모델이 없을 때의 자리표시자다. +#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 아무 근거 없는 자리표시자이므로, +#: 휴리스틱 모드로 운영할 때는 scripts/calibrate_ai_detector_cuts.py 로 산출한 +#: 백분위 컷을 설정으로 주입할 것. DEFAULT_LOW_CUT = 0.40 DEFAULT_HIGH_CUT = 0.70 +#: 백분위 캘리브레이션 기본값. 등록 코퍼스(전부 인간 저작) 대비 상위 10%를 +#: medium, 상위 2%를 high 로 본다. "AI 확률"이 아니라 "인간 저작 대비 이례도"다. +DEFAULT_LOW_PERCENTILE = 90.0 +DEFAULT_HIGH_PERCENTILE = 98.0 + Provenance = Literal["human", "ai", "mixed", "edited", "unknown"] SuspicionLevel = Literal["low", "medium", "high"] @@ -592,6 +599,57 @@ def _heuristic_score(feats: dict[str, float]) -> float: return max(0.0, min(1.0, sum(positions) / len(positions))) +def percentile(values: Sequence[float], pct: float) -> float: + """선형 보간 백분위. numpy 없이도 동작하도록 직접 구현한다.""" + if not values: + return 0.0 + ordered = sorted(values) + if len(ordered) == 1: + return float(ordered[0]) + pos = (len(ordered) - 1) * max(0.0, min(100.0, pct)) / 100.0 + low = int(math.floor(pos)) + high = int(math.ceil(pos)) + if low == high: + return float(ordered[low]) + return float(ordered[low] + (ordered[high] - ordered[low]) * (pos - low)) + + +def calibrate_cuts( + human_scores: Sequence[float], + low_percentile: float = DEFAULT_LOW_PERCENTILE, + high_percentile: float = DEFAULT_HIGH_PERCENTILE, +) -> tuple[float, float]: + """인간 저작 코퍼스 점수 분포 → (low_cut, high_cut). + + 라벨이 전혀 필요 없다. "AI가 쓴 글의 점수는 얼마인가"가 아니라 "우리 코퍼스의 + 인간 저작물 중 상위 몇 %인가"로 컷을 정의하기 때문이다. 그래서 정확도를 + 측정하지 않고도 참인 진술("등록 자서전 대비 상위 2% 이례적 문체")이 되고, + 'high' 배지 비율이 정의상 고정되어 검토 부하도 예측 가능해진다. + + 두 백분위가 같은 값으로 뭉개지면(분포가 평평한 경우) high 를 살짝 올려 + medium 구간이 사라지지 않게 한다. + """ + if not human_scores: + raise ValueError("점수 표본이 비어 있어 컷을 산출할 수 없습니다.") + if not 0.0 <= low_percentile < high_percentile <= 100.0: + raise ValueError( + f"백분위는 0 <= low < high <= 100 이어야 합니다: {low_percentile}, {high_percentile}" + ) + low = percentile(human_scores, low_percentile) + high = percentile(human_scores, high_percentile) + if high <= low: + high = min(1.0, low + 0.01) + return round(low, 4), round(high, 4) + + +def score_text_heuristic(text: str, use_pos: bool = True) -> float | None: + """캘리브레이션용 단일 텍스트 점수. 너무 짧으면 None(표본에서 제외).""" + norm = normalize_text(text) + if len(norm) < HARD_MIN_CHARS: + return None + return _heuristic_score(extract_features(norm, use_pos=use_pos)) + + def _heuristic_contributions(feats: dict[str, float]) -> list[tuple[str, float]]: """휴리스틱에서 어느 특징이 점수를 끌어올렸는지.""" out: list[tuple[str, float]] = [] @@ -700,12 +758,17 @@ class AiGenerationDetector: model_path: str | Path | None = None, allow_heuristic: bool = False, use_pos: bool = True, + low_cut: float | None = None, + high_cut: float | None = None, ): 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.low_cut = low_cut + self.high_cut = high_cut self.artifact = load_artifact(self.model_path) # -- 모드 -------------------------------------------------------------- @@ -720,11 +783,26 @@ class AiGenerationDetector: 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" + if not self.allow_heuristic: + return "unavailable" + # 컷이 코퍼스로 캘리브레이션되었는지를 버전 문자열에 드러낸다. + return ( + f"{self.HEURISTIC_VERSION}+corpus-percentile" + if self.cuts_calibrated else self.HEURISTIC_VERSION + ) + + @property + def cuts_calibrated(self) -> bool: + """컷이 자리표시자가 아니라 실제 코퍼스 분포에서 나왔는지.""" + if self.artifact is not None: + return True + return self.low_cut is not None and self.high_cut is not None def _cuts(self) -> tuple[float, float]: if self.artifact is not None: return self.artifact.low_cut, self.artifact.high_cut + if self.low_cut is not None and self.high_cut is not None: + return self.low_cut, self.high_cut return DEFAULT_LOW_CUT, DEFAULT_HIGH_CUT def _level(self, score: float) -> SuspicionLevel: @@ -862,10 +940,17 @@ class AiGenerationDetector: segments = self._score_segments(norm) if with_segments else [] provenance = self._infer_provenance(score, segments) - if self.artifact is None: + if self.artifact is None and self.cuts_calibrated: note = ( - "⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델이 아니며 " - "판정 근거로 사용할 수 없습니다. 검토 우선순위 참고용." + "학습 모델이 아니라 언어특징 규칙 점수입니다. 'AI일 확률'이 아니라 " + "등록 코퍼스의 인간 저작물 대비 문체 이례도이며, 검토 우선순위 " + "정렬용입니다. 판정 근거로 사용할 수 없습니다." + ) + elif self.artifact is None: + note = ( + "⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델도 아니고 컷도 " + "캘리브레이션되지 않았습니다(자리표시자). 판정 근거로 사용할 수 " + "없습니다. scripts/calibrate_ai_detector_cuts.py 로 컷을 산출하세요." ) else: note = ( @@ -1008,21 +1093,26 @@ def get_ai_detector( model_path: str | Path | None = None, allow_heuristic: bool | None = None, use_pos: bool = True, + low_cut: float | None = None, + high_cut: float | None = None, ) -> AiGenerationDetector: """탐지기 인스턴스(프로세스 캐시). allow_heuristic 을 생략하면 환경변수 AI_DETECTOR_ALLOW_HEURISTIC 을 따르고, 그것도 없으면 False(= 모델 없으면 unavailable) 다. 기본값을 False 로 두는 이유는, 미검증 점수가 조용히 운영에 노출되는 상황을 막기 위해서다. + + low_cut/high_cut 은 휴리스틱 모드에서만 쓰이며, 둘 다 주어져야 적용된다. """ 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)) + key = (str(model_path or ""), bool(allow_heuristic), bool(use_pos), low_cut, high_cut) if key not in _detector_cache: _detector_cache[key] = AiGenerationDetector( - model_path=model_path, allow_heuristic=allow_heuristic, use_pos=use_pos + model_path=model_path, allow_heuristic=allow_heuristic, use_pos=use_pos, + low_cut=low_cut, high_cut=high_cut, ) return _detector_cache[key] diff --git a/app/engine/detector.py b/app/engine/detector.py index 1459708..3d29d38 100644 --- a/app/engine/detector.py +++ b/app/engine/detector.py @@ -70,6 +70,8 @@ class PlagiarismDetector: self.settings.ai_detector_model_path, allow_heuristic=self.settings.ai_detector_allow_heuristic, use_pos=self.settings.ai_detector_use_pos, + low_cut=self.settings.ai_detector_low_cut, + high_cut=self.settings.ai_detector_high_cut, ) self._persistent: PersistentCorpusIndex | None = None diff --git a/docs/AI_DETECTION.md b/docs/AI_DETECTION.md index 3b0bc5e..01379f6 100644 --- a/docs/AI_DETECTION.md +++ b/docs/AI_DETECTION.md @@ -269,3 +269,61 @@ python -m pytest tests/test_ai_detector.py -q 일치 / unavailable 시 점수 미산출 / 휴리스틱의 스텁 표기와 방향성 / 짧은 텍스트 거부·경고 / 구간 오프셋 유효성 / provenance 규칙 / 추론 실패 전파 / 특징 불일치 아티팩트 로드 거부 / JSON 직렬화. + +--- + +## 8. 지표 없이 운영하기 — 백분위 컷 (권장 경로) + +AI 생성 표본이 없어 정확도를 측정할 수 없을 때의 실용 경로다. **모델 학습도 +성능지표도 없이** 규칙 점수만으로 검토 우선순위를 매긴다. + +### 8.1 점수의 의미를 바꾼다 + +"AI일 확률"은 라벨 없이 측정할 수 없다. 대신 등록 코퍼스가 전부 인간 저작이라는 +사실을 이용해, 그 점수 분포의 상위 백분위를 컷으로 잡는다. 그러면 결과는 + +> "등록 자서전 대비 상위 2% 이례적 문체 → 우선 검토" + +가 되며, 이 진술은 **정확도를 측정하지 않아도 참**이다. 덤으로 `high` 배지 비율이 +정의상 고정되어 검토 부하가 예측 가능해진다. 반면 "AI 확률 72%"는 어떤 근거로도 +방어할 수 없다. 이 차이 때문에 백분위 컷을 권장한다. + +### 8.2 절차 + +```bash +# 서버와 같은 환경(kiwipiepy 포함)에서 실행할 것 — 품사 특징 유무가 점수를 바꾼다 +python scripts/calibrate_ai_detector_cuts.py \ + --database data/runtime/corpus.sqlite3 --sample 3000 +``` + +출력된 두 줄을 `.env` 에 넣고 `AI_DETECTOR_ALLOW_HEURISTIC=true` 와 함께 재시작한다. + +```dotenv +AI_DETECTOR_ALLOW_HEURISTIC=true +AI_DETECTOR_LOW_CUT=0.7024 +AI_DETECTOR_HIGH_CUT=0.7352 +``` + +`--low-percentile`(기본 90) / `--high-percentile`(기본 98) 로 배지 비율을 조절한다. +low/high 는 **둘 다** 설정해야 적용된다. 하나만 넣으면 자리표시자로 되돌아간다. + +### 8.3 스크립트가 거부하는 경우 + +규칙 점수는 각 규칙이 [0,1] 로 clip 되므로 상단에서 **포화**할 수 있다. 포화가 +심하면 p90 과 p98 이 같은 값이 되고, 그 컷을 쓰면 `high` 배지가 영원히 0건이 된다. +이 경우 스크립트는 `.env` 를 출력하지 않고 **exit 3** 으로 중단하며 진단을 남긴다 +(`saturated_share`, `distinct_scores`). 대응은 `--high-percentile` 을 낮추거나, +표본을 늘리거나, 표본이 특정 도서에 치우쳤는지 확인하는 것이다. + +유효 표본 100건 미만이면 백분위가 불안정하므로 **exit 2** 로 중단한다. + +### 8.4 캘리브레이션해도 달라지지 않는 것 + +- `is_stub` 은 **여전히 `true`** 다. 컷을 맞췄을 뿐 학습된 모델이 아니다. +- `model_version` 만 `heuristic-baseline-v1` → `heuristic-baseline-v1+corpus-percentile` + 로 바뀌어 컷의 출처를 드러낸다. +- AI 생성 텍스트를 실제로 구분한다는 근거는 **여전히 없다.** 이 점수가 높다는 것은 + "등록된 인간 원고들과 문체 통계가 다르다"는 뜻일 뿐이며, 번역체·대필·윤문·장르 + 차이 모두가 같은 방향으로 점수를 올린다. +- 따라서 저자·편집자에게 노출할 때는 "AI 생성 의심도"보다 **"문체 이례도"** 로 + 표기하고, 검토 우선순위 정렬 용도로만 쓸 것을 권한다. diff --git a/scripts/calibrate_ai_detector_cuts.py b/scripts/calibrate_ai_detector_cuts.py new file mode 100644 index 0000000..8af1ed0 --- /dev/null +++ b/scripts/calibrate_ai_detector_cuts.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""등록 코퍼스로 AI 의심도 컷을 백분위 캘리브레이션한다. + +왜 이렇게 하나: + AI 생성 표본이 없으면 "AI일 확률"은 측정할 수 없다. 그래서 점수의 의미를 + 바꾼다. 등록 코퍼스는 전부 인간 저작이므로, 그 점수 분포의 상위 백분위를 + 컷으로 잡으면 결과는 **"우리 코퍼스의 인간 저작물 대비 얼마나 이례적인 문체인가"** + 가 된다. 라벨도 지표도 필요 없고, "상위 2% 이례적 문체"라는 진술은 정확도를 + 측정하지 않아도 참이다. 덤으로 'high' 배지 비율이 정의상 고정되어 검토 부하가 + 예측 가능해진다. + + 이 값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다. + +입력: 영속 코퍼스 SQLite (기본) 또는 data/reference/*.txt +출력: JSON 리포트 + 그대로 붙여넣을 .env 두 줄 + +사용: + python scripts/calibrate_ai_detector_cuts.py \ + --database data/runtime/corpus.sqlite3 --sample 3000 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import sys +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("calibrate-cuts") + +from app.engine.ai_detector import ( # noqa: E402 + DEFAULT_HIGH_PERCENTILE, + DEFAULT_LOW_PERCENTILE, + HARD_MIN_CHARS, + calibrate_cuts, + percentile, + score_text_heuristic, +) + + +def _stable_pick(key: str) -> int: + """표본 추출용 결정적 해시. 같은 코퍼스면 항상 같은 표본이 뽑힌다.""" + return int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:8], 16) + + +def load_texts(database: Path | None, reference_dir: Path | None, sample: int) -> list[str]: + """코퍼스에서 텍스트를 표본 추출. 전량 처리는 느리므로 기본은 표본이다.""" + items: list[tuple[int, str]] = [] + + if database and database.exists(): + from app.engine.provenance import CorpusStore + + for seg in CorpusStore(database).iter_segments(): + items.append((_stable_pick(seg.segment_id), seg.text)) + logger.info("SQLite 세그먼트 %d건 로드: %s", len(items), database) + elif reference_dir and reference_dir.exists(): + for path in sorted(reference_dir.glob("*.txt")): + try: + items.append((_stable_pick(path.name), path.read_text(encoding="utf-8"))) + except UnicodeDecodeError: + logger.warning("UTF-8 아님, 건너뜀: %s", path.name) + logger.info("텍스트 파일 %d건 로드: %s", len(items), reference_dir) + else: + raise SystemExit("코퍼스를 찾을 수 없습니다. --database 또는 --reference-dir 확인.") + + items.sort(key=lambda kv: kv[0]) + if sample > 0: + items = items[:sample] + return [text for _, text in items] + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--database", type=Path, default=Path("data/runtime/corpus.sqlite3")) + ap.add_argument("--reference-dir", type=Path, default=Path("data/reference"), + help="SQLite 가 없을 때 쓰는 폴백") + ap.add_argument("--sample", type=int, default=3000, + help="표본 수 (0이면 전량). 3000이면 백분위 추정에 충분하다.") + ap.add_argument("--low-percentile", type=float, default=DEFAULT_LOW_PERCENTILE) + ap.add_argument("--high-percentile", type=float, default=DEFAULT_HIGH_PERCENTILE) + ap.add_argument("--no-pos", action="store_true", help="품사 특징 사용 안 함") + ap.add_argument("--out", type=Path, default=Path("data/models/ai_cuts.json")) + args = ap.parse_args() + + texts = load_texts(args.database, args.reference_dir, args.sample) + if not texts: + logger.error("표본이 0건입니다.") + return 2 + + # 실제로 품사 특징이 쓰였는지 확인 (kiwipiepy 없으면 자동 폴백된다) + from app.engine.ai_detector import extract_features + + pos_used = ( + not args.no_pos + and extract_features(texts[0], use_pos=not args.no_pos)["pos_available"] >= 1.0 + ) + if not args.no_pos and not pos_used: + logger.warning( + "kiwipiepy 를 쓸 수 없어 품사 특징 없이 채점합니다. 운영 서버와 " + "동일 조건이 아니면 컷이 맞지 않으니, 반드시 서버와 같은 환경에서 " + "실행하세요." + ) + + scores: list[float] = [] + skipped = 0 + for i, text in enumerate(texts, 1): + value = score_text_heuristic(text, use_pos=not args.no_pos) + if value is None: + skipped += 1 + continue + scores.append(value) + if i % 500 == 0: + logger.info("채점 %d/%d", i, len(texts)) + + logger.info("채점 완료: %d건 (%d자 미만 %d건 제외)", len(scores), HARD_MIN_CHARS, skipped) + if len(scores) < 100: + logger.error( + "유효 표본이 %d건뿐이라 백분위가 불안정합니다(최소 100). " + "--sample 을 늘리거나 코퍼스를 확인하세요.", len(scores) + ) + return 2 + + low_cut, high_cut = calibrate_cuts(scores, args.low_percentile, args.high_percentile) + + distribution = {f"p{p}": round(percentile(scores, p), 4) + for p in (5, 25, 50, 75, 90, 95, 98, 99)} + expected_medium = sum(1 for s in scores if low_cut <= s < high_cut) / len(scores) + expected_high = sum(1 for s in scores if s >= high_cut) / len(scores) + + # 포화 진단: 규칙 점수가 상단에서 clip 되면 p90=p98 이 되어 컷이 무의미해진다. + # 이 경우 .env 를 그대로 쓰면 high 배지가 영원히 0건이 되므로 거부한다. + top = max(scores) + saturated_share = sum(1 for s in scores if s >= top - 1e-9) / len(scores) + distinct = len(set(round(s, 4) for s in scores)) + target_high = (100.0 - args.high_percentile) / 100.0 + + report = { + "source": str(args.database if args.database.exists() else args.reference_dir), + "n_scored": len(scores), + "n_skipped_short": skipped, + "use_pos_requested": not args.no_pos, + "pos_actually_used": pos_used, + "low_percentile": args.low_percentile, + "high_percentile": args.high_percentile, + "low_cut": low_cut, + "high_cut": high_cut, + "distribution": distribution, + "distinct_scores": distinct, + "saturated_share": round(saturated_share, 4), + "expected_rate_on_human_corpus": { + "medium": round(expected_medium, 4), + "high": round(expected_high, 4), + "target_high": target_high, + }, + "note": ( + "인간 저작 코퍼스 대비 문체 이례도 컷이며 AI 작성 판정이 아니다. " + "expected_rate 는 인간 원고에서도 이 비율만큼 medium/high 가 나온다는 뜻." + ), + } + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(report, ensure_ascii=False, indent=2)) + + if expected_high <= 0.0: + logger.error( + "이 컷을 쓰면 'high' 배지가 **영원히 0건**입니다. 점수가 상단에서 " + "포화(최고점 동률 %.1f%%, 서로 다른 점수 %d개)되어 p%.0f 와 p%.0f 가 " + "같은 값이기 때문입니다. .env 를 출력하지 않습니다.", + saturated_share * 100, distinct, args.low_percentile, args.high_percentile, + ) + logger.error( + "대응: ① --high-percentile 을 낮춰 동률 구간 아래로 컷을 내리거나 " + "② --sample 을 늘려 분포를 넓히거나 ③ 표본이 특정 도서에 치우쳤는지 " + "확인하세요. 리포트는 %s 에 저장했습니다.", args.out, + ) + return 3 + + if expected_high > target_high * 3 or expected_high < target_high / 3: + logger.warning( + "예상 high 비율 %.2f%% 가 목표 %.2f%% 에서 크게 벗어났습니다" + "(점수 동률 때문). 배지 비율이 설계와 다르게 나옵니다.", + expected_high * 100, target_high * 100, + ) + + print("\n# .env 에 아래 두 줄을 추가하세요") + print(f"AI_DETECTOR_LOW_CUT={low_cut}") + print(f"AI_DETECTOR_HIGH_CUT={high_cut}") + print( + f"\n# 인간 원고 기준 예상 배지 비율: medium {expected_medium:.1%}, " + f"high {expected_high:.1%}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ai_detector.py b/tests/test_ai_detector.py index 69fb157..818b659 100644 --- a/tests/test_ai_detector.py +++ b/tests/test_ai_detector.py @@ -511,3 +511,147 @@ def test_valid_artifact_roundtrip(tmp_path): 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) + + +# --------------------------------------------------------------------------- +# 백분위 컷 캘리브레이션 (라벨 없이 인간 코퍼스만으로) +# --------------------------------------------------------------------------- + +def test_percentile_matches_known_values(): + values = [0.0, 0.25, 0.5, 0.75, 1.0] + assert ad.percentile(values, 0) == pytest.approx(0.0) + assert ad.percentile(values, 50) == pytest.approx(0.5) + assert ad.percentile(values, 100) == pytest.approx(1.0) + assert ad.percentile([], 50) == 0.0 + assert ad.percentile([0.3], 90) == pytest.approx(0.3) + + +def test_calibrate_cuts_from_human_scores(): + scores = [i / 1000 for i in range(1000)] # 0.000 ~ 0.999 균등 + low, high = ad.calibrate_cuts(scores, 90, 98) + assert low == pytest.approx(0.899, abs=0.01) + assert high == pytest.approx(0.979, abs=0.01) + assert low < high + + +def test_calibrate_cuts_keeps_medium_band_when_distribution_is_flat(): + low, high = ad.calibrate_cuts([0.5] * 200, 90, 98) + assert high > low, "분포가 평평해도 medium 구간이 사라지면 안 된다" + + +def test_calibrate_cuts_rejects_bad_input(): + with pytest.raises(ValueError): + ad.calibrate_cuts([]) + with pytest.raises(ValueError): + ad.calibrate_cuts([0.5], 98, 90) # low >= high + with pytest.raises(ValueError): + ad.calibrate_cuts([0.5], -1, 50) + + +def test_calibrated_cuts_produce_expected_badge_rate(): + """컷의 존재 이유 — 인간 코퍼스에서 high 비율이 설계값으로 고정된다.""" + scores = [i / 1000 for i in range(1000)] + low, high = ad.calibrate_cuts(scores, 90, 98) + high_rate = sum(1 for s in scores if s >= high) / len(scores) + assert high_rate == pytest.approx(0.02, abs=0.005) + + +def test_score_text_heuristic_skips_short_text(): + assert ad.score_text_heuristic("너무 짧다.") is None + value = ad.score_text_heuristic(UNIFORM_TEXT) + assert value is not None and 0.0 <= value <= 1.0 + + +def test_detector_uses_injected_cuts(tmp_path): + det = AiGenerationDetector( + model_path=tmp_path / "none.joblib", allow_heuristic=True, + low_cut=0.10, high_cut=0.20, + ) + assert det.cuts_calibrated is True + assert det._cuts() == (0.10, 0.20) + assert det.model_version == "heuristic-baseline-v1+corpus-percentile" + assert det._level(0.05) == "low" + assert det._level(0.15) == "medium" + assert det._level(0.50) == "high" + + +def test_partial_cuts_are_ignored(tmp_path): + """한쪽만 주면 자리표시자로 되돌아가야 한다 (반쪽 설정 방지).""" + det = AiGenerationDetector( + model_path=tmp_path / "none.joblib", allow_heuristic=True, low_cut=0.1, + ) + assert det.cuts_calibrated is False + assert det._cuts() == (ad.DEFAULT_LOW_CUT, ad.DEFAULT_HIGH_CUT) + assert det.model_version == "heuristic-baseline-v1" + + +def test_uncalibrated_heuristic_note_warns(tmp_path): + det = AiGenerationDetector(model_path=tmp_path / "none.joblib", allow_heuristic=True) + res = det.detect(UNIFORM_TEXT, with_segments=False) + assert res.is_stub is True + assert "캘리브레이션되지 않았습니다" in res.note + + +def test_calibrated_heuristic_note_states_relative_meaning(tmp_path): + det = AiGenerationDetector( + model_path=tmp_path / "none.joblib", allow_heuristic=True, + low_cut=0.3, high_cut=0.6, + ) + res = det.detect(UNIFORM_TEXT, with_segments=False) + assert res.is_stub is True, "캘리브레이션해도 학습 모델은 아니다" + assert "문체 이례도" in res.note + assert "확률" in res.note + + +def test_artifact_cuts_win_over_injected_cuts(): + det = _stub_detector(0.5) + det.low_cut, det.high_cut = 0.01, 0.02 + assert det._cuts() == (0.40, 0.70), "학습 아티팩트가 있으면 그쪽이 우선" + + +def test_calibration_script_refuses_degenerate_corpus(tmp_path): + """포화된 코퍼스에서 'high 배지 0건' 설정을 출력하면 안 된다.""" + import subprocess + import sys + from pathlib import Path + + ref = tmp_path / "ref" + ref.mkdir() + # 완전히 동일한 텍스트 200건 → 점수 전부 동률 = 포화 + for i in range(200): + (ref / f"ref-{i:04d}__같은책.txt").write_text(UNIFORM_TEXT, encoding="utf-8") + + root = Path(__file__).resolve().parents[1] + proc = subprocess.run( + [sys.executable, "scripts/calibrate_ai_detector_cuts.py", + "--database", str(tmp_path / "absent.sqlite3"), + "--reference-dir", str(ref), "--sample", "0", + "--out", str(tmp_path / "cuts.json")], + cwd=root, capture_output=True, text=True, + ) + assert proc.returncode == 3, proc.stderr[-1500:] + assert "AI_DETECTOR_LOW_CUT" not in proc.stdout, "죽은 설정을 출력하면 안 된다" + assert "영원히 0건" in proc.stderr + # 진단 리포트는 남아야 한다 + assert (tmp_path / "cuts.json").exists() + + +def test_calibration_script_fails_on_tiny_sample(tmp_path): + import subprocess + import sys + from pathlib import Path + + ref = tmp_path / "ref" + ref.mkdir() + for i in range(5): + (ref / f"ref-{i}__책.txt").write_text(UNIFORM_TEXT, encoding="utf-8") + + proc = subprocess.run( + [sys.executable, "scripts/calibrate_ai_detector_cuts.py", + "--database", str(tmp_path / "absent.sqlite3"), + "--reference-dir", str(ref), "--sample", "0", + "--out", str(tmp_path / "cuts.json")], + cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, + ) + assert proc.returncode == 2 + assert "불안정" in proc.stderr