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..6710943 100644
--- a/.env.example
+++ b/.env.example
@@ -5,17 +5,46 @@ LOG_LEVEL=info
RELOAD=false
# 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때)
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
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
+# 학습 모델이 없을 때 규칙 기반 점수를 낼지. 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 권장 보수적 임계값 (정밀도 우선)
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/.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..28529dc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,11 @@
-FROM python:3.13-slim
+FROM python:3.13-slim AS base
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
+ PYTHONPATH=/app \
HOST=0.0.0.0 \
PORT=8000
@@ -18,6 +19,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..fbc7057 100644
--- a/app/api/schemas.py
+++ b/app/api/schemas.py
@@ -55,17 +55,41 @@ 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):
start: int
end: int
matched: str
+ source_start: int | None = None
+ source_end: int | None = None
class InfringementTag(BaseModel):
@@ -107,6 +131,26 @@ 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,
+ 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):
@@ -116,19 +160,80 @@ 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 가 된다.
+
+class ScoreSemantics(BaseModel):
+ """점수와 임계값의 의미를 명시 (#9).
+
+ ``confidence``/``similarity`` 는 hashing 어휘 점수와 lemma 겹침을 설정
+ 가중치로 섞은 **검색 랭킹 점수**이며, 침해 확률도 법적 판정도 아니다.
+ 임계값 역시 실데이터 캘리브레이션 전이라 provisional 이다.
"""
- suspicion_level: Literal["low", "medium", "high"] = Field(
- ..., description="낮음/중간/높음 — UI 배지용"
+ combined_score: float = Field(..., ge=0.0, le=1.0)
+ score_kind: str = Field(
+ default="lexical_lemma_blend",
+ description="점수 구성. 확률값이 아니며 서로 다른 코퍼스 간 비교 불가.",
)
- score: float = Field(..., ge=0.0, le=1.0, description="0~1 참고 점수")
- is_stub: bool = Field(default=True, description="True면 더미 응답(미구현)")
- note: str = "더미 응답 — 실제 AI 생성 판별 결과가 아님"
+ threshold_used: float = Field(..., ge=0.0, le=1.0)
+ threshold_source: Literal["request_override", "server_default"]
+ threshold_calibrated: bool = Field(
+ default=False, description="실데이터 FP 분포로 캘리브레이션되었는지",
+ )
+ provisional: bool = Field(
+ default=True, description="True면 임계값이 잠정값이라 판정 근거로 쓸 수 없음",
+ )
+ union_coverage: float = Field(
+ default=0.0, ge=0.0, le=1.0,
+ description="정밀 비교 후보 전체의 비중복 일치 구간 / 질의 길이 (#3)",
+ )
+ covered_chars: int = Field(default=0, ge=0)
+ query_chars: int = Field(default=0, ge=0)
+ evidence_truncated: bool = Field(
+ default=False,
+ description="True면 CPU 상한으로 일부 후보는 정밀 비교하지 않음",
+ )
+ note: str = (
+ "검색 랭킹 점수이며 침해 확률이 아닙니다. 임계값은 캘리브레이션 전 잠정값입니다."
+ )
+
+
+class AiSegmentSignal(BaseModel):
+ index: int
+ start: int
+ end: int
+ char_count: int
+ score: float | None = Field(default=None, ge=0.0, le=1.0)
+ suspicion_level: Literal["low", "medium", "high"] | None = None
+ scored: bool
+ note: str = ""
+
+
+class AiGenerationSignal(BaseModel):
+ """한국어 언어특징 기반 검토 우선순위. AI 작성 확정값이 아님."""
+ suspicion_level: Literal["low", "medium", "high", "unknown"] = "unknown"
+ score: float | None = Field(default=None, ge=0.0, le=1.0, description="검토 우선순위 점수")
+ available: bool = False
+ provenance: Literal["human", "ai", "mixed", "edited", "unknown"] = "unknown"
+ is_stub: bool = Field(default=False, description="True면 미검증 휴리스틱 baseline")
+ model_version: str = "unavailable"
+ feature_set_version: str | None = None
+ pos_available: bool = False
+ warnings: list[str] = Field(default_factory=list)
+ segments: list[AiSegmentSignal] = Field(default_factory=list)
+ top_contributions: list[dict] = Field(default_factory=list)
+ note: str = "학습된 모델이 없어 결과를 제공하지 않습니다."
class ReviewSummary(BaseModel):
@@ -141,15 +246,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 +267,10 @@ 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
+ score_semantics: ScoreSemantics | None = None
autobiography_mode: bool = False
candidates_before_filter: int | None = None
engine_version: str
@@ -221,6 +330,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..00fdc11 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -14,16 +14,46 @@ class Settings(BaseSettings):
reload: bool = False # 개발용 자동 재시작
root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism)
+ # --- 인증 (#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"
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
+ # 상위 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"
+ 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
+ # 임계값이 실데이터로 캘리브레이션되었는지. false 면 API 응답에
+ # provisional=true 로 노출된다 (#9). 79권 FP 분포 측정 후 true 로 전환.
+ similarity_threshold_calibrated: bool = False
# 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
@@ -61,6 +91,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..7585672
--- /dev/null
+++ b/app/engine/ai_detector.py
@@ -0,0 +1,1155 @@
+"""한국어 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 기준으로 재산출되어 아티팩트에 저장되며,
+#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 아무 근거 없는 자리표시자이므로,
+#: 휴리스틱 모드로 운영할 때는 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"]
+
+
+# ---------------------------------------------------------------------------
+# 특징 정의 — 순서가 곧 모델 입력 벡터의 순서다. 절대 중간 삽입/삭제 금지.
+# 변경이 필요하면 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 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]] = []
+ 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,
+ 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)
+
+ # -- 모드 --------------------------------------------------------------
+
+ @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
+ 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:
+ 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 and self.cuts_calibrated:
+ note = (
+ "학습 모델이 아니라 언어특징 규칙 점수입니다. 'AI일 확률'이 아니라 "
+ "등록 코퍼스의 인간 저작물 대비 문체 이례도이며, 검토 우선순위 "
+ "정렬용입니다. 판정 근거로 사용할 수 없습니다."
+ )
+ elif self.artifact is None:
+ note = (
+ "⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델도 아니고 컷도 "
+ "캘리브레이션되지 않았습니다(자리표시자). 판정 근거로 사용할 수 "
+ "없습니다. scripts/calibrate_ai_detector_cuts.py 로 컷을 산출하세요."
+ )
+ 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,
+ 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), 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,
+ low_cut=low_cut, high_cut=high_cut,
+ )
+ 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..3d29d38 100644
--- a/app/engine/detector.py
+++ b/app/engine/detector.py
@@ -23,19 +23,28 @@ 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
+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,
@@ -48,10 +57,44 @@ 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))
+ 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,
+ low_cut=self.settings.ai_detector_low_cut,
+ high_cut=self.settings.ai_detector_high_cut,
+ )
+ 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 +149,48 @@ 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,
+ legal_context: LegalContext | None = None,
) -> 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 +210,126 @@ class PlagiarismDetector:
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
elements = self._extractor.extract(text)
- # 1차 LSH 필터 (옵션)
+ # 영속 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)
+ 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 if h.reranked
+ ]
+ self._backfill_segment_features(persistent_hits)
+ hits.sort(key=lambda h: h.score, reverse=True)
+ candidates_count = len(persistent_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:
+ if len(matches) >= opts.top_k:
+ break
+ provenance_hit = persistent_by_id.get(h.doc_id)
+ # 채택 이유를 명시적으로 남긴다 (#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),
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.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)
+ 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_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,
+ # 문서 단위 union coverage 를 쓴다. 세그먼트 최대값을 쓰면 긴 원고에서
+ # 항상 0에 가까워 strong_copy 판정이 성립하지 않았다 (#3).
+ coverage=union_coverage,
+ longest_span=top_longest,
+ legal_tags=legal_tags,
+ 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에서만 실행한다.
+ 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 +349,34 @@ 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,
+ ),
+ 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,
@@ -193,7 +384,134 @@ 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,
+ ) -> SimilarityHit:
+ from app.api.schemas import EvidenceSpan
+ from app.engine.similarity import _element_similarities
+ from app.engine.structural import lemma_overlap_ratio
+
+ reference_lemmas, reference_elements = self.reference_features(hit)
+ element_sim = _element_similarities(query_elements, reference_elements)
+ lemma_sim = lemma_overlap_ratio(query_lemmas, reference_lemmas)
+ 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:
"""군집화 기반 요소별 부분 표절 분해 (옵션)."""
@@ -308,8 +626,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 +649,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..3f0fbe3
--- /dev/null
+++ b/app/engine/persistent_index.py
@@ -0,0 +1,374 @@
+"""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 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
+ 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
+ #: 인덱싱 때 캐시된 참조 특징 (#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):
+ 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], list[tuple[int, int]], int]:
+ """원문 query 좌표의 공통 연속 구간을 반환.
+
+ 반환: (표시용 상위 span, coverage 계산용 전체 구간 [start,end), 최장 일치 길이)
+ 두 번째 값은 union coverage 를 위해 **잘리지 않은 전체 구간**이다. 표시용은
+ limit 개로 줄이지만 coverage 는 전량으로 계산해야 과소평가되지 않는다.
+ """
+ if not query or not reference:
+ 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))
+ 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
+ ]
+ 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)
+ 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,
+ 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 PersistentQueryResult([], 0.0, 0, len(text), evidence_limit or 0, False, {})
+ 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)
+
+ 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:
+ continue
+ segment_id = self._meta["segment_ids"][int(i)]
+ 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, intervals, longest = _evidence_spans(chunk_text, record.text)
+ for span in evidence:
+ span["start"] += chunk_start
+ span["end"] += chunk_start
+ 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))
+ 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, reranked: bool = True) -> 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,
+ reference_lemmas=record.lemmas,
+ reference_elements=record.elements,
+ reranked=reranked,
+ )
diff --git a/app/engine/provenance.py b/app/engine/provenance.py
new file mode 100644
index 0000000..089ec29
--- /dev/null
+++ b/app/engine/provenance.py
@@ -0,0 +1,324 @@
+"""원문 위치를 보존하는 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
+
+
+#: v2 — 참조 lemma/요소 특징 캐시 컬럼 추가 (#5). 기존 v1 DB 는 ALTER TABLE 로
+#: 자동 승격되며, 캐시가 비어 있으면 질의 시 계산 후 백필된다.
+SCHEMA_VERSION = 2
+
+
+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)
+ #: 인덱싱 시점에 계산해 둔 참조 lemma 열 (#5). None 이면 미계산 상태이며,
+ #: 질의 경로가 계산 후 백필한다. 형태소 분석을 요청마다 반복하지 않기 위한 캐시.
+ lemmas: list[str] | None = None
+ #: 인물/모티프 등 최소 요소 특징. ExtractedElements 를 dict 로 직렬화한 형태.
+ elements: dict | None = None
+
+ @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);
+ """
+ )
+ 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])
+
+ 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,lemmas_json,elements_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),
+ 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:
+ 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:
+ 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))
+ 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:
+ result[row["segment_id"]] = self._row_to_segment(row)
+ 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..14e7034 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
@@ -16,9 +19,62 @@ 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
app.state.detector = PlagiarismDetector(settings=settings)
app.state.job_store = JobStore()
@@ -55,6 +111,15 @@ app = FastAPI(
app.include_router(api_router)
+
+@app.middleware("http")
+async def optional_api_key_auth(request: Request, call_next):
+ """API_KEY가 설정된 운영 환경에서만 API 인증을 강제한다."""
+ 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"
if _STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
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/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..01379f6
--- /dev/null
+++ b/docs/AI_DETECTION.md
@@ -0,0 +1,329 @@
+# 한국어 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 직렬화.
+
+---
+
+## 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/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..caec4d1
--- /dev/null
+++ b/docs/IMPLEMENTATION_RUNBOOK.md
@@ -0,0 +1,201 @@
+# 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=
+REQUIRE_API_KEY=true
+```
+
+`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 인증·방화벽·키 회전을 별도 운영 작업으로 완료해야 한다.
+
+## 선택적 KoSimCSE
+
+기본 CPU 이미지는 새 영속 문자 인덱스를 사용하며 `torch`와 `sentence-transformers`를
+포함하지 않는다. 일반 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/requirements.txt b/requirements.txt
index 78534c2..d9cf133 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,4 +9,8 @@ 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
+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..0d54046
--- /dev/null
+++ b/scripts/build_persistent_index.py
@@ -0,0 +1,73 @@
+#!/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
+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:
+ 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)
+ 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
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
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/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..818b659
--- /dev/null
+++ b/tests/test_ai_detector.py
@@ -0,0 +1,657 @@
+"""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)
+
+
+# ---------------------------------------------------------------------------
+# 백분위 컷 캘리브레이션 (라벨 없이 인간 코퍼스만으로)
+# ---------------------------------------------------------------------------
+
+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
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_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_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
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))
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