Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
8427cf423b
@ -3,5 +3,8 @@ __pycache__
|
||||
.venv
|
||||
.env
|
||||
.git
|
||||
tests
|
||||
scripts
|
||||
data/input
|
||||
data/runtime
|
||||
data/models
|
||||
*.xlsx
|
||||
*.joblib
|
||||
|
||||
31
.env.example
31
.env.example
@ -5,17 +5,46 @@ LOG_LEVEL=info
|
||||
RELOAD=false
|
||||
# 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때)
|
||||
ROOT_PATH=
|
||||
# 설정하면 /v1/health를 제외한 API 요청에 X-API-Key가 필요합니다.
|
||||
API_KEY=
|
||||
# 운영 fail-closed. true인데 API_KEY가 비면 앱이 기동에 실패합니다.
|
||||
# ⚠️ King 실제 활성화는 바이칼 측 키 전달 후 진행 (docs/AI_DETECTION.md 참조).
|
||||
REQUIRE_API_KEY=false
|
||||
# 인증 없이 열어둘 경로 제어
|
||||
PUBLIC_HEALTH=true
|
||||
PUBLIC_DOCS=true
|
||||
|
||||
ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
|
||||
REFERENCE_CORPUS_DIR=./data/reference
|
||||
TAXONOMY_DIR=./data/taxonomy
|
||||
AUTOBIOGRAPHY_PATTERNS_PATH=./data/autobiography/common_patterns.txt
|
||||
|
||||
# 3.5만 에피소드용 SQLite provenance + 증분 영속 인덱스.
|
||||
# 서버에서 ingest/build 완료 후 true로 전환합니다.
|
||||
USE_PERSISTENT_INDEX=false
|
||||
CORPUS_DB_PATH=./data/runtime/corpus.sqlite3
|
||||
PERSISTENT_INDEX_DIR=./data/runtime/index
|
||||
PERSISTENT_SIMILARITY_THRESHOLD=0.65
|
||||
PERSISTENT_MIN_EXACT_SPAN=80
|
||||
PERSISTENT_MIN_COVERAGE=0.30
|
||||
PERSISTENT_RERANK_TOP_K=20
|
||||
PRECEDENTS_PATH=./data/precedents/precedents.jsonl
|
||||
AI_DETECTOR_MODEL_PATH=./data/models/ai_detector.joblib
|
||||
# 학습 모델이 없을 때 규칙 기반 점수를 낼지. true 면 is_stub=true 로 노출된다.
|
||||
AI_DETECTOR_ALLOW_HEURISTIC=false
|
||||
# 휴리스틱 컷. scripts/calibrate_ai_detector_cuts.py 출력값을 넣는다.
|
||||
# 비워두면 근거 없는 자리표시자(0.40/0.70)가 쓰이므로 배지가 무의미해진다.
|
||||
# 값이 없으면 아래 두 줄은 주석 처리한 채로 둘 것 (빈 값은 파싱 오류).
|
||||
#AI_DETECTOR_LOW_CUT=
|
||||
#AI_DETECTOR_HIGH_CUT=
|
||||
AI_DETECTOR_USE_POS=true
|
||||
|
||||
# PDF VII-4 권장 보수적 임계값 (정밀도 우선)
|
||||
SIMILARITY_THRESHOLD=0.85
|
||||
|
||||
# KoSimCSE / KoSBERT (PDF VII-3 권장 - 한국어 오픈소스 임베딩, 자체 산출물)
|
||||
USE_KOSIMCSE=true
|
||||
# CPU 기본 이미지는 torch를 포함하지 않습니다. 레거시 KoSimCSE를 별도 설치한 경우만 true.
|
||||
USE_KOSIMCSE=false
|
||||
KOSIMCSE_MODEL=BM-K/KoSimCSE-roberta-multitask
|
||||
KOSIMCSE_MAX_LENGTH=512
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -6,6 +6,7 @@
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
.venv-*/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@ -31,6 +32,12 @@ data/training/*.csv
|
||||
data/reference/autobio-*.txt
|
||||
data/reference/corpus-*.txt
|
||||
|
||||
# 운영 원고·인덱스·학습 모델 (서버 볼륨에만 저장)
|
||||
data/input/
|
||||
data/runtime/
|
||||
data/models/
|
||||
*.joblib
|
||||
|
||||
# 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능)
|
||||
|
||||
# 로그
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@ -1,10 +1,11 @@
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8000
|
||||
|
||||
@ -18,6 +19,14 @@ RUN pip install -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY data ./data
|
||||
COPY scripts ./scripts
|
||||
|
||||
FROM base AS test
|
||||
RUN pip install "pytest>=8.0"
|
||||
COPY tests ./tests
|
||||
CMD ["pytest", "tests", "-q"]
|
||||
|
||||
FROM base AS runtime
|
||||
|
||||
# 코퍼스 업로드 디렉토리는 볼륨 마운트 권장
|
||||
VOLUME ["/app/data/reference"]
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.schemas import (
|
||||
BatchCreatedResponse,
|
||||
@ -49,6 +50,10 @@ async def health(request: Request) -> HealthResponse:
|
||||
corpus_size=det.corpus_size,
|
||||
taxonomy_version=taxonomy_version,
|
||||
autobiography_mode=settings.autobiography_mode,
|
||||
corpus_documents=det.corpus_document_count,
|
||||
index_backend=det.index_backend,
|
||||
ai_model_ready=getattr(det, "ai_model_ready", False),
|
||||
precedent_count=det.precedent_count,
|
||||
)
|
||||
|
||||
|
||||
@ -83,7 +88,8 @@ async def taxonomy(request: Request) -> TaxonomyResponse:
|
||||
tags=["plagiarism"],
|
||||
)
|
||||
async def detect(req: DetectRequest, request: Request) -> DetectResponse:
|
||||
return _detector(request).detect_request(req)
|
||||
# 형태소/임베딩/행렬 연산으로 event loop가 막히지 않도록 worker thread에서 실행.
|
||||
return await run_in_threadpool(_detector(request).detect_request, req)
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -166,6 +172,16 @@ def _rebuild(request: Request) -> int:
|
||||
return rebuild_detector(request.app)
|
||||
|
||||
|
||||
def _persistent_add_locked(request: Request, detector, doc_id, title, text) -> str:
|
||||
with request.app.state.detector_lock:
|
||||
return detector.add_persistent_document(doc_id, title, text)
|
||||
|
||||
|
||||
def _persistent_delete_locked(request: Request, detector, doc_id: str) -> bool:
|
||||
with request.app.state.detector_lock:
|
||||
return detector.delete_persistent_document(doc_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/corpus",
|
||||
response_model=CorpusListResponse,
|
||||
@ -173,6 +189,16 @@ def _rebuild(request: Request) -> int:
|
||||
)
|
||||
async def corpus_list(request: Request) -> CorpusListResponse:
|
||||
settings = get_settings()
|
||||
detector = _detector(request)
|
||||
if detector.uses_persistent_index:
|
||||
docs = [
|
||||
{
|
||||
"doc_id": d["document_id"], "title": d["title"],
|
||||
"size_bytes": d["characters"], "filename": d["source_path"],
|
||||
}
|
||||
for d in detector.list_persistent_documents()
|
||||
]
|
||||
else:
|
||||
docs = list_documents(settings.corpus_path)
|
||||
return CorpusListResponse(
|
||||
total=len(docs),
|
||||
@ -189,6 +215,21 @@ async def corpus_list(request: Request) -> CorpusListResponse:
|
||||
async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
|
||||
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
|
||||
settings = get_settings()
|
||||
detector = _detector(request)
|
||||
if detector.uses_persistent_index:
|
||||
try:
|
||||
document_id = await run_in_threadpool(
|
||||
_persistent_add_locked, request, detector, req.doc_id, req.title, req.text
|
||||
)
|
||||
except FileExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return CorpusUploadResponse(
|
||||
doc_id=document_id, title=req.title,
|
||||
size_bytes=len(req.text.encode("utf-8")),
|
||||
corpus_size_after=detector.corpus_size, rebuilt=False,
|
||||
)
|
||||
try:
|
||||
doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text)
|
||||
except FileExistsError as e:
|
||||
@ -196,7 +237,7 @@ async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> Corp
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
new_size = _rebuild(request)
|
||||
new_size = await run_in_threadpool(_rebuild, request)
|
||||
return CorpusUploadResponse(
|
||||
doc_id=doc.doc_id, title=doc.title,
|
||||
size_bytes=len(doc.text.encode("utf-8")),
|
||||
@ -224,6 +265,21 @@ async def corpus_upload_file(
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.")
|
||||
|
||||
detector = _detector(request)
|
||||
if detector.uses_persistent_index:
|
||||
try:
|
||||
document_id = await run_in_threadpool(
|
||||
_persistent_add_locked, request, detector, doc_id, title, text
|
||||
)
|
||||
except FileExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return CorpusUploadResponse(
|
||||
doc_id=document_id, title=title, size_bytes=len(raw),
|
||||
corpus_size_after=detector.corpus_size, rebuilt=False,
|
||||
)
|
||||
|
||||
try:
|
||||
doc = add_document(settings.corpus_path, doc_id, title, text)
|
||||
except FileExistsError as e:
|
||||
@ -231,7 +287,7 @@ async def corpus_upload_file(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
new_size = _rebuild(request)
|
||||
new_size = await run_in_threadpool(_rebuild, request)
|
||||
return CorpusUploadResponse(
|
||||
doc_id=doc.doc_id, title=doc.title,
|
||||
size_bytes=len(doc.text.encode("utf-8")),
|
||||
@ -246,9 +302,17 @@ async def corpus_upload_file(
|
||||
)
|
||||
async def corpus_delete(doc_id: str, request: Request) -> None:
|
||||
settings = get_settings()
|
||||
detector = _detector(request)
|
||||
if detector.uses_persistent_index:
|
||||
deleted = await run_in_threadpool(
|
||||
_persistent_delete_locked, request, detector, doc_id
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
|
||||
return
|
||||
if not delete_document(settings.corpus_path, doc_id):
|
||||
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
|
||||
_rebuild(request)
|
||||
await run_in_threadpool(_rebuild, request)
|
||||
|
||||
|
||||
def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: BatchRequest) -> None:
|
||||
@ -260,6 +324,7 @@ def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req:
|
||||
text=item.text,
|
||||
metadata=item.metadata,
|
||||
options=req.options,
|
||||
include_ai_segments=False,
|
||||
)
|
||||
store.append_result(job_id, result)
|
||||
store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc))
|
||||
|
||||
@ -55,17 +55,41 @@ class DetectOptions(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class LegalContext(BaseModel):
|
||||
"""사람이 확인한 법적 사실. 제공하지 않으면 missing_factors 로 남는다 (#10).
|
||||
|
||||
엔진은 이 값들을 **추론하지 않는다.** 텍스트만으로는 알 수 없는 사실이므로,
|
||||
검토자가 확인한 경우에만 전달받아 판례 매칭과 위험도 판단에 반영한다.
|
||||
"""
|
||||
work_type: str = Field(
|
||||
default="literary", description="저작물 유형 (literary/musical/visual 등)"
|
||||
)
|
||||
access_evidence: bool | None = Field(
|
||||
default=None,
|
||||
description="원저작물 접근·의거 가능성이 확인되었는지. None이면 미제공.",
|
||||
)
|
||||
protected_expression_reviewed: bool = Field(
|
||||
default=False, description="보호되는 창작적 표현인지 사람이 검토했는지",
|
||||
)
|
||||
rights_verified: bool = Field(
|
||||
default=False, description="저작권 귀속·이용허락·인용 요건이 확인되었는지",
|
||||
)
|
||||
|
||||
|
||||
class DetectRequest(BaseModel):
|
||||
doc_id: str
|
||||
text: str = Field(..., min_length=1)
|
||||
metadata: DocumentMetadata | None = None
|
||||
options: DetectOptions = Field(default_factory=DetectOptions)
|
||||
legal_context: LegalContext | None = None
|
||||
|
||||
|
||||
class EvidenceSpan(BaseModel):
|
||||
start: int
|
||||
end: int
|
||||
matched: str
|
||||
source_start: int | None = None
|
||||
source_end: int | None = None
|
||||
|
||||
|
||||
class InfringementTag(BaseModel):
|
||||
@ -107,6 +131,26 @@ class MatchResult(BaseModel):
|
||||
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
|
||||
score_breakdown: ScoreBreakdown | None = None
|
||||
partial_signal: PartialPlagiarismSignal | None = None
|
||||
source_document_id: str | None = None
|
||||
source_segment_id: str | None = None
|
||||
source_locator: str | None = None
|
||||
coordinate_scope: Literal["document", "page", "paragraph", "episode", "unknown"] = "unknown"
|
||||
page_number: int | None = None
|
||||
paragraph_number: int | None = None
|
||||
source_char_start: int | None = None
|
||||
source_char_end: int | None = None
|
||||
matched_coverage: float = Field(
|
||||
default=0.0, ge=0.0, le=1.0,
|
||||
description="이 세그먼트 일치 구간이 질의 전체 길이에서 차지하는 비율",
|
||||
)
|
||||
longest_span: int = Field(default=0, ge=0)
|
||||
match_reasons: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"이 후보가 채택된 이유 (#9). score_threshold=결합점수가 임계 초과, "
|
||||
"exact_span=연속 일치 길이 조건 충족, coverage=커버리지 조건 충족."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExtractedElements(BaseModel):
|
||||
@ -116,19 +160,80 @@ class ExtractedElements(BaseModel):
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiGenerationSignal(BaseModel):
|
||||
"""AI 생성 의심도 — 스텁(더미) 응답.
|
||||
class LegalRiskSignal(BaseModel):
|
||||
"""판례 기반 검토 보조. 법률상 침해 확정값이 아님."""
|
||||
status: Literal[
|
||||
"review_required", "no_registered_corpus_match", "insufficient_precedent_data"
|
||||
]
|
||||
risk_level: Literal["low", "medium", "high"] | None = None
|
||||
similarity_evidence: str
|
||||
protected_expression: Literal["reviewed", "not_reviewed"]
|
||||
access_evidence: Literal["provided", "not_found", "not_provided"]
|
||||
missing_factors: list[str] = Field(default_factory=list)
|
||||
precedent_ids: list[str] = Field(default_factory=list)
|
||||
disclaimer: str
|
||||
|
||||
⚠️ 현재는 실제 판별 로직이 아니라 요청마다 더미 값을 반환한다. 바이칼 UI 연동
|
||||
(낮음/중간/높음 배지)을 위한 API 계약 확정용. 정식 구현은 워터마킹(내부 생성물)
|
||||
+ 한국어 언어특징 분류(외부 유입분)로 대체 예정이며, 그때 is_stub=false 가 된다.
|
||||
|
||||
class ScoreSemantics(BaseModel):
|
||||
"""점수와 임계값의 의미를 명시 (#9).
|
||||
|
||||
``confidence``/``similarity`` 는 hashing 어휘 점수와 lemma 겹침을 설정
|
||||
가중치로 섞은 **검색 랭킹 점수**이며, 침해 확률도 법적 판정도 아니다.
|
||||
임계값 역시 실데이터 캘리브레이션 전이라 provisional 이다.
|
||||
"""
|
||||
suspicion_level: Literal["low", "medium", "high"] = Field(
|
||||
..., description="낮음/중간/높음 — UI 배지용"
|
||||
combined_score: float = Field(..., ge=0.0, le=1.0)
|
||||
score_kind: str = Field(
|
||||
default="lexical_lemma_blend",
|
||||
description="점수 구성. 확률값이 아니며 서로 다른 코퍼스 간 비교 불가.",
|
||||
)
|
||||
score: float = Field(..., ge=0.0, le=1.0, description="0~1 참고 점수")
|
||||
is_stub: bool = Field(default=True, description="True면 더미 응답(미구현)")
|
||||
note: str = "더미 응답 — 실제 AI 생성 판별 결과가 아님"
|
||||
threshold_used: float = Field(..., ge=0.0, le=1.0)
|
||||
threshold_source: Literal["request_override", "server_default"]
|
||||
threshold_calibrated: bool = Field(
|
||||
default=False, description="실데이터 FP 분포로 캘리브레이션되었는지",
|
||||
)
|
||||
provisional: bool = Field(
|
||||
default=True, description="True면 임계값이 잠정값이라 판정 근거로 쓸 수 없음",
|
||||
)
|
||||
union_coverage: float = Field(
|
||||
default=0.0, ge=0.0, le=1.0,
|
||||
description="정밀 비교 후보 전체의 비중복 일치 구간 / 질의 길이 (#3)",
|
||||
)
|
||||
covered_chars: int = Field(default=0, ge=0)
|
||||
query_chars: int = Field(default=0, ge=0)
|
||||
evidence_truncated: bool = Field(
|
||||
default=False,
|
||||
description="True면 CPU 상한으로 일부 후보는 정밀 비교하지 않음",
|
||||
)
|
||||
note: str = (
|
||||
"검색 랭킹 점수이며 침해 확률이 아닙니다. 임계값은 캘리브레이션 전 잠정값입니다."
|
||||
)
|
||||
|
||||
|
||||
class AiSegmentSignal(BaseModel):
|
||||
index: int
|
||||
start: int
|
||||
end: int
|
||||
char_count: int
|
||||
score: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
suspicion_level: Literal["low", "medium", "high"] | None = None
|
||||
scored: bool
|
||||
note: str = ""
|
||||
|
||||
|
||||
class AiGenerationSignal(BaseModel):
|
||||
"""한국어 언어특징 기반 검토 우선순위. AI 작성 확정값이 아님."""
|
||||
suspicion_level: Literal["low", "medium", "high", "unknown"] = "unknown"
|
||||
score: float | None = Field(default=None, ge=0.0, le=1.0, description="검토 우선순위 점수")
|
||||
available: bool = False
|
||||
provenance: Literal["human", "ai", "mixed", "edited", "unknown"] = "unknown"
|
||||
is_stub: bool = Field(default=False, description="True면 미검증 휴리스틱 baseline")
|
||||
model_version: str = "unavailable"
|
||||
feature_set_version: str | None = None
|
||||
pos_available: bool = False
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
segments: list[AiSegmentSignal] = Field(default_factory=list)
|
||||
top_contributions: list[dict] = Field(default_factory=list)
|
||||
note: str = "학습된 모델이 없어 결과를 제공하지 않습니다."
|
||||
|
||||
|
||||
class ReviewSummary(BaseModel):
|
||||
@ -141,15 +246,15 @@ class ReviewSummary(BaseModel):
|
||||
유사 문장 0건 ↔ similar_sentence_count
|
||||
대조 3.5만 건 ↔ compared_count (코퍼스 크기)
|
||||
표절 의심 구간 없음 ↔ has_suspicion(false)
|
||||
AI 생성 의심도 낮음 ↔ ai_suspicion_level (현재 더미)
|
||||
AI 생성 의심도 ↔ ai_suspicion_level (미학습/채점 불가는 unknown)
|
||||
"""
|
||||
originality_percent: int = Field(..., ge=0, le=100, description="독창성 % (100 - 유사도)")
|
||||
similarity_percent: int = Field(..., ge=0, le=100, description="유사도 %")
|
||||
similar_sentence_count: int = Field(..., ge=0, description="유사 문장(매칭) 건수")
|
||||
compared_count: int = Field(..., ge=0, description="대조한 원본(코퍼스) 건수")
|
||||
has_suspicion: bool = Field(..., description="표절 의심 구간 존재 여부")
|
||||
ai_suspicion_level: Literal["low", "medium", "high"] = Field(
|
||||
..., description="AI 생성 의심도(낮음/중간/높음) — 현재 더미"
|
||||
ai_suspicion_level: Literal["low", "medium", "high", "unknown"] = Field(
|
||||
..., description="AI 생성 의심도. unknown이면 학습 모델 없음/채점 불가"
|
||||
)
|
||||
|
||||
|
||||
@ -162,6 +267,10 @@ class DetectResponse(BaseModel):
|
||||
ccl_basis: str | None = None
|
||||
review_summary: ReviewSummary | None = None
|
||||
ai_generation: AiGenerationSignal | None = None
|
||||
has_similarity_match: bool | None = None
|
||||
corpus_scope_note: str | None = None
|
||||
legal_risk: LegalRiskSignal | None = None
|
||||
score_semantics: ScoreSemantics | None = None
|
||||
autobiography_mode: bool = False
|
||||
candidates_before_filter: int | None = None
|
||||
engine_version: str
|
||||
@ -221,6 +330,10 @@ class HealthResponse(BaseModel):
|
||||
corpus_size: int
|
||||
taxonomy_version: str | None = None
|
||||
autobiography_mode: bool = False
|
||||
corpus_documents: int | None = None
|
||||
index_backend: str | None = None
|
||||
ai_model_ready: bool | None = None
|
||||
precedent_count: int | None = None
|
||||
|
||||
|
||||
class TaxonomyResponse(BaseModel):
|
||||
|
||||
@ -14,16 +14,46 @@ class Settings(BaseSettings):
|
||||
reload: bool = False # 개발용 자동 재시작
|
||||
root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism)
|
||||
|
||||
# --- 인증 (#1) ---
|
||||
# api_key 만으로는 "빈 값 = 무인증"이 조용히 성립한다. 운영에서는
|
||||
# require_api_key=true 로 명시적 fail-closed 를 걸어, 키가 없으면 앱이
|
||||
# 아예 뜨지 않게 한다.
|
||||
api_key: str = "" # 설정 시 X-API-Key 필수
|
||||
require_api_key: bool = False # true 인데 api_key 가 비면 기동 실패
|
||||
public_health: bool = True # /v1/health 를 인증 없이 공개할지
|
||||
public_docs: bool = True # /docs, /openapi.json, /redoc 공개 여부
|
||||
|
||||
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
|
||||
reference_corpus_dir: str = "./data/reference"
|
||||
taxonomy_dir: str = "./data/taxonomy"
|
||||
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt"
|
||||
corpus_db_path: str = "./data/runtime/corpus.sqlite3"
|
||||
persistent_index_dir: str = "./data/runtime/index"
|
||||
use_persistent_index: bool = False
|
||||
persistent_similarity_threshold: float = 0.65
|
||||
persistent_min_exact_span: int = 80
|
||||
persistent_min_coverage: float = 0.30
|
||||
# 상위 N개 후보만 SequenceMatcher 정밀 비교 + union coverage 에 참여시킨다.
|
||||
# 이 값이 곧 요청당 O(질의길이 × 세그먼트길이) 연산의 상한이다 (#3/#5).
|
||||
persistent_rerank_top_k: int = 20
|
||||
precedents_path: str = "./data/precedents/precedents.jsonl"
|
||||
ai_detector_model_path: str = "./data/models/ai_detector.joblib"
|
||||
ai_detector_allow_heuristic: bool = False
|
||||
ai_detector_use_pos: bool = True
|
||||
# 휴리스틱 모드 의심도 구간. scripts/calibrate_ai_detector_cuts.py 가 등록
|
||||
# 코퍼스(전부 인간 저작)의 점수 분포에서 백분위로 산출한다. 둘 다 설정해야
|
||||
# 적용되며, 없으면 근거 없는 자리표시자(0.40/0.70)가 쓰인다.
|
||||
ai_detector_low_cut: float | None = None
|
||||
ai_detector_high_cut: float | None = None
|
||||
|
||||
# PDF VII-4 권장: 정밀도 우선 보수적 임계값
|
||||
similarity_threshold: float = 0.85
|
||||
# 임계값이 실데이터로 캘리브레이션되었는지. false 면 API 응답에
|
||||
# provisional=true 로 노출된다 (#9). 79권 FP 분포 측정 후 true 로 전환.
|
||||
similarity_threshold_calibrated: bool = False
|
||||
|
||||
# KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩
|
||||
use_kosimcse: bool = True
|
||||
use_kosimcse: bool = False
|
||||
kosimcse_model: str = "BM-K/KoSimCSE-roberta-multitask"
|
||||
kosimcse_max_length: int = 512
|
||||
|
||||
@ -61,6 +91,18 @@ class Settings(BaseSettings):
|
||||
def taxonomy_path(self) -> Path:
|
||||
return Path(self.taxonomy_dir).resolve()
|
||||
|
||||
@property
|
||||
def corpus_db(self) -> Path:
|
||||
return Path(self.corpus_db_path).resolve()
|
||||
|
||||
@property
|
||||
def persistent_index_path(self) -> Path:
|
||||
return Path(self.persistent_index_dir).resolve()
|
||||
|
||||
@property
|
||||
def precedent_path(self) -> Path:
|
||||
return Path(self.precedents_path).resolve()
|
||||
|
||||
@property
|
||||
def has_openai(self) -> bool:
|
||||
return bool(self.openai_api_key.strip())
|
||||
|
||||
1155
app/engine/ai_detector.py
Normal file
1155
app/engine/ai_detector.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -23,19 +23,28 @@ from app.api.schemas import (
|
||||
DetectRequest,
|
||||
DetectResponse,
|
||||
DocumentMetadata,
|
||||
ExtractedElements,
|
||||
InfringementTag,
|
||||
InfringementType,
|
||||
LegalContext,
|
||||
LegalRiskSignal,
|
||||
MatchResult,
|
||||
PartialPlagiarismSignal,
|
||||
ReviewSummary,
|
||||
ScoreBreakdown,
|
||||
ScoreSemantics,
|
||||
)
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.engine.autobiography_filter import preprocess_for_autobiography
|
||||
from app.engine.ai_detector import get_ai_detector
|
||||
from app.engine.clustering import ClusterIndex
|
||||
from app.engine.corpus import load_corpus
|
||||
from app.engine.extractor import Extractor, get_extractor
|
||||
from app.engine.lsh_filter import LshIndex
|
||||
from app.engine.legal_risk import LegalRiskEngine, load_precedents
|
||||
from app.engine.persistent_index import PersistentCorpusIndex, PersistentHit
|
||||
from app.engine.provenance import DocumentRecord, SegmentRecord, stable_id
|
||||
from app.engine.source_extraction import chunk_with_offsets
|
||||
from app.engine.similarity import (
|
||||
DualSimilarityIndex,
|
||||
SimilarityHit,
|
||||
@ -48,10 +57,44 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlagiarismDetector:
|
||||
#: 참조 특징 프로세스 캐시 상한. 초과하면 통째로 비운다(단순 LRU 대용).
|
||||
_FEATURE_CACHE_MAX = 20_000
|
||||
|
||||
def __init__(self, settings: Settings | None = None, extractor: Extractor | None = None):
|
||||
self.settings = settings or get_settings()
|
||||
self._feature_cache: dict[str, tuple[list[str], "ExtractedElements"]] = {}
|
||||
self._extractor: Extractor = extractor or get_extractor(self.settings)
|
||||
self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path)
|
||||
self._legal_engine = LegalRiskEngine(load_precedents(self.settings.precedent_path))
|
||||
self._ai_detector = get_ai_detector(
|
||||
self.settings.ai_detector_model_path,
|
||||
allow_heuristic=self.settings.ai_detector_allow_heuristic,
|
||||
use_pos=self.settings.ai_detector_use_pos,
|
||||
low_cut=self.settings.ai_detector_low_cut,
|
||||
high_cut=self.settings.ai_detector_high_cut,
|
||||
)
|
||||
self._persistent: PersistentCorpusIndex | None = None
|
||||
|
||||
if self.settings.use_persistent_index:
|
||||
candidate = PersistentCorpusIndex(
|
||||
self.settings.corpus_db, self.settings.persistent_index_path,
|
||||
)
|
||||
if candidate.ready:
|
||||
self._persistent = candidate.load()
|
||||
self._corpus = []
|
||||
self._corpus_preprocessed_texts = []
|
||||
self._corpus_elements = []
|
||||
self._corpus_lemmas = []
|
||||
self._index = None
|
||||
self._lsh = None
|
||||
self._cluster = None
|
||||
self._docs_by_id = {}
|
||||
logger.info("Loaded persistent index: %d segments", self._persistent.size)
|
||||
return
|
||||
logger.warning(
|
||||
"USE_PERSISTENT_INDEX=true but index is absent; falling back to text corpus: %s",
|
||||
self.settings.persistent_index_path,
|
||||
)
|
||||
|
||||
self._corpus = load_corpus(self.settings.corpus_path)
|
||||
|
||||
@ -106,17 +149,48 @@ class PlagiarismDetector:
|
||||
|
||||
@property
|
||||
def corpus_size(self) -> int:
|
||||
return self._persistent.size if self._persistent else len(self._corpus)
|
||||
|
||||
@property
|
||||
def corpus_document_count(self) -> int:
|
||||
if self._persistent:
|
||||
return self._persistent.document_count
|
||||
return len(self._corpus)
|
||||
|
||||
@property
|
||||
def index_backend(self) -> str:
|
||||
return "persistent-hashing-char-3-4" if self._persistent else "legacy-in-memory"
|
||||
|
||||
@property
|
||||
def precedent_count(self) -> int:
|
||||
return len(self._legal_engine.precedents)
|
||||
|
||||
@property
|
||||
def ai_model_ready(self) -> bool:
|
||||
return self._ai_detector.mode == "trained"
|
||||
|
||||
@property
|
||||
def uses_persistent_index(self) -> bool:
|
||||
return self._persistent is not None
|
||||
|
||||
def list_persistent_documents(self) -> list[dict]:
|
||||
return self._persistent.store.list_documents() if self._persistent else []
|
||||
|
||||
def detect(
|
||||
self,
|
||||
doc_id: str,
|
||||
text: str,
|
||||
metadata: DocumentMetadata | None = None,
|
||||
options: DetectOptions | None = None,
|
||||
include_ai_segments: bool = True,
|
||||
legal_context: LegalContext | None = None,
|
||||
) -> DetectResponse:
|
||||
opts = options or DetectOptions()
|
||||
threshold = opts.threshold if opts.threshold is not None else self.settings.similarity_threshold
|
||||
default_threshold = (
|
||||
self.settings.persistent_similarity_threshold
|
||||
if self._persistent else self.settings.similarity_threshold
|
||||
)
|
||||
threshold = opts.threshold if opts.threshold is not None else default_threshold
|
||||
|
||||
# 요청 단위 자서전 모드 override
|
||||
autobio_mode = (
|
||||
@ -136,17 +210,48 @@ class PlagiarismDetector:
|
||||
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
|
||||
elements = self._extractor.extract(text)
|
||||
|
||||
# 1차 LSH 필터 (옵션)
|
||||
candidate_ids: set[str] | None = None
|
||||
candidates_count: int | None = None
|
||||
# 영속 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] = {}
|
||||
if self._lsh:
|
||||
else:
|
||||
hits = []
|
||||
candidates_count = None
|
||||
lsh_jaccards = {}
|
||||
|
||||
# 1차 LSH 필터 (레거시 옵션)
|
||||
candidate_ids: set[str] | None = None
|
||||
if not self._persistent and self._lsh:
|
||||
cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k)
|
||||
candidate_ids = {c.doc_id for c in cands}
|
||||
candidates_count = len(cands)
|
||||
lsh_jaccards = {c.doc_id: c.jaccard for c in cands}
|
||||
|
||||
# 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔)
|
||||
if not self._persistent:
|
||||
hits = self._index.query(query_text, elements, top_k=opts.top_k)
|
||||
if candidate_ids is not None:
|
||||
hits = [h for h in hits if h.doc_id in candidate_ids]
|
||||
@ -154,19 +259,77 @@ class PlagiarismDetector:
|
||||
# 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준)
|
||||
query_lemmas = extract_lemmas(query_text) if self._cluster else None
|
||||
|
||||
matches = [
|
||||
self._to_match(
|
||||
persistent_by_id = {h.segment_id: h for h in persistent_hits}
|
||||
matches = []
|
||||
for h in hits:
|
||||
if len(matches) >= opts.top_k:
|
||||
break
|
||||
provenance_hit = persistent_by_id.get(h.doc_id)
|
||||
# 채택 이유를 명시적으로 남긴다 (#9). 임계 초과가 아니라 연속 일치나
|
||||
# 커버리지 때문에 올라온 후보를 검토자가 구분할 수 있어야 한다.
|
||||
reasons: list[str] = []
|
||||
if h.score >= threshold:
|
||||
reasons.append("score_threshold")
|
||||
if provenance_hit:
|
||||
if provenance_hit.longest_span >= self.settings.persistent_min_exact_span:
|
||||
reasons.append("exact_span")
|
||||
# 커버리지 조건은 문서 단위 union 으로 판단한다. 세그먼트 단독
|
||||
# 비율은 긴 원고에서 구조적으로 작아 조건이 성립하지 않는다 (#3).
|
||||
if document_coverage.get(provenance_hit.document_id, 0.0) >= self.settings.persistent_min_coverage:
|
||||
reasons.append("coverage")
|
||||
if not reasons:
|
||||
continue
|
||||
match = self._to_match(
|
||||
h, opts.return_evidence, lsh_jaccards.get(h.doc_id),
|
||||
self._partial_signal(h.doc_id, elements, query_lemmas),
|
||||
)
|
||||
for h in hits if h.score >= threshold
|
||||
]
|
||||
if provenance_hit:
|
||||
match = self._add_provenance(match, provenance_hit)
|
||||
matches.append(match.model_copy(update={"match_reasons": reasons}))
|
||||
confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0)
|
||||
is_infringement = bool(matches)
|
||||
is_infringement = bool(matches) # 후방호환 필드. 법적 확정이 아니라 임계 초과 매칭.
|
||||
ccl_basis = self._build_ccl_basis(matches) if is_infringement else None
|
||||
|
||||
# 저작권 탭 UI 매핑 (나누구 앱). 독창성=100-유사도, AI 의심도는 현재 스텁.
|
||||
ai_signal = _dummy_ai_generation_signal(text)
|
||||
top_longest = max((m.longest_span for m in matches), default=0)
|
||||
legal_tags = [t.tag for m in matches for t in m.tags]
|
||||
ctx = legal_context or LegalContext()
|
||||
legal = self._legal_engine.assess(
|
||||
max_similarity=matches[0].similarity if matches else 0.0,
|
||||
# 문서 단위 union coverage 를 쓴다. 세그먼트 최대값을 쓰면 긴 원고에서
|
||||
# 항상 0에 가까워 strong_copy 판정이 성립하지 않았다 (#3).
|
||||
coverage=union_coverage,
|
||||
longest_span=top_longest,
|
||||
legal_tags=legal_tags,
|
||||
work_type=ctx.work_type,
|
||||
access_evidence=ctx.access_evidence,
|
||||
protected_expression_reviewed=ctx.protected_expression_reviewed,
|
||||
rights_verified=ctx.rights_verified,
|
||||
)
|
||||
|
||||
# AI 탐지는 전처리 전 raw text에서만 실행한다.
|
||||
ai_result = self._ai_detector.detect(text, with_segments=include_ai_segments)
|
||||
ai_signal = AiGenerationSignal(
|
||||
suspicion_level=ai_result.suspicion_level or "unknown",
|
||||
score=ai_result.score,
|
||||
available=ai_result.available,
|
||||
provenance=ai_result.provenance,
|
||||
is_stub=ai_result.is_stub,
|
||||
model_version=ai_result.model_version,
|
||||
feature_set_version=ai_result.feature_set_version,
|
||||
pos_available=ai_result.pos_available,
|
||||
warnings=ai_result.warnings,
|
||||
segments=[{
|
||||
"index": s.index, "start": s.start, "end": s.end,
|
||||
"char_count": s.char_count, "score": s.score,
|
||||
"suspicion_level": s.suspicion_level, "scored": s.scored,
|
||||
"note": s.note,
|
||||
} for s in ai_result.segments],
|
||||
top_contributions=[
|
||||
{"feature": name, "contribution": round(value, 4)}
|
||||
for name, value in ai_result.top_contributions
|
||||
],
|
||||
note=ai_result.note,
|
||||
)
|
||||
sim_pct = _calibrate_similarity(confidence, threshold)
|
||||
review = ReviewSummary(
|
||||
originality_percent=100 - sim_pct,
|
||||
@ -186,6 +349,34 @@ class PlagiarismDetector:
|
||||
ccl_basis=ccl_basis,
|
||||
review_summary=review,
|
||||
ai_generation=ai_signal,
|
||||
has_similarity_match=bool(matches),
|
||||
corpus_scope_note=(
|
||||
f"현재 등록된 {self.corpus_document_count}개 원천 문서의 "
|
||||
f"{self.corpus_size}개 검색 세그먼트만 대조했습니다. 미매칭은 비침해 확정이 아닙니다."
|
||||
),
|
||||
legal_risk=LegalRiskSignal(
|
||||
status=legal.status,
|
||||
risk_level=legal.risk_level,
|
||||
similarity_evidence=legal.similarity_evidence,
|
||||
protected_expression=legal.protected_expression,
|
||||
access_evidence=legal.access_evidence,
|
||||
missing_factors=list(legal.missing_factors),
|
||||
precedent_ids=list(legal.precedent_ids),
|
||||
disclaimer=legal.disclaimer,
|
||||
),
|
||||
score_semantics=ScoreSemantics(
|
||||
combined_score=round(confidence, 4),
|
||||
threshold_used=threshold,
|
||||
threshold_source=(
|
||||
"request_override" if opts.threshold is not None else "server_default"
|
||||
),
|
||||
threshold_calibrated=self.settings.similarity_threshold_calibrated,
|
||||
provisional=not self.settings.similarity_threshold_calibrated,
|
||||
union_coverage=round(union_coverage, 4),
|
||||
covered_chars=covered_chars,
|
||||
query_chars=len(text),
|
||||
evidence_truncated=evidence_truncated,
|
||||
),
|
||||
autobiography_mode=autobio_mode,
|
||||
candidates_before_filter=candidates_count,
|
||||
engine_version=self.settings.engine_version,
|
||||
@ -193,7 +384,134 @@ class PlagiarismDetector:
|
||||
)
|
||||
|
||||
def detect_request(self, req: DetectRequest) -> DetectResponse:
|
||||
return self.detect(req.doc_id, req.text, req.metadata, req.options)
|
||||
return self.detect(
|
||||
req.doc_id, req.text, req.metadata, req.options,
|
||||
legal_context=req.legal_context,
|
||||
)
|
||||
|
||||
def reference_features(self, hit: PersistentHit) -> tuple[list[str], ExtractedElements]:
|
||||
"""참조 세그먼트의 lemma/요소. 인덱스 캐시 → 프로세스 캐시 → 계산 순 (#5).
|
||||
|
||||
인덱싱 때 채워둔 값이 있으면 형태소 분석을 아예 하지 않는다. v1 DB 나
|
||||
API 업로드분처럼 캐시가 없으면 계산하되 프로세스 캐시에 담고, 호출자가
|
||||
DB 로 백필한다.
|
||||
"""
|
||||
from app.api.schemas import ExtractedElements as _EE
|
||||
|
||||
if hit.reference_lemmas is not None and hit.reference_elements is not None:
|
||||
return hit.reference_lemmas, _EE(**hit.reference_elements)
|
||||
|
||||
cached = self._feature_cache.get(hit.segment_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
lemmas = extract_lemmas(hit.reference_text)
|
||||
elements = self._extractor.extract(hit.reference_text)
|
||||
if len(self._feature_cache) >= self._FEATURE_CACHE_MAX:
|
||||
self._feature_cache.clear()
|
||||
self._feature_cache[hit.segment_id] = (lemmas, elements)
|
||||
return lemmas, elements
|
||||
|
||||
def _backfill_segment_features(self, hits: list[PersistentHit]) -> None:
|
||||
"""질의 중 계산한 참조 특징을 DB 에 되돌려 다음 요청부터 재사용."""
|
||||
if not self._persistent:
|
||||
return
|
||||
pending = [
|
||||
(h.segment_id, *self._feature_cache[h.segment_id])
|
||||
for h in hits
|
||||
if h.reranked
|
||||
and h.reference_lemmas is None
|
||||
and h.segment_id in self._feature_cache
|
||||
]
|
||||
if not pending:
|
||||
return
|
||||
try:
|
||||
self._persistent.store.update_segment_features(
|
||||
(sid, lemmas, elements.model_dump()) for sid, lemmas, elements in pending
|
||||
)
|
||||
except Exception as exc: # 백필 실패가 탐지 응답을 막아서는 안 된다
|
||||
logger.warning("Segment feature backfill failed: %s", exc)
|
||||
|
||||
def _persistent_to_similarity_hit(
|
||||
self, hit: PersistentHit, query_lemmas: list[str], query_elements,
|
||||
) -> SimilarityHit:
|
||||
from app.api.schemas import EvidenceSpan
|
||||
from app.engine.similarity import _element_similarities
|
||||
from app.engine.structural import lemma_overlap_ratio
|
||||
|
||||
reference_lemmas, reference_elements = self.reference_features(hit)
|
||||
element_sim = _element_similarities(query_elements, reference_elements)
|
||||
lemma_sim = lemma_overlap_ratio(query_lemmas, reference_lemmas)
|
||||
s = self.settings
|
||||
combined = (
|
||||
s.weight_text_sim * hit.score
|
||||
+ s.weight_lemma_sim * lemma_sim
|
||||
+ s.weight_char_sim * element_sim["characters"]
|
||||
+ s.weight_motif_sim * element_sim["motifs"]
|
||||
)
|
||||
|
||||
return SimilarityHit(
|
||||
doc_id=hit.segment_id,
|
||||
title=hit.title,
|
||||
score=combined,
|
||||
text_sim=hit.score,
|
||||
lemma_sim=lemma_sim,
|
||||
element_sim=element_sim,
|
||||
evidence=[EvidenceSpan(**span) for span in hit.evidence],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_provenance(match: MatchResult, hit: PersistentHit) -> MatchResult:
|
||||
return match.model_copy(update={
|
||||
"source_document_id": hit.document_id,
|
||||
"source_segment_id": hit.segment_id,
|
||||
"source_locator": hit.source_locator,
|
||||
"coordinate_scope": hit.coordinate_scope,
|
||||
"page_number": hit.page_number,
|
||||
"paragraph_number": hit.paragraph_number,
|
||||
"source_char_start": hit.source_char_start,
|
||||
"source_char_end": hit.source_char_end,
|
||||
"matched_coverage": round(hit.coverage, 4),
|
||||
"longest_span": hit.longest_span,
|
||||
})
|
||||
|
||||
def add_persistent_document(self, doc_id: str | None, title: str, text: str) -> str:
|
||||
if not self._persistent:
|
||||
raise RuntimeError("persistent index is not enabled")
|
||||
document_id = (doc_id or stable_id("doc", title)).strip()
|
||||
self._persistent.store.upsert_document(DocumentRecord(document_id=document_id, title=title))
|
||||
clean_text = text.strip()
|
||||
segments = [SegmentRecord(
|
||||
segment_id=stable_id("seg", document_id, str(start), chunk),
|
||||
document_id=document_id, text=chunk, ordinal=str(i),
|
||||
coordinate_scope="document", char_start=start, char_end=end,
|
||||
source_locator=f"api://corpus/{document_id}#chars={start}-{end}",
|
||||
metadata={"provenance_quality": "api_document"},
|
||||
) for i, (start, end, chunk) in enumerate(
|
||||
chunk_with_offsets(clean_text, size=1000, stride=500), 1
|
||||
)]
|
||||
inserted, duplicates = self._persistent.store.add_segments(segments)
|
||||
if duplicates and not inserted:
|
||||
raise FileExistsError(f"doc_id '{document_id}' 또는 동일 본문이 이미 존재합니다")
|
||||
self._replace_persistent_index()
|
||||
return document_id
|
||||
|
||||
def delete_persistent_document(self, doc_id: str) -> bool:
|
||||
if not self._persistent:
|
||||
raise RuntimeError("persistent index is not enabled")
|
||||
deleted = self._persistent.store.delete_document(doc_id)
|
||||
if deleted:
|
||||
self._replace_persistent_index()
|
||||
return deleted
|
||||
|
||||
def _replace_persistent_index(self) -> None:
|
||||
"""새 객체를 완성한 뒤 포인터를 한 번에 교체해 동시 질의 정합성을 보장."""
|
||||
replacement = PersistentCorpusIndex(
|
||||
self.settings.corpus_db, self.settings.persistent_index_path,
|
||||
)
|
||||
replacement.sync()
|
||||
replacement.load()
|
||||
self._persistent = replacement
|
||||
|
||||
def _partial_signal(self, doc_id, query_elements, query_lemmas) -> PartialPlagiarismSignal | None:
|
||||
"""군집화 기반 요소별 부분 표절 분해 (옵션)."""
|
||||
@ -308,8 +626,9 @@ class PlagiarismDetector:
|
||||
tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요"
|
||||
case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else ""
|
||||
return (
|
||||
f"'{top.source_title}'와 결합 유사도 {top.similarity:.2%}로 매칭. "
|
||||
f"주 침해 태그: {tag_summary}.{case_part}{breakdown}"
|
||||
f"'{top.source_title}'와 검색 유사도 {top.similarity:.2%}로 후보 매칭. "
|
||||
f"검토 가설 태그: {tag_summary}.{case_part}{breakdown} "
|
||||
"법적 침해 여부는 보호되는 표현·의거관계·권리관계를 별도 검토해야 합니다."
|
||||
)
|
||||
|
||||
|
||||
@ -330,20 +649,6 @@ def _calibrate_similarity(raw: float, threshold: float) -> int:
|
||||
return max(0, min(100, round(disp)))
|
||||
|
||||
|
||||
def _dummy_ai_generation_signal(text: str) -> AiGenerationSignal:
|
||||
"""AI 생성 의심도 스텁 — 실제 판별이 아니라 텍스트 기반 결정적 더미 값.
|
||||
|
||||
바이칼 UI(낮음/중간/높음 배지) 연동용 계약 확정 목적. 정식 구현(워터마킹+
|
||||
언어특징 분류) 전까지 is_stub=True 로 반환한다.
|
||||
"""
|
||||
h = sum(ord(c) for c in text[:300]) % 100
|
||||
if h < 70:
|
||||
return AiGenerationSignal(suspicion_level="low", score=round(0.05 + h / 500, 3))
|
||||
if h < 90:
|
||||
return AiGenerationSignal(suspicion_level="medium", score=round(0.45 + (h - 70) / 200, 3))
|
||||
return AiGenerationSignal(suspicion_level="high", score=round(0.72 + (h - 90) / 200, 3))
|
||||
|
||||
|
||||
def _classify_legacy(hit: SimilarityHit) -> InfringementType:
|
||||
"""후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용)."""
|
||||
elem = hit.element_sim
|
||||
|
||||
133
app/engine/legal_risk.py
Normal file
133
app/engine/legal_risk.py
Normal 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),
|
||||
)
|
||||
374
app/engine/persistent_index.py
Normal file
374
app/engine/persistent_index.py
Normal 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
324
app/engine/provenance.py
Normal 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
|
||||
106
app/engine/source_extraction.py
Normal file
106
app/engine/source_extraction.py
Normal 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}")
|
||||
65
app/main.py
65
app/main.py
@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import hmac
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@ -16,9 +19,62 @@ from app.jobs.store import JobStore
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
class AuthConfigurationError(RuntimeError):
|
||||
"""운영 인증 설정이 모순될 때 기동을 막는다."""
|
||||
|
||||
|
||||
def validate_auth_settings(settings) -> None:
|
||||
"""REQUIRE_API_KEY=true 인데 키가 없으면 기동 실패 (fail-closed).
|
||||
|
||||
'키를 깜빡해서 무인증으로 떠 있었다'가 가능한 구성을 없애는 것이 목적이다.
|
||||
미출간 원고를 다루는 서버라 조용한 무인증이 가장 위험하다.
|
||||
"""
|
||||
if settings.require_api_key and not settings.api_key.strip():
|
||||
raise AuthConfigurationError(
|
||||
"REQUIRE_API_KEY=true 인데 API_KEY 가 비어 있습니다. "
|
||||
"키를 설정하거나 REQUIRE_API_KEY=false 로 두십시오."
|
||||
)
|
||||
|
||||
|
||||
def public_paths(settings) -> set[str]:
|
||||
"""인증 없이 접근 가능한 경로. 설정으로 좁힐 수 있다."""
|
||||
paths = {"/"}
|
||||
if settings.public_health:
|
||||
paths.add("/v1/health")
|
||||
if settings.public_docs:
|
||||
paths.update({"/docs", "/openapi.json", "/redoc"})
|
||||
return paths
|
||||
|
||||
|
||||
def is_authorized(settings, path: str, supplied: str) -> bool:
|
||||
"""요청 허용 여부. 순수 함수라 앱 기동 없이 테스트할 수 있다.
|
||||
|
||||
api_key 가 비어 있으면(개발 기본값) 인증을 걸지 않는다. 이 경우 기동 시
|
||||
critical 경고가 남는다.
|
||||
"""
|
||||
configured = settings.api_key.strip()
|
||||
if not configured:
|
||||
return True
|
||||
if path in public_paths(settings):
|
||||
return True
|
||||
protected_docs = {"/docs", "/openapi.json", "/redoc"}
|
||||
if not path.startswith("/v1") and path not in protected_docs:
|
||||
return True
|
||||
# compare_digest 는 비ASCII str 에서 TypeError 를 던진다. 한글/이모지 키를
|
||||
# 넣으면 전 요청이 500 이 되므로 반드시 bytes 로 비교한다.
|
||||
return hmac.compare_digest(configured.encode("utf-8"), (supplied or "").encode("utf-8"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
validate_auth_settings(settings)
|
||||
if not settings.api_key.strip():
|
||||
logging.critical(
|
||||
"API_KEY is empty: all /v1 endpoints are unauthenticated. "
|
||||
"Set REQUIRE_API_KEY=true with a key before exposing this server. "
|
||||
"Do not expose unpublished manuscripts publicly until client key rollout is complete."
|
||||
)
|
||||
app.state.settings = settings
|
||||
app.state.detector = PlagiarismDetector(settings=settings)
|
||||
app.state.job_store = JobStore()
|
||||
@ -55,6 +111,15 @@ app = FastAPI(
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def optional_api_key_auth(request: Request, call_next):
|
||||
"""API_KEY가 설정된 운영 환경에서만 API 인증을 강제한다."""
|
||||
settings = getattr(request.app.state, "settings", None) or _settings
|
||||
if not is_authorized(settings, request.url.path, request.headers.get("x-api-key", "")):
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"})
|
||||
return await call_next(request)
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
if _STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
@ -92,6 +92,24 @@
|
||||
.scorebar-row .label { color: var(--muted); }
|
||||
.scorebar-row .value { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.ai-card {
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 14px; margin-top: 8px;
|
||||
}
|
||||
.ai-card.ai-high { border-color: var(--danger); background: rgba(248, 81, 73, 0.08); }
|
||||
.ai-card.ai-medium { border-color: var(--warning); background: rgba(210, 153, 34, 0.08); }
|
||||
.ai-card.ai-low { border-color: var(--success); background: rgba(63, 185, 80, 0.08); }
|
||||
.ai-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.ai-title { font-size: 14px; font-weight: 600; }
|
||||
.ai-score { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.ai-note { color: var(--muted); font-size: 11px; margin-top: 8px; line-height: 1.6; }
|
||||
.ai-warning { color: var(--warning); font-size: 11px; margin-top: 6px; }
|
||||
.ai-segment {
|
||||
margin-top: 8px; padding: 8px 10px; background: var(--bg); border-radius: 4px;
|
||||
font-size: 11px; border-left: 3px solid var(--warning);
|
||||
}
|
||||
.ai-segment .meta { color: var(--muted); margin-bottom: 3px; }
|
||||
|
||||
.match-card {
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 14px; margin-top: 10px;
|
||||
@ -281,6 +299,9 @@
|
||||
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">점수 분석 (삼중 유사도 결합)</h3>
|
||||
<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>
|
||||
<div id="matches"></div>
|
||||
|
||||
@ -468,18 +489,21 @@ async function runDetect() {
|
||||
|
||||
function renderResult(data, originalText) {
|
||||
const verdict = document.getElementById("verdict");
|
||||
const displaySimilarity = data.review_summary && Number.isFinite(data.review_summary.similarity_percent)
|
||||
? data.review_summary.similarity_percent
|
||||
: Math.round(data.confidence * 100);
|
||||
if (data.is_infringement) {
|
||||
verdict.className = "verdict infringement";
|
||||
verdict.innerHTML = `
|
||||
<h3>⚠ 저작권 침해 가능성 확인</h3>
|
||||
<div class="conf-line">결합 유사도</div>
|
||||
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`;
|
||||
<div class="conf-line">보정 유사도</div>
|
||||
<div class="conf-number">${displaySimilarity}%</div>`;
|
||||
} else {
|
||||
verdict.className = "verdict clean";
|
||||
verdict.innerHTML = `
|
||||
<h3>✓ 침해 신호 없음</h3>
|
||||
<div class="conf-line">최상위 유사도</div>
|
||||
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`;
|
||||
<div class="conf-line">보정 유사도 · 검색 후보 점수는 침해 확률이 아님</div>
|
||||
<div class="conf-number">${displaySimilarity}%</div>`;
|
||||
}
|
||||
|
||||
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>';
|
||||
}
|
||||
|
||||
renderAiGeneration(data.ai_generation, originalText);
|
||||
|
||||
// 매칭 카드
|
||||
const matchesEl = document.getElementById("matches");
|
||||
if (!data.matches || data.matches.length === 0) {
|
||||
@ -541,6 +567,68 @@ function renderResult(data, originalText) {
|
||||
document.getElementById("raw-json").textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function renderAiGeneration(ai, originalText) {
|
||||
const el = document.getElementById("ai-generation");
|
||||
if (!ai || !ai.available) {
|
||||
const note = ai && ai.note
|
||||
? ai.note
|
||||
: "학습된 AI 생성 탐지 모델이 없어 의심도를 산출하지 않습니다.";
|
||||
const version = ai && ai.model_version ? ai.model_version : "unavailable";
|
||||
el.innerHTML = `
|
||||
<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) {
|
||||
if (!spans || spans.length === 0) return "";
|
||||
// span 정렬 후 마킹
|
||||
|
||||
10
data/precedents/README.md
Normal file
10
data/precedents/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# 판례 지식베이스
|
||||
|
||||
`precedents.jsonl`에는 검증 가능한 공식 출처가 있는 판례만 등록합니다. 현재 포함된
|
||||
레코드는 엔진과 스키마 검증용 최소 시드이며, 2,000여 건이 적재됐다는 의미가 아닙니다.
|
||||
|
||||
필수 필드: `case_id`, `title`, `source_url`, `holding_summary`.
|
||||
권장 필드: `work_types`, `legal_tags`, `criteria`, `outcome`.
|
||||
|
||||
판례 원문/요약의 재배포 가능 범위와 전문가 라벨링 기준을 확인한 뒤 데이터를 늘려야
|
||||
합니다. 모델이나 LLM이 사건번호를 자유 생성하게 해서는 안 됩니다.
|
||||
1
data/precedents/precedents.jsonl
Normal file
1
data/precedents/precedents.jsonl
Normal 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}
|
||||
@ -16,6 +16,10 @@ services:
|
||||
REFERENCE_CORPUS_DIR: /app/data/reference
|
||||
TAXONOMY_DIR: /app/data/taxonomy
|
||||
AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt
|
||||
CORPUS_DB_PATH: /app/data/runtime/corpus.sqlite3
|
||||
PERSISTENT_INDEX_DIR: /app/data/runtime/index
|
||||
PRECEDENTS_PATH: /app/data/precedents/precedents.jsonl
|
||||
AI_DETECTOR_MODEL_PATH: /app/data/models/ai_detector.joblib
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
329
docs/AI_DETECTION.md
Normal file
329
docs/AI_DETECTION.md
Normal 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 생성 의심도"보다 **"문체 이례도"** 로
|
||||
표기하고, 검토 우선순위 정렬 용도로만 쓸 것을 권한다.
|
||||
@ -64,7 +64,7 @@
|
||||
| 저작권 · 유사도 2% | 2 | `review_summary.similarity_percent` |
|
||||
| "…3.5만 건과 대조한 결과 표절 의심 구간이 없습니다" | 35000 / false | `review_summary.compared_count`, `review_summary.has_suspicion` |
|
||||
| 유사 문장 0건 | 0 | `review_summary.similar_sentence_count` |
|
||||
| AI 생성 의심도 낮음 | low | `review_summary.ai_suspicion_level` (`low/medium/high`=낮음/중간/높음) |
|
||||
| AI 생성 의심도 | unknown | `review_summary.ai_suspicion_level` (`low/medium/high/unknown`) |
|
||||
| (표절 시) 일치 구간 하이라이트 | start~end | `matches[].evidence_spans[]` |
|
||||
| (표절 시) 침해 유형 배지 | 복제권 | `matches[].tags[].label_ko` + `case_id` |
|
||||
|
||||
@ -114,7 +114,7 @@
|
||||
"has_suspicion": false,
|
||||
"ai_suspicion_level": "low"
|
||||
},
|
||||
"ai_generation": { "suspicion_level": "low", "score": 0.06, "is_stub": true, "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" },
|
||||
"ai_generation": { "suspicion_level": "unknown", "score": null, "available": false, "is_stub": false, "model_version": "unavailable", "note": "학습된 모델이 없어 채점하지 않음" },
|
||||
"matches": [],
|
||||
"extracted_elements": { "characters": [], "motifs": ["비밀의 정원"], "genre": null, "keywords": ["숲","전설"] },
|
||||
"ccl_basis": null,
|
||||
@ -130,7 +130,7 @@
|
||||
| 필드 | 타입 | 의미 | 화면 사용 |
|
||||
|---|---|---|---|
|
||||
| `doc_id` | string | 요청의 doc_id 그대로 | 응답 매칭 |
|
||||
| `is_infringement` | bool | 표절 판정 여부(임계 초과 매칭 존재). `review_summary.has_suspicion`과 동일 | — |
|
||||
| `is_infringement` | bool | 후방호환 필드. 법적 침해 확정이 아니라 임계 초과 매칭 존재 여부 | — |
|
||||
| `confidence` | float 0~1 | 최상위 매칭의 **결합 유사도 원값**. ⚠️ 임베딩 성분 때문에 무관한 글도 높게 나옴 → **화면 표시는 `review_summary` 사용, `confidence` 직접 표시 금지** | ✗ |
|
||||
| `review_summary` | object | **저작권 탭 UI 요약** (아래 상세) | ★ |
|
||||
| `ai_generation` | object | AI 생성 의심도 상세 (아래 상세) | 참고 |
|
||||
@ -151,7 +151,7 @@
|
||||
| `similar_sentence_count` | int | **유사 문장 건수** = 임계 초과 매칭 수 |
|
||||
| `compared_count` | int | **대조한 원본(코퍼스) 건수** |
|
||||
| `has_suspicion` | bool | **표절 의심 구간 존재 여부** |
|
||||
| `ai_suspicion_level` | `low/medium/high` | **AI 생성 의심도**(낮음/중간/높음). ⚠️ 현재 더미 |
|
||||
| `ai_suspicion_level` | `low/medium/high/unknown` | **AI 생성 의심도**. 미학습/채점 불가는 `unknown` |
|
||||
|
||||
**`ai_generation` (AI 생성 의심도 상세)**
|
||||
|
||||
@ -159,11 +159,14 @@
|
||||
|---|---|---|
|
||||
| `suspicion_level` | `low/medium/high` | 의심도 등급 |
|
||||
| `score` | float 0~1 | 참고 점수 |
|
||||
| `is_stub` | bool | **`true`면 더미(미구현)**. 정식 구현 시 `false` |
|
||||
| `available` | bool | 학습 모델 또는 명시적으로 활성화한 baseline으로 채점했는지 |
|
||||
| `is_stub` | bool | `true`면 미검증 휴리스틱 baseline. 학습 모델은 `false` |
|
||||
| `model_version` | string | 학습 아티팩트/특징 버전 추적 |
|
||||
| `note` | string | 안내 문구 |
|
||||
|
||||
> ⚠️ **AI 생성 의심도는 현재 스텁**이다. `is_stub=true`이며 요청마다 더미 값을 낸다.
|
||||
> UI 배지는 붙여두되 **출판 승인 게이트로 쓰지 말 것**(참고용).
|
||||
> 학습 모델이 없으면 점수를 임의 생성하지 않고 `available=false`, `score=null`,
|
||||
> `suspicion_level=unknown`을 반환한다. 학습 후에도 **출판 승인 게이트나 저자 제재의
|
||||
> 단독 근거로 쓰지 말 것**(사람 검토 우선순위용).
|
||||
|
||||
**`matches[]` (표절 의심 시 채워짐)**
|
||||
|
||||
|
||||
@ -32,9 +32,9 @@
|
||||
| 표절 의심 구간 **없음** | false | `review_summary.has_suspicion` |
|
||||
| AI 생성 의심도 **낮음** | low | `review_summary.ai_suspicion_level` |
|
||||
|
||||
> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음
|
||||
> **⚠️ AI 생성 의심도는 현재 더미(스텁) 값입니다.** UI 연동 계약 확정용이며, 정식 판별
|
||||
> 로직(워터마킹+언어특징 분류) 적용 전까지 `ai_generation.is_stub=true` 로 반환됩니다.
|
||||
> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음,
|
||||
> `unknown`=미학습 또는 채점 불가. 학습 모델이 없으면 점수를 만들지 않고
|
||||
> `ai_generation.available=false`, `score=null`을 반환합니다.
|
||||
> UI는 지금 그대로 붙여두면 되고, 정식 구현 시 값만 실제로 바뀝니다.
|
||||
|
||||
---
|
||||
@ -88,7 +88,9 @@
|
||||
"suspicion_level": "low",
|
||||
"score": 0.06,
|
||||
"is_stub": true,
|
||||
"note": "더미 응답 — 실제 AI 생성 판별 결과가 아님"
|
||||
"available": false,
|
||||
"model_version": "unavailable",
|
||||
"note": "학습된 모델이 없어 채점하지 않음"
|
||||
},
|
||||
"matches": [],
|
||||
"extracted_elements": {
|
||||
@ -117,7 +119,7 @@
|
||||
"has_suspicion": true,
|
||||
"ai_suspicion_level": "low"
|
||||
},
|
||||
"ai_generation": { "suspicion_level": "low", "score": 0.06, "is_stub": true, "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" },
|
||||
"ai_generation": { "suspicion_level": "unknown", "score": null, "available": false, "is_stub": false, "model_version": "unavailable", "note": "학습된 모델이 없어 채점하지 않음" },
|
||||
"matches": [
|
||||
{
|
||||
"source_doc": "auto-0003",
|
||||
@ -151,10 +153,10 @@
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `is_infringement` | bool | 표절 판정 여부 (= `review_summary.has_suspicion`) |
|
||||
| `is_infringement` | bool | 후방호환용 임계 초과 매칭 여부. 법적 침해 확정 아님 |
|
||||
| `confidence` | float | 최상위 매칭 결합 유사도 원값(0~1). **화면 표시는 `review_summary` 사용 권장** |
|
||||
| `review_summary` | object | **저작권 탭 UI 직접 매핑** (§1 표 참조) |
|
||||
| `ai_generation` | object | AI 생성 의심도 상세. `is_stub=true`면 더미 |
|
||||
| `ai_generation` | object | AI 생성 의심도 상세. 미학습이면 `available=false`, 휴리스틱이면 `is_stub=true` |
|
||||
| `matches[]` | array | 매칭된 원본별 상세 (표절 시). 태그·케이스·근거 구간 포함 |
|
||||
| `matches[].tags[]` | array | 법령 태그. `role`: `primary`(주)/`secondary`(보조), `label_ko` 한글 표기 |
|
||||
| `matches[].case_id` | string | 39종 침해 케이스 ID (예: A6) |
|
||||
@ -196,7 +198,7 @@ FastAPI 표준. HTTP 상태코드 + `detail`.
|
||||
|
||||
## 5. 연동 시 주의
|
||||
|
||||
1. **AI 생성 의심도는 현재 더미** — `is_stub` 로 판별 후, UI에는 표시하되 "참고용"으로 둘 것.
|
||||
1. **AI 생성 의심도는 확정값이 아님** — `available`/`is_stub`/`model_version`을 확인하고 사람 검토 우선순위로만 사용할 것.
|
||||
출판 승인/거절 게이트로 쓰지 말 것(오탐 리스크).
|
||||
2. **화면 값은 `review_summary` 사용** — `confidence`(원값)는 임베딩 성분 때문에 무관한 글도
|
||||
높게 나오므로 직접 표시 금지. 독창성 환산은 오투오가 `review_summary`에서 완료해 제공.
|
||||
|
||||
201
docs/IMPLEMENTATION_RUNBOOK.md
Normal file
201
docs/IMPLEMENTATION_RUNBOOK.md
Normal 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` 일치 ③ 사건번호 순이며, 생성형 인용은 어떤 경로로도 발생하지 않는다.
|
||||
@ -9,4 +9,8 @@ openai>=1.55
|
||||
httpx>=0.27
|
||||
kiwipiepy>=0.18
|
||||
datasketch>=1.6
|
||||
sentence-transformers>=3.0
|
||||
openpyxl>=3.1
|
||||
joblib>=1.4
|
||||
scipy>=1.13
|
||||
pypdf>=5.0
|
||||
python-docx>=1.1
|
||||
|
||||
504
scripts/build_ai_training_dataset.py
Normal file
504
scripts/build_ai_training_dataset.py
Normal 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())
|
||||
73
scripts/build_persistent_index.py
Normal file
73
scripts/build_persistent_index.py
Normal 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())
|
||||
205
scripts/calibrate_ai_detector_cuts.py
Normal file
205
scripts/calibrate_ai_detector_cuts.py
Normal 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())
|
||||
51
scripts/extract_source_documents.py
Normal file
51
scripts/extract_source_documents.py
Normal 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
113
scripts/ingest_o2o_xlsx.py
Normal 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())
|
||||
417
scripts/train_ai_detector.py
Normal file
417
scripts/train_ai_detector.py
Normal 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())
|
||||
27
scripts/validate_precedents.py
Normal file
27
scripts/validate_precedents.py
Normal 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
657
tests/test_ai_detector.py
Normal 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
|
||||
167
tests/test_ai_training_split.py
Normal file
167
tests/test_ai_training_split.py
Normal 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
|
||||
@ -56,3 +56,69 @@ def test_batch_flow():
|
||||
job_id = resp.json()["job_id"]
|
||||
status = client.get(f"/v1/plagiarism/batch/{job_id}")
|
||||
assert status.status_code == 200
|
||||
|
||||
|
||||
def test_detect_exposes_score_semantics():
|
||||
"""#9 — 점수/임계값의 의미가 응답에 명시되어야 한다."""
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={"doc_id": "s-1", "text": "어린왕자는 작은 별에서 온 소년이다."},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
sem = resp.json()["score_semantics"]
|
||||
assert sem["threshold_source"] == "server_default"
|
||||
assert sem["threshold_calibrated"] is False
|
||||
assert sem["provisional"] is True, "캘리브레이션 전에는 잠정값으로 노출"
|
||||
assert 0.0 <= sem["union_coverage"] <= 1.0
|
||||
assert sem["query_chars"] > 0
|
||||
assert "침해 확률이 아닙니다" in sem["note"]
|
||||
|
||||
|
||||
def test_detect_reports_request_threshold_override():
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={"doc_id": "s-2", "text": "앤 셜리는 초록 지붕 집에 입양된 소녀다.",
|
||||
"options": {"threshold": 0.4}},
|
||||
)
|
||||
sem = resp.json()["score_semantics"]
|
||||
assert sem["threshold_source"] == "request_override"
|
||||
assert sem["threshold_used"] == 0.4
|
||||
|
||||
|
||||
def test_detect_accepts_legal_context():
|
||||
"""#10 — 사람이 확인한 사실을 전달하면 missing_factors 에서 빠진다."""
|
||||
with TestClient(app) as client:
|
||||
base = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={"doc_id": "l-1", "text": "홍길동은 활빈당을 만들어 재물을 나누었다."},
|
||||
).json()["legal_risk"]
|
||||
assert base["access_evidence"] == "not_provided"
|
||||
assert len(base["missing_factors"]) == 3
|
||||
|
||||
supplied = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={
|
||||
"doc_id": "l-2", "text": "홍길동은 활빈당을 만들어 재물을 나누었다.",
|
||||
"legal_context": {
|
||||
"work_type": "literary", "access_evidence": True,
|
||||
"protected_expression_reviewed": True, "rights_verified": True,
|
||||
},
|
||||
},
|
||||
).json()["legal_risk"]
|
||||
assert supplied["access_evidence"] == "provided"
|
||||
assert supplied["protected_expression"] == "reviewed"
|
||||
assert supplied["missing_factors"] == []
|
||||
|
||||
|
||||
def test_matches_carry_match_reasons():
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={"doc_id": "r-1", "text": "어린왕자는 작은 별에서 온 소년이다. 그는 여우를 만난다.",
|
||||
"options": {"threshold": 0.01}},
|
||||
)
|
||||
for match in resp.json()["matches"]:
|
||||
assert match["match_reasons"], "채택 이유가 비어 있으면 안 된다"
|
||||
assert set(match["match_reasons"]) <= {"score_threshold", "exact_span", "coverage"}
|
||||
|
||||
144
tests/test_auth_middleware.py
Normal file
144
tests/test_auth_middleware.py
Normal 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
|
||||
337
tests/test_coverage_and_features.py
Normal file
337
tests/test_coverage_and_features.py
Normal 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
39
tests/test_legal_risk.py
Normal 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"
|
||||
69
tests/test_provenance_index.py
Normal file
69
tests/test_provenance_index.py
Normal 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
|
||||
144
tests/test_review_regressions.py
Normal file
144
tests/test_review_regressions.py
Normal 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
|
||||
16
tests/test_source_extraction.py
Normal file
16
tests/test_source_extraction.py
Normal 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
24
tests/test_static_ui.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user