Merge remote-tracking branch 'origin/main'

This commit is contained in:
hbyang 2026-08-14 13:13:26 +09:00
commit 8427cf423b
40 changed files with 6500 additions and 75 deletions

View File

@ -3,5 +3,8 @@ __pycache__
.venv .venv
.env .env
.git .git
tests data/input
scripts data/runtime
data/models
*.xlsx
*.joblib

View File

@ -5,17 +5,46 @@ LOG_LEVEL=info
RELOAD=false RELOAD=false
# 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때) # 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때)
ROOT_PATH= 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 ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
REFERENCE_CORPUS_DIR=./data/reference REFERENCE_CORPUS_DIR=./data/reference
TAXONOMY_DIR=./data/taxonomy TAXONOMY_DIR=./data/taxonomy
AUTOBIOGRAPHY_PATTERNS_PATH=./data/autobiography/common_patterns.txt 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 권장 보수적 임계값 (정밀도 우선) # PDF VII-4 권장 보수적 임계값 (정밀도 우선)
SIMILARITY_THRESHOLD=0.85 SIMILARITY_THRESHOLD=0.85
# KoSimCSE / KoSBERT (PDF VII-3 권장 - 한국어 오픈소스 임베딩, 자체 산출물) # KoSimCSE / KoSBERT (PDF VII-3 권장 - 한국어 오픈소스 임베딩, 자체 산출물)
USE_KOSIMCSE=true # CPU 기본 이미지는 torch를 포함하지 않습니다. 레거시 KoSimCSE를 별도 설치한 경우만 true.
USE_KOSIMCSE=false
KOSIMCSE_MODEL=BM-K/KoSimCSE-roberta-multitask KOSIMCSE_MODEL=BM-K/KoSimCSE-roberta-multitask
KOSIMCSE_MAX_LENGTH=512 KOSIMCSE_MAX_LENGTH=512

7
.gitignore vendored
View File

@ -6,6 +6,7 @@
# Python # Python
.venv/ .venv/
.venv-*/
venv/ venv/
__pycache__/ __pycache__/
*.pyc *.pyc
@ -31,6 +32,12 @@ data/training/*.csv
data/reference/autobio-*.txt data/reference/autobio-*.txt
data/reference/corpus-*.txt data/reference/corpus-*.txt
# 운영 원고·인덱스·학습 모델 (서버 볼륨에만 저장)
data/input/
data/runtime/
data/models/
*.joblib
# 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능) # 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능)
# 로그 # 로그

View File

@ -1,10 +1,11 @@
FROM python:3.13-slim FROM python:3.13-slim AS base
WORKDIR /app WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=8000 PORT=8000
@ -18,6 +19,14 @@ RUN pip install -r requirements.txt
COPY app ./app COPY app ./app
COPY data ./data 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"] VOLUME ["/app/data/reference"]

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status
from starlette.concurrency import run_in_threadpool
from app.api.schemas import ( from app.api.schemas import (
BatchCreatedResponse, BatchCreatedResponse,
@ -49,6 +50,10 @@ async def health(request: Request) -> HealthResponse:
corpus_size=det.corpus_size, corpus_size=det.corpus_size,
taxonomy_version=taxonomy_version, taxonomy_version=taxonomy_version,
autobiography_mode=settings.autobiography_mode, 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"], tags=["plagiarism"],
) )
async def detect(req: DetectRequest, request: Request) -> DetectResponse: 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( @router.post(
@ -166,6 +172,16 @@ def _rebuild(request: Request) -> int:
return rebuild_detector(request.app) 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( @router.get(
"/corpus", "/corpus",
response_model=CorpusListResponse, response_model=CorpusListResponse,
@ -173,7 +189,17 @@ def _rebuild(request: Request) -> int:
) )
async def corpus_list(request: Request) -> CorpusListResponse: async def corpus_list(request: Request) -> CorpusListResponse:
settings = get_settings() 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( return CorpusListResponse(
total=len(docs), total=len(docs),
docs=[CorpusItem(**d) for d in 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: async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드.""" """JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
settings = get_settings() 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: try:
doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text) doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text)
except FileExistsError as e: except FileExistsError as e:
@ -196,7 +237,7 @@ async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> Corp
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
new_size = _rebuild(request) new_size = await run_in_threadpool(_rebuild, request)
return CorpusUploadResponse( return CorpusUploadResponse(
doc_id=doc.doc_id, title=doc.title, doc_id=doc.doc_id, title=doc.title,
size_bytes=len(doc.text.encode("utf-8")), size_bytes=len(doc.text.encode("utf-8")),
@ -224,6 +265,21 @@ async def corpus_upload_file(
except UnicodeDecodeError: except UnicodeDecodeError:
raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.") 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: try:
doc = add_document(settings.corpus_path, doc_id, title, text) doc = add_document(settings.corpus_path, doc_id, title, text)
except FileExistsError as e: except FileExistsError as e:
@ -231,7 +287,7 @@ async def corpus_upload_file(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
new_size = _rebuild(request) new_size = await run_in_threadpool(_rebuild, request)
return CorpusUploadResponse( return CorpusUploadResponse(
doc_id=doc.doc_id, title=doc.title, doc_id=doc.doc_id, title=doc.title,
size_bytes=len(doc.text.encode("utf-8")), 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: async def corpus_delete(doc_id: str, request: Request) -> None:
settings = get_settings() 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): if not delete_document(settings.corpus_path, doc_id):
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found") 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: 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, text=item.text,
metadata=item.metadata, metadata=item.metadata,
options=req.options, options=req.options,
include_ai_segments=False,
) )
store.append_result(job_id, result) store.append_result(job_id, result)
store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc)) store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc))

View File

@ -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): class DetectRequest(BaseModel):
doc_id: str doc_id: str
text: str = Field(..., min_length=1) text: str = Field(..., min_length=1)
metadata: DocumentMetadata | None = None metadata: DocumentMetadata | None = None
options: DetectOptions = Field(default_factory=DetectOptions) options: DetectOptions = Field(default_factory=DetectOptions)
legal_context: LegalContext | None = None
class EvidenceSpan(BaseModel): class EvidenceSpan(BaseModel):
start: int start: int
end: int end: int
matched: str matched: str
source_start: int | None = None
source_end: int | None = None
class InfringementTag(BaseModel): class InfringementTag(BaseModel):
@ -107,6 +131,26 @@ class MatchResult(BaseModel):
evidence_spans: list[EvidenceSpan] = Field(default_factory=list) evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
score_breakdown: ScoreBreakdown | None = None score_breakdown: ScoreBreakdown | None = None
partial_signal: PartialPlagiarismSignal | 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): class ExtractedElements(BaseModel):
@ -116,19 +160,80 @@ class ExtractedElements(BaseModel):
keywords: list[str] = Field(default_factory=list) keywords: list[str] = Field(default_factory=list)
class AiGenerationSignal(BaseModel): class LegalRiskSignal(BaseModel):
"""AI 생성 의심도 — 스텁(더미) 응답. """판례 기반 검토 보조. 법률상 침해 확정값이 아님."""
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 계약 확정용. 정식 구현은 워터마킹(내부 생성물) class ScoreSemantics(BaseModel):
+ 한국어 언어특징 분류(외부 유입분) 대체 예정이며, 그때 is_stub=false 된다. """점수와 임계값의 의미를 명시 (#9).
``confidence``/``similarity`` hashing 어휘 점수와 lemma 겹침을 설정
가중치로 섞은 **검색 랭킹 점수**이며, 침해 확률도 법적 판정도 아니다.
임계값 역시 실데이터 캘리브레이션 전이라 provisional 이다.
""" """
suspicion_level: Literal["low", "medium", "high"] = Field( combined_score: float = Field(..., ge=0.0, le=1.0)
..., description="낮음/중간/높음 — UI 배지용" score_kind: str = Field(
default="lexical_lemma_blend",
description="점수 구성. 확률값이 아니며 서로 다른 코퍼스 간 비교 불가.",
) )
score: float = Field(..., ge=0.0, le=1.0, description="0~1 참고 점수") threshold_used: float = Field(..., ge=0.0, le=1.0)
is_stub: bool = Field(default=True, description="True면 더미 응답(미구현)") threshold_source: Literal["request_override", "server_default"]
note: str = "더미 응답 — 실제 AI 생성 판별 결과가 아님" 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): class ReviewSummary(BaseModel):
@ -141,15 +246,15 @@ class ReviewSummary(BaseModel):
유사 문장 0 similar_sentence_count 유사 문장 0 similar_sentence_count
대조 3.5 compared_count (코퍼스 크기) 대조 3.5 compared_count (코퍼스 크기)
표절 의심 구간 없음 has_suspicion(false) 표절 의심 구간 없음 has_suspicion(false)
AI 생성 의심도 낮음 ai_suspicion_level (현재 더미) AI 생성 의심도 ai_suspicion_level (미학습/채점 불가는 unknown)
""" """
originality_percent: int = Field(..., ge=0, le=100, description="독창성 % (100 - 유사도)") originality_percent: int = Field(..., ge=0, le=100, description="독창성 % (100 - 유사도)")
similarity_percent: int = Field(..., ge=0, le=100, description="유사도 %") similarity_percent: int = Field(..., ge=0, le=100, description="유사도 %")
similar_sentence_count: int = Field(..., ge=0, description="유사 문장(매칭) 건수") similar_sentence_count: int = Field(..., ge=0, description="유사 문장(매칭) 건수")
compared_count: int = Field(..., ge=0, description="대조한 원본(코퍼스) 건수") compared_count: int = Field(..., ge=0, description="대조한 원본(코퍼스) 건수")
has_suspicion: bool = Field(..., description="표절 의심 구간 존재 여부") has_suspicion: bool = Field(..., description="표절 의심 구간 존재 여부")
ai_suspicion_level: Literal["low", "medium", "high"] = Field( ai_suspicion_level: Literal["low", "medium", "high", "unknown"] = Field(
..., description="AI 생성 의심도(낮음/중간/높음) — 현재 더미" ..., description="AI 생성 의심도. unknown이면 학습 모델 없음/채점 불가"
) )
@ -162,6 +267,10 @@ class DetectResponse(BaseModel):
ccl_basis: str | None = None ccl_basis: str | None = None
review_summary: ReviewSummary | None = None review_summary: ReviewSummary | None = None
ai_generation: AiGenerationSignal | 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 autobiography_mode: bool = False
candidates_before_filter: int | None = None candidates_before_filter: int | None = None
engine_version: str engine_version: str
@ -221,6 +330,10 @@ class HealthResponse(BaseModel):
corpus_size: int corpus_size: int
taxonomy_version: str | None = None taxonomy_version: str | None = None
autobiography_mode: bool = False 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): class TaxonomyResponse(BaseModel):

View File

@ -14,16 +14,46 @@ class Settings(BaseSettings):
reload: bool = False # 개발용 자동 재시작 reload: bool = False # 개발용 자동 재시작
root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism) 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" engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
reference_corpus_dir: str = "./data/reference" reference_corpus_dir: str = "./data/reference"
taxonomy_dir: str = "./data/taxonomy" taxonomy_dir: str = "./data/taxonomy"
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt" 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 권장: 정밀도 우선 보수적 임계값 # PDF VII-4 권장: 정밀도 우선 보수적 임계값
similarity_threshold: float = 0.85 similarity_threshold: float = 0.85
# 임계값이 실데이터로 캘리브레이션되었는지. false 면 API 응답에
# provisional=true 로 노출된다 (#9). 79권 FP 분포 측정 후 true 로 전환.
similarity_threshold_calibrated: bool = False
# KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩 # KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩
use_kosimcse: bool = True use_kosimcse: bool = False
kosimcse_model: str = "BM-K/KoSimCSE-roberta-multitask" kosimcse_model: str = "BM-K/KoSimCSE-roberta-multitask"
kosimcse_max_length: int = 512 kosimcse_max_length: int = 512
@ -61,6 +91,18 @@ class Settings(BaseSettings):
def taxonomy_path(self) -> Path: def taxonomy_path(self) -> Path:
return Path(self.taxonomy_dir).resolve() 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 @property
def has_openai(self) -> bool: def has_openai(self) -> bool:
return bool(self.openai_api_key.strip()) return bool(self.openai_api_key.strip())

1155
app/engine/ai_detector.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,19 +23,28 @@ from app.api.schemas import (
DetectRequest, DetectRequest,
DetectResponse, DetectResponse,
DocumentMetadata, DocumentMetadata,
ExtractedElements,
InfringementTag, InfringementTag,
InfringementType, InfringementType,
LegalContext,
LegalRiskSignal,
MatchResult, MatchResult,
PartialPlagiarismSignal, PartialPlagiarismSignal,
ReviewSummary, ReviewSummary,
ScoreBreakdown, ScoreBreakdown,
ScoreSemantics,
) )
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.engine.autobiography_filter import preprocess_for_autobiography 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.clustering import ClusterIndex
from app.engine.corpus import load_corpus from app.engine.corpus import load_corpus
from app.engine.extractor import Extractor, get_extractor from app.engine.extractor import Extractor, get_extractor
from app.engine.lsh_filter import LshIndex 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 ( from app.engine.similarity import (
DualSimilarityIndex, DualSimilarityIndex,
SimilarityHit, SimilarityHit,
@ -48,10 +57,44 @@ logger = logging.getLogger(__name__)
class PlagiarismDetector: class PlagiarismDetector:
#: 참조 특징 프로세스 캐시 상한. 초과하면 통째로 비운다(단순 LRU 대용).
_FEATURE_CACHE_MAX = 20_000
def __init__(self, settings: Settings | None = None, extractor: Extractor | None = None): def __init__(self, settings: Settings | None = None, extractor: Extractor | None = None):
self.settings = settings or get_settings() 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._extractor: Extractor = extractor or get_extractor(self.settings)
self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path) 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) self._corpus = load_corpus(self.settings.corpus_path)
@ -106,17 +149,48 @@ class PlagiarismDetector:
@property @property
def corpus_size(self) -> int: 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) 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( def detect(
self, self,
doc_id: str, doc_id: str,
text: str, text: str,
metadata: DocumentMetadata | None = None, metadata: DocumentMetadata | None = None,
options: DetectOptions | None = None, options: DetectOptions | None = None,
include_ai_segments: bool = True,
legal_context: LegalContext | None = None,
) -> DetectResponse: ) -> DetectResponse:
opts = options or DetectOptions() 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 # 요청 단위 자서전 모드 override
autobio_mode = ( autobio_mode = (
@ -136,37 +210,126 @@ class PlagiarismDetector:
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용) # 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
elements = self._extractor.extract(text) 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 candidate_ids: set[str] | None = None
candidates_count: int | None = None if not self._persistent and self._lsh:
lsh_jaccards: dict[str, float] = {}
if self._lsh:
cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k) cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k)
candidate_ids = {c.doc_id for c in cands} candidate_ids = {c.doc_id for c in cands}
candidates_count = len(cands) candidates_count = len(cands)
lsh_jaccards = {c.doc_id: c.jaccard for c in cands} lsh_jaccards = {c.doc_id: c.jaccard for c in cands}
# 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔) # 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔)
hits = self._index.query(query_text, elements, top_k=opts.top_k) if not self._persistent:
if candidate_ids is not None: hits = self._index.query(query_text, elements, top_k=opts.top_k)
hits = [h for h in hits if h.doc_id in candidate_ids] if candidate_ids is not None:
hits = [h for h in hits if h.doc_id in candidate_ids]
# 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준) # 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준)
query_lemmas = extract_lemmas(query_text) if self._cluster else None query_lemmas = extract_lemmas(query_text) if self._cluster else None
matches = [ persistent_by_id = {h.segment_id: h for h in persistent_hits}
self._to_match( 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), h, opts.return_evidence, lsh_jaccards.get(h.doc_id),
self._partial_signal(h.doc_id, elements, query_lemmas), 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) 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 ccl_basis = self._build_ccl_basis(matches) if is_infringement else None
# 저작권 탭 UI 매핑 (나누구 앱). 독창성=100-유사도, AI 의심도는 현재 스텁. top_longest = max((m.longest_span for m in matches), default=0)
ai_signal = _dummy_ai_generation_signal(text) 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) sim_pct = _calibrate_similarity(confidence, threshold)
review = ReviewSummary( review = ReviewSummary(
originality_percent=100 - sim_pct, originality_percent=100 - sim_pct,
@ -186,6 +349,34 @@ class PlagiarismDetector:
ccl_basis=ccl_basis, ccl_basis=ccl_basis,
review_summary=review, review_summary=review,
ai_generation=ai_signal, 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, autobiography_mode=autobio_mode,
candidates_before_filter=candidates_count, candidates_before_filter=candidates_count,
engine_version=self.settings.engine_version, engine_version=self.settings.engine_version,
@ -193,7 +384,134 @@ class PlagiarismDetector:
) )
def detect_request(self, req: DetectRequest) -> DetectResponse: 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: 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 "확인 필요" tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요"
case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else "" case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else ""
return ( return (
f"'{top.source_title}'와 결합 유사도 {top.similarity:.2%}로 매칭. " f"'{top.source_title}'와 검색 유사도 {top.similarity:.2%}로 후보 매칭. "
f"주 침해 태그: {tag_summary}.{case_part}{breakdown}" 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))) 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: def _classify_legacy(hit: SimilarityHit) -> InfringementType:
"""후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용).""" """후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용)."""
elem = hit.element_sim elem = hit.element_sim

133
app/engine/legal_risk.py Normal file
View File

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

View File

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

324
app/engine/provenance.py Normal file
View File

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

View File

@ -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}")

View File

@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import hmac
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles 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") 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings = get_settings() 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.settings = settings
app.state.detector = PlagiarismDetector(settings=settings) app.state.detector = PlagiarismDetector(settings=settings)
app.state.job_store = JobStore() app.state.job_store = JobStore()
@ -55,6 +111,15 @@ app = FastAPI(
app.include_router(api_router) 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" _STATIC_DIR = Path(__file__).resolve().parent / "static"
if _STATIC_DIR.exists(): if _STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")

View File

@ -92,6 +92,24 @@
.scorebar-row .label { color: var(--muted); } .scorebar-row .label { color: var(--muted); }
.scorebar-row .value { text-align: right; font-variant-numeric: tabular-nums; } .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 { .match-card {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
padding: 14px; margin-top: 10px; padding: 14px; margin-top: 10px;
@ -281,6 +299,9 @@
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">점수 분석 (삼중 유사도 결합)</h3> <h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">점수 분석 (삼중 유사도 결합)</h3>
<div id="score-breakdown"></div> <div id="score-breakdown"></div>
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">AI 생성 의심도</h3>
<div id="ai-generation"></div>
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">매칭된 레퍼런스</h3> <h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">매칭된 레퍼런스</h3>
<div id="matches"></div> <div id="matches"></div>
@ -468,18 +489,21 @@ async function runDetect() {
function renderResult(data, originalText) { function renderResult(data, originalText) {
const verdict = document.getElementById("verdict"); 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) { if (data.is_infringement) {
verdict.className = "verdict infringement"; verdict.className = "verdict infringement";
verdict.innerHTML = ` verdict.innerHTML = `
<h3>⚠ 저작권 침해 가능성 확인</h3> <h3>⚠ 저작권 침해 가능성 확인</h3>
<div class="conf-line">결합 유사도</div> <div class="conf-line">보정 유사도</div>
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`; <div class="conf-number">${displaySimilarity}%</div>`;
} else { } else {
verdict.className = "verdict clean"; verdict.className = "verdict clean";
verdict.innerHTML = ` verdict.innerHTML = `
<h3>✓ 침해 신호 없음</h3> <h3>✓ 침해 신호 없음</h3>
<div class="conf-line">최상위 유사도</div> <div class="conf-line">보정 유사도 · 검색 후보 점수는 침해 확률이 아님</div>
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`; <div class="conf-number">${displaySimilarity}%</div>`;
} }
document.getElementById("result-body").style.display = "block"; document.getElementById("result-body").style.display = "block";
@ -497,6 +521,8 @@ function renderResult(data, originalText) {
breakdownEl.innerHTML = '<div style="color: var(--muted); font-size: 12px;">매칭된 레퍼런스가 없어 점수 분석을 표시할 수 없습니다.</div>'; breakdownEl.innerHTML = '<div style="color: var(--muted); font-size: 12px;">매칭된 레퍼런스가 없어 점수 분석을 표시할 수 없습니다.</div>';
} }
renderAiGeneration(data.ai_generation, originalText);
// 매칭 카드 // 매칭 카드
const matchesEl = document.getElementById("matches"); const matchesEl = document.getElementById("matches");
if (!data.matches || data.matches.length === 0) { 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); 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 = `
<div class="ai-card">
<div class="ai-head">
<span class="ai-title">판정 불가 · 모델 준비 전</span>
<span class="type-badge type-unknown">${escapeHtml(version)}</span>
</div>
<div class="ai-note">${escapeHtml(note)}</div>
${renderAiWarnings(ai && ai.warnings)}
</div>`;
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
? '<span class="type-badge type-plot">미검증 휴리스틱</span>'
: `<span class="type-badge type-unknown">${escapeHtml(ai.model_version || "model")}</span>`;
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 `<div class="ai-segment">
<div class="meta">구간 ${start}${end}자 · 의심도 ${(s.score * 100).toFixed(1)}%</div>
${escapeHtml(snippet.length > 160 ? snippet.slice(0, 160) + "…" : snippet)}
</div>`;
}).join("");
el.innerHTML = `
<div class="ai-card ai-${level}">
<div class="ai-head">
<div><span class="ai-title">AI 생성 의심도 ${labels[level]}</span>${stub}</div>
<span class="ai-score">${score}</span>
</div>
<div class="ai-note">검토 우선순위 신호이며 AI 작성 여부를 확정하지 않습니다.${provenance}</div>
${ai.note ? `<div class="ai-note">${escapeHtml(ai.note)}</div>` : ""}
${renderAiWarnings(ai.warnings)}
${segments}
</div>`;
}
function renderAiWarnings(warnings) {
if (!warnings || warnings.length === 0) return "";
return warnings.map(w => `<div class="ai-warning">⚠ ${escapeHtml(w)}</div>`).join("");
}
function renderEvidence(originalText, spans) { function renderEvidence(originalText, spans) {
if (!spans || spans.length === 0) return ""; if (!spans || spans.length === 0) return "";
// span 정렬 후 마킹 // span 정렬 후 마킹

10
data/precedents/README.md Normal file
View File

@ -0,0 +1,10 @@
# 판례 지식베이스
`precedents.jsonl`에는 검증 가능한 공식 출처가 있는 판례만 등록합니다. 현재 포함된
레코드는 엔진과 스키마 검증용 최소 시드이며, 2,000여 건이 적재됐다는 의미가 아닙니다.
필수 필드: `case_id`, `title`, `source_url`, `holding_summary`.
권장 필드: `work_types`, `legal_tags`, `criteria`, `outcome`.
판례 원문/요약의 재배포 가능 범위와 전문가 라벨링 기준을 확인한 뒤 데이터를 늘려야
합니다. 모델이나 LLM이 사건번호를 자유 생성하게 해서는 안 됩니다.

View File

@ -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}

View File

@ -16,6 +16,10 @@ services:
REFERENCE_CORPUS_DIR: /app/data/reference REFERENCE_CORPUS_DIR: /app/data/reference
TAXONOMY_DIR: /app/data/taxonomy TAXONOMY_DIR: /app/data/taxonomy
AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt 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: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped

329
docs/AI_DETECTION.md Normal file
View File

@ -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:<generator>` (또는 `--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 생성 의심도"보다 **"문체 이례도"** 로
표기하고, 검토 우선순위 정렬 용도로만 쓸 것을 권한다.

View File

@ -64,7 +64,7 @@
| 저작권 · 유사도 2% | 2 | `review_summary.similarity_percent` | | 저작권 · 유사도 2% | 2 | `review_summary.similarity_percent` |
| "…3.5만 건과 대조한 결과 표절 의심 구간이 없습니다" | 35000 / false | `review_summary.compared_count`, `review_summary.has_suspicion` | | "…3.5만 건과 대조한 결과 표절 의심 구간이 없습니다" | 35000 / false | `review_summary.compared_count`, `review_summary.has_suspicion` |
| 유사 문장 0건 | 0 | `review_summary.similar_sentence_count` | | 유사 문장 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[]` | | (표절 시) 일치 구간 하이라이트 | start~end | `matches[].evidence_spans[]` |
| (표절 시) 침해 유형 배지 | 복제권 | `matches[].tags[].label_ko` + `case_id` | | (표절 시) 침해 유형 배지 | 복제권 | `matches[].tags[].label_ko` + `case_id` |
@ -114,7 +114,7 @@
"has_suspicion": false, "has_suspicion": false,
"ai_suspicion_level": "low" "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": [], "matches": [],
"extracted_elements": { "characters": [], "motifs": ["비밀의 정원"], "genre": null, "keywords": ["숲","전설"] }, "extracted_elements": { "characters": [], "motifs": ["비밀의 정원"], "genre": null, "keywords": ["숲","전설"] },
"ccl_basis": null, "ccl_basis": null,
@ -130,7 +130,7 @@
| 필드 | 타입 | 의미 | 화면 사용 | | 필드 | 타입 | 의미 | 화면 사용 |
|---|---|---|---| |---|---|---|---|
| `doc_id` | string | 요청의 doc_id 그대로 | 응답 매칭 | | `doc_id` | string | 요청의 doc_id 그대로 | 응답 매칭 |
| `is_infringement` | bool | 표절 판정 여부(임계 초과 매칭 존재). `review_summary.has_suspicion`과 동일 | — | | `is_infringement` | bool | 후방호환 필드. 법적 침해 확정이 아니라 임계 초과 매칭 존재 여부 | — |
| `confidence` | float 0~1 | 최상위 매칭의 **결합 유사도 원값**. ⚠️ 임베딩 성분 때문에 무관한 글도 높게 나옴 → **화면 표시는 `review_summary` 사용, `confidence` 직접 표시 금지** | ✗ | | `confidence` | float 0~1 | 최상위 매칭의 **결합 유사도 원값**. ⚠️ 임베딩 성분 때문에 무관한 글도 높게 나옴 → **화면 표시는 `review_summary` 사용, `confidence` 직접 표시 금지** | ✗ |
| `review_summary` | object | **저작권 탭 UI 요약** (아래 상세) | ★ | | `review_summary` | object | **저작권 탭 UI 요약** (아래 상세) | ★ |
| `ai_generation` | object | AI 생성 의심도 상세 (아래 상세) | 참고 | | `ai_generation` | object | AI 생성 의심도 상세 (아래 상세) | 참고 |
@ -151,7 +151,7 @@
| `similar_sentence_count` | int | **유사 문장 건수** = 임계 초과 매칭 수 | | `similar_sentence_count` | int | **유사 문장 건수** = 임계 초과 매칭 수 |
| `compared_count` | int | **대조한 원본(코퍼스) 건수** | | `compared_count` | int | **대조한 원본(코퍼스) 건수** |
| `has_suspicion` | bool | **표절 의심 구간 존재 여부** | | `has_suspicion` | bool | **표절 의심 구간 존재 여부** |
| `ai_suspicion_level` | `low/medium/high` | **AI 생성 의심도**(낮음/중간/높음). ⚠️ 현재 더미 | | `ai_suspicion_level` | `low/medium/high/unknown` | **AI 생성 의심도**. 미학습/채점 불가는 `unknown` |
**`ai_generation` (AI 생성 의심도 상세)** **`ai_generation` (AI 생성 의심도 상세)**
@ -159,11 +159,14 @@
|---|---|---| |---|---|---|
| `suspicion_level` | `low/medium/high` | 의심도 등급 | | `suspicion_level` | `low/medium/high` | 의심도 등급 |
| `score` | float 0~1 | 참고 점수 | | `score` | float 0~1 | 참고 점수 |
| `is_stub` | bool | **`true`면 더미(미구현)**. 정식 구현 시 `false` | | `available` | bool | 학습 모델 또는 명시적으로 활성화한 baseline으로 채점했는지 |
| `is_stub` | bool | `true`면 미검증 휴리스틱 baseline. 학습 모델은 `false` |
| `model_version` | string | 학습 아티팩트/특징 버전 추적 |
| `note` | string | 안내 문구 | | `note` | string | 안내 문구 |
> ⚠️ **AI 생성 의심도는 현재 스텁**이다. `is_stub=true`이며 요청마다 더미 값을 낸다. > 학습 모델이 없으면 점수를 임의 생성하지 않고 `available=false`, `score=null`,
> UI 배지는 붙여두되 **출판 승인 게이트로 쓰지 말 것**(참고용). > `suspicion_level=unknown`을 반환한다. 학습 후에도 **출판 승인 게이트나 저자 제재의
> 단독 근거로 쓰지 말 것**(사람 검토 우선순위용).
**`matches[]` (표절 의심 시 채워짐)** **`matches[]` (표절 의심 시 채워짐)**

View File

@ -32,9 +32,9 @@
| 표절 의심 구간 **없음** | false | `review_summary.has_suspicion` | | 표절 의심 구간 **없음** | false | `review_summary.has_suspicion` |
| AI 생성 의심도 **낮음** | low | `review_summary.ai_suspicion_level` | | AI 생성 의심도 **낮음** | low | `review_summary.ai_suspicion_level` |
> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음 > `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음,
> **⚠️ AI 생성 의심도는 현재 더미(스텁) 값입니다.** UI 연동 계약 확정용이며, 정식 판별 > `unknown`=미학습 또는 채점 불가. 학습 모델이 없으면 점수를 만들지 않고
> 로직(워터마킹+언어특징 분류) 적용 전까지 `ai_generation.is_stub=true` 로 반환됩니다. > `ai_generation.available=false`, `score=null`을 반환합니다.
> UI는 지금 그대로 붙여두면 되고, 정식 구현 시 값만 실제로 바뀝니다. > UI는 지금 그대로 붙여두면 되고, 정식 구현 시 값만 실제로 바뀝니다.
--- ---
@ -88,7 +88,9 @@
"suspicion_level": "low", "suspicion_level": "low",
"score": 0.06, "score": 0.06,
"is_stub": true, "is_stub": true,
"note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" "available": false,
"model_version": "unavailable",
"note": "학습된 모델이 없어 채점하지 않음"
}, },
"matches": [], "matches": [],
"extracted_elements": { "extracted_elements": {
@ -117,7 +119,7 @@
"has_suspicion": true, "has_suspicion": true,
"ai_suspicion_level": "low" "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": [ "matches": [
{ {
"source_doc": "auto-0003", "source_doc": "auto-0003",
@ -151,10 +153,10 @@
| 필드 | 타입 | 설명 | | 필드 | 타입 | 설명 |
|---|---|---| |---|---|---|
| `is_infringement` | bool | 표절 판정 여부 (= `review_summary.has_suspicion`) | | `is_infringement` | bool | 후방호환용 임계 초과 매칭 여부. 법적 침해 확정 아님 |
| `confidence` | float | 최상위 매칭 결합 유사도 원값(0~1). **화면 표시는 `review_summary` 사용 권장** | | `confidence` | float | 최상위 매칭 결합 유사도 원값(0~1). **화면 표시는 `review_summary` 사용 권장** |
| `review_summary` | object | **저작권 탭 UI 직접 매핑** (§1 표 참조) | | `review_summary` | object | **저작권 탭 UI 직접 매핑** (§1 표 참조) |
| `ai_generation` | object | AI 생성 의심도 상세. `is_stub=true`면 더미 | | `ai_generation` | object | AI 생성 의심도 상세. 미학습이면 `available=false`, 휴리스틱이면 `is_stub=true` |
| `matches[]` | array | 매칭된 원본별 상세 (표절 시). 태그·케이스·근거 구간 포함 | | `matches[]` | array | 매칭된 원본별 상세 (표절 시). 태그·케이스·근거 구간 포함 |
| `matches[].tags[]` | array | 법령 태그. `role`: `primary`(주)/`secondary`(보조), `label_ko` 한글 표기 | | `matches[].tags[]` | array | 법령 태그. `role`: `primary`(주)/`secondary`(보조), `label_ko` 한글 표기 |
| `matches[].case_id` | string | 39종 침해 케이스 ID (예: A6) | | `matches[].case_id` | string | 39종 침해 케이스 ID (예: A6) |
@ -196,7 +198,7 @@ FastAPI 표준. HTTP 상태코드 + `detail`.
## 5. 연동 시 주의 ## 5. 연동 시 주의
1. **AI 생성 의심도는 현재 더미** — `is_stub` 로 판별 후, UI에는 표시하되 "참고용"으로 둘 것. 1. **AI 생성 의심도는 확정값이 아님** — `available`/`is_stub`/`model_version`을 확인하고 사람 검토 우선순위로만 사용할 것.
출판 승인/거절 게이트로 쓰지 말 것(오탐 리스크). 출판 승인/거절 게이트로 쓰지 말 것(오탐 리스크).
2. **화면 값은 `review_summary` 사용**`confidence`(원값)는 임베딩 성분 때문에 무관한 글도 2. **화면 값은 `review_summary` 사용**`confidence`(원값)는 임베딩 성분 때문에 무관한 글도
높게 나오므로 직접 표시 금지. 독창성 환산은 오투오가 `review_summary`에서 완료해 제공. 높게 나오므로 직접 표시 금지. 독창성 환산은 오투오가 `review_summary`에서 완료해 제공.

View File

@ -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=<secret-manager-or-protected-env-value>
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` 일치 ③ 사건번호 순이며, 생성형 인용은 어떤 경로로도 발생하지 않는다.

View File

@ -9,4 +9,8 @@ openai>=1.55
httpx>=0.27 httpx>=0.27
kiwipiepy>=0.18 kiwipiepy>=0.18
datasketch>=1.6 datasketch>=1.6
sentence-transformers>=3.0 openpyxl>=3.1
joblib>=1.4
scipy>=1.13
pypdf>=5.0
python-docx>=1.1

View File

@ -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:<generator>` 기본).
· 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 경로 (기본: <out>.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())

View File

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

View File

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

View File

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

113
scripts/ingest_o2o_xlsx.py Normal file
View File

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

View File

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

View File

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

657
tests/test_ai_detector.py Normal file
View File

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

View File

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

View File

@ -56,3 +56,69 @@ def test_batch_flow():
job_id = resp.json()["job_id"] job_id = resp.json()["job_id"]
status = client.get(f"/v1/plagiarism/batch/{job_id}") status = client.get(f"/v1/plagiarism/batch/{job_id}")
assert status.status_code == 200 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"}

View File

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

View File

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

39
tests/test_legal_risk.py Normal file
View File

@ -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"

View File

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

View File

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

View File

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

24
tests/test_static_ui.py Normal file
View File

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