3.5만 에피소드 기반 표절·AI·판례 분석 파이프라인 구현
This commit is contained in:
parent
4f6898d0b2
commit
ce50e89089
@ -3,5 +3,8 @@ __pycache__
|
|||||||
.venv
|
.venv
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
tests
|
data/input
|
||||||
scripts
|
data/runtime
|
||||||
|
data/models
|
||||||
|
*.xlsx
|
||||||
|
*.joblib
|
||||||
|
|||||||
16
.env.example
16
.env.example
@ -5,12 +5,28 @@ LOG_LEVEL=info
|
|||||||
RELOAD=false
|
RELOAD=false
|
||||||
# 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때)
|
# 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때)
|
||||||
ROOT_PATH=
|
ROOT_PATH=
|
||||||
|
# 설정하면 /v1/health를 제외한 API 요청에 X-API-Key가 필요합니다.
|
||||||
|
API_KEY=
|
||||||
|
|
||||||
ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
|
ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
|
||||||
REFERENCE_CORPUS_DIR=./data/reference
|
REFERENCE_CORPUS_DIR=./data/reference
|
||||||
TAXONOMY_DIR=./data/taxonomy
|
TAXONOMY_DIR=./data/taxonomy
|
||||||
AUTOBIOGRAPHY_PATTERNS_PATH=./data/autobiography/common_patterns.txt
|
AUTOBIOGRAPHY_PATTERNS_PATH=./data/autobiography/common_patterns.txt
|
||||||
|
|
||||||
|
# 3.5만 에피소드용 SQLite provenance + 증분 영속 인덱스.
|
||||||
|
# 서버에서 ingest/build 완료 후 true로 전환합니다.
|
||||||
|
USE_PERSISTENT_INDEX=false
|
||||||
|
CORPUS_DB_PATH=./data/runtime/corpus.sqlite3
|
||||||
|
PERSISTENT_INDEX_DIR=./data/runtime/index
|
||||||
|
PERSISTENT_SIMILARITY_THRESHOLD=0.65
|
||||||
|
PERSISTENT_MIN_EXACT_SPAN=80
|
||||||
|
PERSISTENT_MIN_COVERAGE=0.30
|
||||||
|
PERSISTENT_RERANK_TOP_K=20
|
||||||
|
PRECEDENTS_PATH=./data/precedents/precedents.jsonl
|
||||||
|
AI_DETECTOR_MODEL_PATH=./data/models/ai_detector.joblib
|
||||||
|
AI_DETECTOR_ALLOW_HEURISTIC=false
|
||||||
|
AI_DETECTOR_USE_POS=true
|
||||||
|
|
||||||
# PDF VII-4 권장 보수적 임계값 (정밀도 우선)
|
# PDF VII-4 권장 보수적 임계값 (정밀도 우선)
|
||||||
SIMILARITY_THRESHOLD=0.85
|
SIMILARITY_THRESHOLD=0.85
|
||||||
|
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv-*/
|
||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@ -31,6 +32,12 @@ data/training/*.csv
|
|||||||
data/reference/autobio-*.txt
|
data/reference/autobio-*.txt
|
||||||
data/reference/corpus-*.txt
|
data/reference/corpus-*.txt
|
||||||
|
|
||||||
|
# 운영 원고·인덱스·학습 모델 (서버 볼륨에만 저장)
|
||||||
|
data/input/
|
||||||
|
data/runtime/
|
||||||
|
data/models/
|
||||||
|
*.joblib
|
||||||
|
|
||||||
# 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능)
|
# 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능)
|
||||||
|
|
||||||
# 로그
|
# 로그
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13-slim
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@ -18,6 +18,14 @@ RUN pip install -r requirements.txt
|
|||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY data ./data
|
COPY data ./data
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
FROM base AS test
|
||||||
|
RUN pip install "pytest>=8.0"
|
||||||
|
COPY tests ./tests
|
||||||
|
CMD ["pytest", "tests", "-q"]
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
# 코퍼스 업로드 디렉토리는 볼륨 마운트 권장
|
# 코퍼스 업로드 디렉토리는 볼륨 마운트 권장
|
||||||
VOLUME ["/app/data/reference"]
|
VOLUME ["/app/data/reference"]
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status
|
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
BatchCreatedResponse,
|
BatchCreatedResponse,
|
||||||
@ -49,6 +50,10 @@ async def health(request: Request) -> HealthResponse:
|
|||||||
corpus_size=det.corpus_size,
|
corpus_size=det.corpus_size,
|
||||||
taxonomy_version=taxonomy_version,
|
taxonomy_version=taxonomy_version,
|
||||||
autobiography_mode=settings.autobiography_mode,
|
autobiography_mode=settings.autobiography_mode,
|
||||||
|
corpus_documents=det.corpus_document_count,
|
||||||
|
index_backend=det.index_backend,
|
||||||
|
ai_model_ready=getattr(det, "ai_model_ready", False),
|
||||||
|
precedent_count=det.precedent_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -83,7 +88,8 @@ async def taxonomy(request: Request) -> TaxonomyResponse:
|
|||||||
tags=["plagiarism"],
|
tags=["plagiarism"],
|
||||||
)
|
)
|
||||||
async def detect(req: DetectRequest, request: Request) -> DetectResponse:
|
async def detect(req: DetectRequest, request: Request) -> DetectResponse:
|
||||||
return _detector(request).detect_request(req)
|
# 형태소/임베딩/행렬 연산으로 event loop가 막히지 않도록 worker thread에서 실행.
|
||||||
|
return await run_in_threadpool(_detector(request).detect_request, req)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@ -166,6 +172,16 @@ def _rebuild(request: Request) -> int:
|
|||||||
return rebuild_detector(request.app)
|
return rebuild_detector(request.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _persistent_add_locked(request: Request, detector, doc_id, title, text) -> str:
|
||||||
|
with request.app.state.detector_lock:
|
||||||
|
return detector.add_persistent_document(doc_id, title, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _persistent_delete_locked(request: Request, detector, doc_id: str) -> bool:
|
||||||
|
with request.app.state.detector_lock:
|
||||||
|
return detector.delete_persistent_document(doc_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/corpus",
|
"/corpus",
|
||||||
response_model=CorpusListResponse,
|
response_model=CorpusListResponse,
|
||||||
@ -173,7 +189,17 @@ def _rebuild(request: Request) -> int:
|
|||||||
)
|
)
|
||||||
async def corpus_list(request: Request) -> CorpusListResponse:
|
async def corpus_list(request: Request) -> CorpusListResponse:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
docs = list_documents(settings.corpus_path)
|
detector = _detector(request)
|
||||||
|
if detector.uses_persistent_index:
|
||||||
|
docs = [
|
||||||
|
{
|
||||||
|
"doc_id": d["document_id"], "title": d["title"],
|
||||||
|
"size_bytes": d["characters"], "filename": d["source_path"],
|
||||||
|
}
|
||||||
|
for d in detector.list_persistent_documents()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
docs = list_documents(settings.corpus_path)
|
||||||
return CorpusListResponse(
|
return CorpusListResponse(
|
||||||
total=len(docs),
|
total=len(docs),
|
||||||
docs=[CorpusItem(**d) for d in docs],
|
docs=[CorpusItem(**d) for d in docs],
|
||||||
@ -189,6 +215,21 @@ async def corpus_list(request: Request) -> CorpusListResponse:
|
|||||||
async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
|
async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
|
||||||
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
|
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
detector = _detector(request)
|
||||||
|
if detector.uses_persistent_index:
|
||||||
|
try:
|
||||||
|
document_id = await run_in_threadpool(
|
||||||
|
_persistent_add_locked, request, detector, req.doc_id, req.title, req.text
|
||||||
|
)
|
||||||
|
except FileExistsError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return CorpusUploadResponse(
|
||||||
|
doc_id=document_id, title=req.title,
|
||||||
|
size_bytes=len(req.text.encode("utf-8")),
|
||||||
|
corpus_size_after=detector.corpus_size, rebuilt=False,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text)
|
doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text)
|
||||||
except FileExistsError as e:
|
except FileExistsError as e:
|
||||||
@ -196,7 +237,7 @@ async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> Corp
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
new_size = _rebuild(request)
|
new_size = await run_in_threadpool(_rebuild, request)
|
||||||
return CorpusUploadResponse(
|
return CorpusUploadResponse(
|
||||||
doc_id=doc.doc_id, title=doc.title,
|
doc_id=doc.doc_id, title=doc.title,
|
||||||
size_bytes=len(doc.text.encode("utf-8")),
|
size_bytes=len(doc.text.encode("utf-8")),
|
||||||
@ -224,6 +265,21 @@ async def corpus_upload_file(
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.")
|
raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.")
|
||||||
|
|
||||||
|
detector = _detector(request)
|
||||||
|
if detector.uses_persistent_index:
|
||||||
|
try:
|
||||||
|
document_id = await run_in_threadpool(
|
||||||
|
_persistent_add_locked, request, detector, doc_id, title, text
|
||||||
|
)
|
||||||
|
except FileExistsError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return CorpusUploadResponse(
|
||||||
|
doc_id=document_id, title=title, size_bytes=len(raw),
|
||||||
|
corpus_size_after=detector.corpus_size, rebuilt=False,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = add_document(settings.corpus_path, doc_id, title, text)
|
doc = add_document(settings.corpus_path, doc_id, title, text)
|
||||||
except FileExistsError as e:
|
except FileExistsError as e:
|
||||||
@ -231,7 +287,7 @@ async def corpus_upload_file(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
new_size = _rebuild(request)
|
new_size = await run_in_threadpool(_rebuild, request)
|
||||||
return CorpusUploadResponse(
|
return CorpusUploadResponse(
|
||||||
doc_id=doc.doc_id, title=doc.title,
|
doc_id=doc.doc_id, title=doc.title,
|
||||||
size_bytes=len(doc.text.encode("utf-8")),
|
size_bytes=len(doc.text.encode("utf-8")),
|
||||||
@ -246,9 +302,17 @@ async def corpus_upload_file(
|
|||||||
)
|
)
|
||||||
async def corpus_delete(doc_id: str, request: Request) -> None:
|
async def corpus_delete(doc_id: str, request: Request) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
detector = _detector(request)
|
||||||
|
if detector.uses_persistent_index:
|
||||||
|
deleted = await run_in_threadpool(
|
||||||
|
_persistent_delete_locked, request, detector, doc_id
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
|
||||||
|
return
|
||||||
if not delete_document(settings.corpus_path, doc_id):
|
if not delete_document(settings.corpus_path, doc_id):
|
||||||
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
|
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
|
||||||
_rebuild(request)
|
await run_in_threadpool(_rebuild, request)
|
||||||
|
|
||||||
|
|
||||||
def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: BatchRequest) -> None:
|
def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: BatchRequest) -> None:
|
||||||
@ -260,6 +324,7 @@ def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req:
|
|||||||
text=item.text,
|
text=item.text,
|
||||||
metadata=item.metadata,
|
metadata=item.metadata,
|
||||||
options=req.options,
|
options=req.options,
|
||||||
|
include_ai_segments=False,
|
||||||
)
|
)
|
||||||
store.append_result(job_id, result)
|
store.append_result(job_id, result)
|
||||||
store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc))
|
store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc))
|
||||||
|
|||||||
@ -66,6 +66,8 @@ class EvidenceSpan(BaseModel):
|
|||||||
start: int
|
start: int
|
||||||
end: int
|
end: int
|
||||||
matched: str
|
matched: str
|
||||||
|
source_start: int | None = None
|
||||||
|
source_end: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class InfringementTag(BaseModel):
|
class InfringementTag(BaseModel):
|
||||||
@ -107,6 +109,16 @@ class MatchResult(BaseModel):
|
|||||||
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
|
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
|
||||||
score_breakdown: ScoreBreakdown | None = None
|
score_breakdown: ScoreBreakdown | None = None
|
||||||
partial_signal: PartialPlagiarismSignal | None = None
|
partial_signal: PartialPlagiarismSignal | None = None
|
||||||
|
source_document_id: str | None = None
|
||||||
|
source_segment_id: str | None = None
|
||||||
|
source_locator: str | None = None
|
||||||
|
coordinate_scope: Literal["document", "page", "paragraph", "episode", "unknown"] = "unknown"
|
||||||
|
page_number: int | None = None
|
||||||
|
paragraph_number: int | None = None
|
||||||
|
source_char_start: int | None = None
|
||||||
|
source_char_end: int | None = None
|
||||||
|
matched_coverage: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
longest_span: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ExtractedElements(BaseModel):
|
class ExtractedElements(BaseModel):
|
||||||
@ -116,19 +128,45 @@ class ExtractedElements(BaseModel):
|
|||||||
keywords: list[str] = Field(default_factory=list)
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AiGenerationSignal(BaseModel):
|
class LegalRiskSignal(BaseModel):
|
||||||
"""AI 생성 의심도 — 스텁(더미) 응답.
|
"""판례 기반 검토 보조. 법률상 침해 확정값이 아님."""
|
||||||
|
status: Literal[
|
||||||
|
"review_required", "no_registered_corpus_match", "insufficient_precedent_data"
|
||||||
|
]
|
||||||
|
risk_level: Literal["low", "medium", "high"] | None = None
|
||||||
|
similarity_evidence: str
|
||||||
|
protected_expression: Literal["reviewed", "not_reviewed"]
|
||||||
|
access_evidence: Literal["provided", "not_found", "not_provided"]
|
||||||
|
missing_factors: list[str] = Field(default_factory=list)
|
||||||
|
precedent_ids: list[str] = Field(default_factory=list)
|
||||||
|
disclaimer: str
|
||||||
|
|
||||||
⚠️ 현재는 실제 판별 로직이 아니라 요청마다 더미 값을 반환한다. 바이칼 UI 연동
|
|
||||||
(낮음/중간/높음 배지)을 위한 API 계약 확정용. 정식 구현은 워터마킹(내부 생성물)
|
class AiSegmentSignal(BaseModel):
|
||||||
+ 한국어 언어특징 분류(외부 유입분)로 대체 예정이며, 그때 is_stub=false 가 된다.
|
index: int
|
||||||
"""
|
start: int
|
||||||
suspicion_level: Literal["low", "medium", "high"] = Field(
|
end: int
|
||||||
..., description="낮음/중간/높음 — UI 배지용"
|
char_count: int
|
||||||
)
|
score: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
score: float = Field(..., ge=0.0, le=1.0, description="0~1 참고 점수")
|
suspicion_level: Literal["low", "medium", "high"] | None = None
|
||||||
is_stub: bool = Field(default=True, description="True면 더미 응답(미구현)")
|
scored: bool
|
||||||
note: str = "더미 응답 — 실제 AI 생성 판별 결과가 아님"
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AiGenerationSignal(BaseModel):
|
||||||
|
"""한국어 언어특징 기반 검토 우선순위. AI 작성 확정값이 아님."""
|
||||||
|
suspicion_level: Literal["low", "medium", "high", "unknown"] = "unknown"
|
||||||
|
score: float | None = Field(default=None, ge=0.0, le=1.0, description="검토 우선순위 점수")
|
||||||
|
available: bool = False
|
||||||
|
provenance: Literal["human", "ai", "mixed", "edited", "unknown"] = "unknown"
|
||||||
|
is_stub: bool = Field(default=False, description="True면 미검증 휴리스틱 baseline")
|
||||||
|
model_version: str = "unavailable"
|
||||||
|
feature_set_version: str | None = None
|
||||||
|
pos_available: bool = False
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
segments: list[AiSegmentSignal] = Field(default_factory=list)
|
||||||
|
top_contributions: list[dict] = Field(default_factory=list)
|
||||||
|
note: str = "학습된 모델이 없어 결과를 제공하지 않습니다."
|
||||||
|
|
||||||
|
|
||||||
class ReviewSummary(BaseModel):
|
class ReviewSummary(BaseModel):
|
||||||
@ -141,15 +179,15 @@ class ReviewSummary(BaseModel):
|
|||||||
유사 문장 0건 ↔ similar_sentence_count
|
유사 문장 0건 ↔ similar_sentence_count
|
||||||
대조 3.5만 건 ↔ compared_count (코퍼스 크기)
|
대조 3.5만 건 ↔ compared_count (코퍼스 크기)
|
||||||
표절 의심 구간 없음 ↔ has_suspicion(false)
|
표절 의심 구간 없음 ↔ has_suspicion(false)
|
||||||
AI 생성 의심도 낮음 ↔ ai_suspicion_level (현재 더미)
|
AI 생성 의심도 ↔ ai_suspicion_level (미학습/채점 불가는 unknown)
|
||||||
"""
|
"""
|
||||||
originality_percent: int = Field(..., ge=0, le=100, description="독창성 % (100 - 유사도)")
|
originality_percent: int = Field(..., ge=0, le=100, description="독창성 % (100 - 유사도)")
|
||||||
similarity_percent: int = Field(..., ge=0, le=100, description="유사도 %")
|
similarity_percent: int = Field(..., ge=0, le=100, description="유사도 %")
|
||||||
similar_sentence_count: int = Field(..., ge=0, description="유사 문장(매칭) 건수")
|
similar_sentence_count: int = Field(..., ge=0, description="유사 문장(매칭) 건수")
|
||||||
compared_count: int = Field(..., ge=0, description="대조한 원본(코퍼스) 건수")
|
compared_count: int = Field(..., ge=0, description="대조한 원본(코퍼스) 건수")
|
||||||
has_suspicion: bool = Field(..., description="표절 의심 구간 존재 여부")
|
has_suspicion: bool = Field(..., description="표절 의심 구간 존재 여부")
|
||||||
ai_suspicion_level: Literal["low", "medium", "high"] = Field(
|
ai_suspicion_level: Literal["low", "medium", "high", "unknown"] = Field(
|
||||||
..., description="AI 생성 의심도(낮음/중간/높음) — 현재 더미"
|
..., description="AI 생성 의심도. unknown이면 학습 모델 없음/채점 불가"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -162,6 +200,9 @@ class DetectResponse(BaseModel):
|
|||||||
ccl_basis: str | None = None
|
ccl_basis: str | None = None
|
||||||
review_summary: ReviewSummary | None = None
|
review_summary: ReviewSummary | None = None
|
||||||
ai_generation: AiGenerationSignal | None = None
|
ai_generation: AiGenerationSignal | None = None
|
||||||
|
has_similarity_match: bool | None = None
|
||||||
|
corpus_scope_note: str | None = None
|
||||||
|
legal_risk: LegalRiskSignal | None = None
|
||||||
autobiography_mode: bool = False
|
autobiography_mode: bool = False
|
||||||
candidates_before_filter: int | None = None
|
candidates_before_filter: int | None = None
|
||||||
engine_version: str
|
engine_version: str
|
||||||
@ -221,6 +262,10 @@ class HealthResponse(BaseModel):
|
|||||||
corpus_size: int
|
corpus_size: int
|
||||||
taxonomy_version: str | None = None
|
taxonomy_version: str | None = None
|
||||||
autobiography_mode: bool = False
|
autobiography_mode: bool = False
|
||||||
|
corpus_documents: int | None = None
|
||||||
|
index_backend: str | None = None
|
||||||
|
ai_model_ready: bool | None = None
|
||||||
|
precedent_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaxonomyResponse(BaseModel):
|
class TaxonomyResponse(BaseModel):
|
||||||
|
|||||||
@ -13,11 +13,23 @@ class Settings(BaseSettings):
|
|||||||
log_level: str = "info" # debug / info / warning / error
|
log_level: str = "info" # debug / info / warning / error
|
||||||
reload: bool = False # 개발용 자동 재시작
|
reload: bool = False # 개발용 자동 재시작
|
||||||
root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism)
|
root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism)
|
||||||
|
api_key: str = "" # 설정 시 /v1/health 외 X-API-Key 필수
|
||||||
|
|
||||||
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
|
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
|
||||||
reference_corpus_dir: str = "./data/reference"
|
reference_corpus_dir: str = "./data/reference"
|
||||||
taxonomy_dir: str = "./data/taxonomy"
|
taxonomy_dir: str = "./data/taxonomy"
|
||||||
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt"
|
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt"
|
||||||
|
corpus_db_path: str = "./data/runtime/corpus.sqlite3"
|
||||||
|
persistent_index_dir: str = "./data/runtime/index"
|
||||||
|
use_persistent_index: bool = False
|
||||||
|
persistent_similarity_threshold: float = 0.65
|
||||||
|
persistent_min_exact_span: int = 80
|
||||||
|
persistent_min_coverage: float = 0.30
|
||||||
|
persistent_rerank_top_k: int = 20
|
||||||
|
precedents_path: str = "./data/precedents/precedents.jsonl"
|
||||||
|
ai_detector_model_path: str = "./data/models/ai_detector.joblib"
|
||||||
|
ai_detector_allow_heuristic: bool = False
|
||||||
|
ai_detector_use_pos: bool = True
|
||||||
|
|
||||||
# PDF VII-4 권장: 정밀도 우선 보수적 임계값
|
# PDF VII-4 권장: 정밀도 우선 보수적 임계값
|
||||||
similarity_threshold: float = 0.85
|
similarity_threshold: float = 0.85
|
||||||
@ -61,6 +73,18 @@ class Settings(BaseSettings):
|
|||||||
def taxonomy_path(self) -> Path:
|
def taxonomy_path(self) -> Path:
|
||||||
return Path(self.taxonomy_dir).resolve()
|
return Path(self.taxonomy_dir).resolve()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def corpus_db(self) -> Path:
|
||||||
|
return Path(self.corpus_db_path).resolve()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def persistent_index_path(self) -> Path:
|
||||||
|
return Path(self.persistent_index_dir).resolve()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def precedent_path(self) -> Path:
|
||||||
|
return Path(self.precedents_path).resolve()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_openai(self) -> bool:
|
def has_openai(self) -> bool:
|
||||||
return bool(self.openai_api_key.strip())
|
return bool(self.openai_api_key.strip())
|
||||||
|
|||||||
1065
app/engine/ai_detector.py
Normal file
1065
app/engine/ai_detector.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -25,6 +25,7 @@ from app.api.schemas import (
|
|||||||
DocumentMetadata,
|
DocumentMetadata,
|
||||||
InfringementTag,
|
InfringementTag,
|
||||||
InfringementType,
|
InfringementType,
|
||||||
|
LegalRiskSignal,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
PartialPlagiarismSignal,
|
PartialPlagiarismSignal,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
@ -32,10 +33,15 @@ from app.api.schemas import (
|
|||||||
)
|
)
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.engine.autobiography_filter import preprocess_for_autobiography
|
from app.engine.autobiography_filter import preprocess_for_autobiography
|
||||||
|
from app.engine.ai_detector import get_ai_detector
|
||||||
from app.engine.clustering import ClusterIndex
|
from app.engine.clustering import ClusterIndex
|
||||||
from app.engine.corpus import load_corpus
|
from app.engine.corpus import load_corpus
|
||||||
from app.engine.extractor import Extractor, get_extractor
|
from app.engine.extractor import Extractor, get_extractor
|
||||||
from app.engine.lsh_filter import LshIndex
|
from app.engine.lsh_filter import LshIndex
|
||||||
|
from app.engine.legal_risk import LegalRiskEngine, load_precedents
|
||||||
|
from app.engine.persistent_index import PersistentCorpusIndex, PersistentHit
|
||||||
|
from app.engine.provenance import DocumentRecord, SegmentRecord, stable_id
|
||||||
|
from app.engine.source_extraction import chunk_with_offsets
|
||||||
from app.engine.similarity import (
|
from app.engine.similarity import (
|
||||||
DualSimilarityIndex,
|
DualSimilarityIndex,
|
||||||
SimilarityHit,
|
SimilarityHit,
|
||||||
@ -52,6 +58,34 @@ class PlagiarismDetector:
|
|||||||
self.settings = settings or get_settings()
|
self.settings = settings or get_settings()
|
||||||
self._extractor: Extractor = extractor or get_extractor(self.settings)
|
self._extractor: Extractor = extractor or get_extractor(self.settings)
|
||||||
self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path)
|
self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path)
|
||||||
|
self._legal_engine = LegalRiskEngine(load_precedents(self.settings.precedent_path))
|
||||||
|
self._ai_detector = get_ai_detector(
|
||||||
|
self.settings.ai_detector_model_path,
|
||||||
|
allow_heuristic=self.settings.ai_detector_allow_heuristic,
|
||||||
|
use_pos=self.settings.ai_detector_use_pos,
|
||||||
|
)
|
||||||
|
self._persistent: PersistentCorpusIndex | None = None
|
||||||
|
|
||||||
|
if self.settings.use_persistent_index:
|
||||||
|
candidate = PersistentCorpusIndex(
|
||||||
|
self.settings.corpus_db, self.settings.persistent_index_path,
|
||||||
|
)
|
||||||
|
if candidate.ready:
|
||||||
|
self._persistent = candidate.load()
|
||||||
|
self._corpus = []
|
||||||
|
self._corpus_preprocessed_texts = []
|
||||||
|
self._corpus_elements = []
|
||||||
|
self._corpus_lemmas = []
|
||||||
|
self._index = None
|
||||||
|
self._lsh = None
|
||||||
|
self._cluster = None
|
||||||
|
self._docs_by_id = {}
|
||||||
|
logger.info("Loaded persistent index: %d segments", self._persistent.size)
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"USE_PERSISTENT_INDEX=true but index is absent; falling back to text corpus: %s",
|
||||||
|
self.settings.persistent_index_path,
|
||||||
|
)
|
||||||
|
|
||||||
self._corpus = load_corpus(self.settings.corpus_path)
|
self._corpus = load_corpus(self.settings.corpus_path)
|
||||||
|
|
||||||
@ -106,17 +140,47 @@ class PlagiarismDetector:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def corpus_size(self) -> int:
|
def corpus_size(self) -> int:
|
||||||
|
return self._persistent.size if self._persistent else len(self._corpus)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def corpus_document_count(self) -> int:
|
||||||
|
if self._persistent:
|
||||||
|
return self._persistent.document_count
|
||||||
return len(self._corpus)
|
return len(self._corpus)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index_backend(self) -> str:
|
||||||
|
return "persistent-hashing-char-3-4" if self._persistent else "legacy-in-memory"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def precedent_count(self) -> int:
|
||||||
|
return len(self._legal_engine.precedents)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ai_model_ready(self) -> bool:
|
||||||
|
return self._ai_detector.mode == "trained"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uses_persistent_index(self) -> bool:
|
||||||
|
return self._persistent is not None
|
||||||
|
|
||||||
|
def list_persistent_documents(self) -> list[dict]:
|
||||||
|
return self._persistent.store.list_documents() if self._persistent else []
|
||||||
|
|
||||||
def detect(
|
def detect(
|
||||||
self,
|
self,
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
text: str,
|
text: str,
|
||||||
metadata: DocumentMetadata | None = None,
|
metadata: DocumentMetadata | None = None,
|
||||||
options: DetectOptions | None = None,
|
options: DetectOptions | None = None,
|
||||||
|
include_ai_segments: bool = True,
|
||||||
) -> DetectResponse:
|
) -> DetectResponse:
|
||||||
opts = options or DetectOptions()
|
opts = options or DetectOptions()
|
||||||
threshold = opts.threshold if opts.threshold is not None else self.settings.similarity_threshold
|
default_threshold = (
|
||||||
|
self.settings.persistent_similarity_threshold
|
||||||
|
if self._persistent else self.settings.similarity_threshold
|
||||||
|
)
|
||||||
|
threshold = opts.threshold if opts.threshold is not None else default_threshold
|
||||||
|
|
||||||
# 요청 단위 자서전 모드 override
|
# 요청 단위 자서전 모드 override
|
||||||
autobio_mode = (
|
autobio_mode = (
|
||||||
@ -136,37 +200,101 @@ class PlagiarismDetector:
|
|||||||
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
|
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
|
||||||
elements = self._extractor.extract(text)
|
elements = self._extractor.extract(text)
|
||||||
|
|
||||||
# 1차 LSH 필터 (옵션)
|
# 영속 CPU 인덱스: 전량 행렬곱으로 후보를 구하고 상위 후보만 증거 비교한다.
|
||||||
|
persistent_hits: list[PersistentHit] = []
|
||||||
|
if self._persistent:
|
||||||
|
persistent_query_lemmas = extract_lemmas(text)
|
||||||
|
persistent_hits = self._persistent.query(
|
||||||
|
text,
|
||||||
|
top_k=max(opts.top_k, self.settings.persistent_rerank_top_k),
|
||||||
|
)
|
||||||
|
hits = [
|
||||||
|
self._persistent_to_similarity_hit(h, persistent_query_lemmas, elements)
|
||||||
|
for h in persistent_hits
|
||||||
|
]
|
||||||
|
hits.sort(key=lambda h: h.score, reverse=True)
|
||||||
|
candidates_count = len(hits)
|
||||||
|
lsh_jaccards: dict[str, float] = {}
|
||||||
|
else:
|
||||||
|
hits = []
|
||||||
|
candidates_count = None
|
||||||
|
lsh_jaccards = {}
|
||||||
|
|
||||||
|
# 1차 LSH 필터 (레거시 옵션)
|
||||||
candidate_ids: set[str] | None = None
|
candidate_ids: set[str] | None = None
|
||||||
candidates_count: int | None = None
|
if not self._persistent and self._lsh:
|
||||||
lsh_jaccards: dict[str, float] = {}
|
|
||||||
if self._lsh:
|
|
||||||
cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k)
|
cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k)
|
||||||
candidate_ids = {c.doc_id for c in cands}
|
candidate_ids = {c.doc_id for c in cands}
|
||||||
candidates_count = len(cands)
|
candidates_count = len(cands)
|
||||||
lsh_jaccards = {c.doc_id: c.jaccard for c in cands}
|
lsh_jaccards = {c.doc_id: c.jaccard for c in cands}
|
||||||
|
|
||||||
# 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔)
|
# 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔)
|
||||||
hits = self._index.query(query_text, elements, top_k=opts.top_k)
|
if not self._persistent:
|
||||||
if candidate_ids is not None:
|
hits = self._index.query(query_text, elements, top_k=opts.top_k)
|
||||||
hits = [h for h in hits if h.doc_id in candidate_ids]
|
if candidate_ids is not None:
|
||||||
|
hits = [h for h in hits if h.doc_id in candidate_ids]
|
||||||
|
|
||||||
# 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준)
|
# 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준)
|
||||||
query_lemmas = extract_lemmas(query_text) if self._cluster else None
|
query_lemmas = extract_lemmas(query_text) if self._cluster else None
|
||||||
|
|
||||||
matches = [
|
persistent_by_id = {h.segment_id: h for h in persistent_hits}
|
||||||
self._to_match(
|
matches = []
|
||||||
|
for h in hits:
|
||||||
|
provenance_hit = persistent_by_id.get(h.doc_id)
|
||||||
|
exact_partial_match = bool(
|
||||||
|
provenance_hit and (
|
||||||
|
provenance_hit.longest_span >= self.settings.persistent_min_exact_span
|
||||||
|
or provenance_hit.coverage >= self.settings.persistent_min_coverage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (h.score < threshold and not exact_partial_match) or len(matches) >= opts.top_k:
|
||||||
|
continue
|
||||||
|
match = self._to_match(
|
||||||
h, opts.return_evidence, lsh_jaccards.get(h.doc_id),
|
h, opts.return_evidence, lsh_jaccards.get(h.doc_id),
|
||||||
self._partial_signal(h.doc_id, elements, query_lemmas),
|
self._partial_signal(h.doc_id, elements, query_lemmas),
|
||||||
)
|
)
|
||||||
for h in hits if h.score >= threshold
|
if provenance_hit:
|
||||||
]
|
match = self._add_provenance(match, provenance_hit)
|
||||||
|
matches.append(match)
|
||||||
confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0)
|
confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0)
|
||||||
is_infringement = bool(matches)
|
is_infringement = bool(matches) # 후방호환 필드. 법적 확정이 아니라 임계 초과 매칭.
|
||||||
ccl_basis = self._build_ccl_basis(matches) if is_infringement else None
|
ccl_basis = self._build_ccl_basis(matches) if is_infringement else None
|
||||||
|
|
||||||
# 저작권 탭 UI 매핑 (나누구 앱). 독창성=100-유사도, AI 의심도는 현재 스텁.
|
top_coverage = max((m.matched_coverage for m in matches), default=0.0)
|
||||||
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]
|
||||||
|
legal = self._legal_engine.assess(
|
||||||
|
max_similarity=matches[0].similarity if matches else 0.0,
|
||||||
|
coverage=top_coverage,
|
||||||
|
longest_span=top_longest,
|
||||||
|
legal_tags=legal_tags,
|
||||||
|
work_type="literary",
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI 탐지는 전처리 전 raw text에서만 실행한다.
|
||||||
|
ai_result = self._ai_detector.detect(text, with_segments=include_ai_segments)
|
||||||
|
ai_signal = AiGenerationSignal(
|
||||||
|
suspicion_level=ai_result.suspicion_level or "unknown",
|
||||||
|
score=ai_result.score,
|
||||||
|
available=ai_result.available,
|
||||||
|
provenance=ai_result.provenance,
|
||||||
|
is_stub=ai_result.is_stub,
|
||||||
|
model_version=ai_result.model_version,
|
||||||
|
feature_set_version=ai_result.feature_set_version,
|
||||||
|
pos_available=ai_result.pos_available,
|
||||||
|
warnings=ai_result.warnings,
|
||||||
|
segments=[{
|
||||||
|
"index": s.index, "start": s.start, "end": s.end,
|
||||||
|
"char_count": s.char_count, "score": s.score,
|
||||||
|
"suspicion_level": s.suspicion_level, "scored": s.scored,
|
||||||
|
"note": s.note,
|
||||||
|
} for s in ai_result.segments],
|
||||||
|
top_contributions=[
|
||||||
|
{"feature": name, "contribution": round(value, 4)}
|
||||||
|
for name, value in ai_result.top_contributions
|
||||||
|
],
|
||||||
|
note=ai_result.note,
|
||||||
|
)
|
||||||
sim_pct = _calibrate_similarity(confidence, threshold)
|
sim_pct = _calibrate_similarity(confidence, threshold)
|
||||||
review = ReviewSummary(
|
review = ReviewSummary(
|
||||||
originality_percent=100 - sim_pct,
|
originality_percent=100 - sim_pct,
|
||||||
@ -186,6 +314,21 @@ class PlagiarismDetector:
|
|||||||
ccl_basis=ccl_basis,
|
ccl_basis=ccl_basis,
|
||||||
review_summary=review,
|
review_summary=review,
|
||||||
ai_generation=ai_signal,
|
ai_generation=ai_signal,
|
||||||
|
has_similarity_match=bool(matches),
|
||||||
|
corpus_scope_note=(
|
||||||
|
f"현재 등록된 {self.corpus_document_count}개 원천 문서의 "
|
||||||
|
f"{self.corpus_size}개 검색 세그먼트만 대조했습니다. 미매칭은 비침해 확정이 아닙니다."
|
||||||
|
),
|
||||||
|
legal_risk=LegalRiskSignal(
|
||||||
|
status=legal.status,
|
||||||
|
risk_level=legal.risk_level,
|
||||||
|
similarity_evidence=legal.similarity_evidence,
|
||||||
|
protected_expression=legal.protected_expression,
|
||||||
|
access_evidence=legal.access_evidence,
|
||||||
|
missing_factors=list(legal.missing_factors),
|
||||||
|
precedent_ids=list(legal.precedent_ids),
|
||||||
|
disclaimer=legal.disclaimer,
|
||||||
|
),
|
||||||
autobiography_mode=autobio_mode,
|
autobiography_mode=autobio_mode,
|
||||||
candidates_before_filter=candidates_count,
|
candidates_before_filter=candidates_count,
|
||||||
engine_version=self.settings.engine_version,
|
engine_version=self.settings.engine_version,
|
||||||
@ -195,6 +338,87 @@ class PlagiarismDetector:
|
|||||||
def detect_request(self, req: DetectRequest) -> DetectResponse:
|
def detect_request(self, req: DetectRequest) -> DetectResponse:
|
||||||
return self.detect(req.doc_id, req.text, req.metadata, req.options)
|
return self.detect(req.doc_id, req.text, req.metadata, req.options)
|
||||||
|
|
||||||
|
def _persistent_to_similarity_hit(
|
||||||
|
self, hit: PersistentHit, query_lemmas: list[str], query_elements,
|
||||||
|
) -> SimilarityHit:
|
||||||
|
from app.api.schemas import EvidenceSpan
|
||||||
|
from app.engine.similarity import _element_similarities
|
||||||
|
from app.engine.structural import lemma_overlap_ratio
|
||||||
|
|
||||||
|
reference_elements = self._extractor.extract(hit.reference_text)
|
||||||
|
element_sim = _element_similarities(query_elements, reference_elements)
|
||||||
|
lemma_sim = lemma_overlap_ratio(query_lemmas, extract_lemmas(hit.reference_text))
|
||||||
|
s = self.settings
|
||||||
|
combined = (
|
||||||
|
s.weight_text_sim * hit.score
|
||||||
|
+ s.weight_lemma_sim * lemma_sim
|
||||||
|
+ s.weight_char_sim * element_sim["characters"]
|
||||||
|
+ s.weight_motif_sim * element_sim["motifs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return SimilarityHit(
|
||||||
|
doc_id=hit.segment_id,
|
||||||
|
title=hit.title,
|
||||||
|
score=combined,
|
||||||
|
text_sim=hit.score,
|
||||||
|
lemma_sim=lemma_sim,
|
||||||
|
element_sim=element_sim,
|
||||||
|
evidence=[EvidenceSpan(**span) for span in hit.evidence],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_provenance(match: MatchResult, hit: PersistentHit) -> MatchResult:
|
||||||
|
return match.model_copy(update={
|
||||||
|
"source_document_id": hit.document_id,
|
||||||
|
"source_segment_id": hit.segment_id,
|
||||||
|
"source_locator": hit.source_locator,
|
||||||
|
"coordinate_scope": hit.coordinate_scope,
|
||||||
|
"page_number": hit.page_number,
|
||||||
|
"paragraph_number": hit.paragraph_number,
|
||||||
|
"source_char_start": hit.source_char_start,
|
||||||
|
"source_char_end": hit.source_char_end,
|
||||||
|
"matched_coverage": round(hit.coverage, 4),
|
||||||
|
"longest_span": hit.longest_span,
|
||||||
|
})
|
||||||
|
|
||||||
|
def add_persistent_document(self, doc_id: str | None, title: str, text: str) -> str:
|
||||||
|
if not self._persistent:
|
||||||
|
raise RuntimeError("persistent index is not enabled")
|
||||||
|
document_id = (doc_id or stable_id("doc", title)).strip()
|
||||||
|
self._persistent.store.upsert_document(DocumentRecord(document_id=document_id, title=title))
|
||||||
|
clean_text = text.strip()
|
||||||
|
segments = [SegmentRecord(
|
||||||
|
segment_id=stable_id("seg", document_id, str(start), chunk),
|
||||||
|
document_id=document_id, text=chunk, ordinal=str(i),
|
||||||
|
coordinate_scope="document", char_start=start, char_end=end,
|
||||||
|
source_locator=f"api://corpus/{document_id}#chars={start}-{end}",
|
||||||
|
metadata={"provenance_quality": "api_document"},
|
||||||
|
) for i, (start, end, chunk) in enumerate(
|
||||||
|
chunk_with_offsets(clean_text, size=1000, stride=500), 1
|
||||||
|
)]
|
||||||
|
inserted, duplicates = self._persistent.store.add_segments(segments)
|
||||||
|
if duplicates and not inserted:
|
||||||
|
raise FileExistsError(f"doc_id '{document_id}' 또는 동일 본문이 이미 존재합니다")
|
||||||
|
self._replace_persistent_index()
|
||||||
|
return document_id
|
||||||
|
|
||||||
|
def delete_persistent_document(self, doc_id: str) -> bool:
|
||||||
|
if not self._persistent:
|
||||||
|
raise RuntimeError("persistent index is not enabled")
|
||||||
|
deleted = self._persistent.store.delete_document(doc_id)
|
||||||
|
if deleted:
|
||||||
|
self._replace_persistent_index()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _replace_persistent_index(self) -> None:
|
||||||
|
"""새 객체를 완성한 뒤 포인터를 한 번에 교체해 동시 질의 정합성을 보장."""
|
||||||
|
replacement = PersistentCorpusIndex(
|
||||||
|
self.settings.corpus_db, self.settings.persistent_index_path,
|
||||||
|
)
|
||||||
|
replacement.sync()
|
||||||
|
replacement.load()
|
||||||
|
self._persistent = replacement
|
||||||
|
|
||||||
def _partial_signal(self, doc_id, query_elements, query_lemmas) -> PartialPlagiarismSignal | None:
|
def _partial_signal(self, doc_id, query_elements, query_lemmas) -> PartialPlagiarismSignal | None:
|
||||||
"""군집화 기반 요소별 부분 표절 분해 (옵션)."""
|
"""군집화 기반 요소별 부분 표절 분해 (옵션)."""
|
||||||
if not self._cluster:
|
if not self._cluster:
|
||||||
@ -308,8 +532,9 @@ class PlagiarismDetector:
|
|||||||
tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요"
|
tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요"
|
||||||
case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else ""
|
case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else ""
|
||||||
return (
|
return (
|
||||||
f"'{top.source_title}'와 결합 유사도 {top.similarity:.2%}로 매칭. "
|
f"'{top.source_title}'와 검색 유사도 {top.similarity:.2%}로 후보 매칭. "
|
||||||
f"주 침해 태그: {tag_summary}.{case_part}{breakdown}"
|
f"검토 가설 태그: {tag_summary}.{case_part}{breakdown} "
|
||||||
|
"법적 침해 여부는 보호되는 표현·의거관계·권리관계를 별도 검토해야 합니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -330,20 +555,6 @@ def _calibrate_similarity(raw: float, threshold: float) -> int:
|
|||||||
return max(0, min(100, round(disp)))
|
return max(0, min(100, round(disp)))
|
||||||
|
|
||||||
|
|
||||||
def _dummy_ai_generation_signal(text: str) -> AiGenerationSignal:
|
|
||||||
"""AI 생성 의심도 스텁 — 실제 판별이 아니라 텍스트 기반 결정적 더미 값.
|
|
||||||
|
|
||||||
바이칼 UI(낮음/중간/높음 배지) 연동용 계약 확정 목적. 정식 구현(워터마킹+
|
|
||||||
언어특징 분류) 전까지 is_stub=True 로 반환한다.
|
|
||||||
"""
|
|
||||||
h = sum(ord(c) for c in text[:300]) % 100
|
|
||||||
if h < 70:
|
|
||||||
return AiGenerationSignal(suspicion_level="low", score=round(0.05 + h / 500, 3))
|
|
||||||
if h < 90:
|
|
||||||
return AiGenerationSignal(suspicion_level="medium", score=round(0.45 + (h - 70) / 200, 3))
|
|
||||||
return AiGenerationSignal(suspicion_level="high", score=round(0.72 + (h - 90) / 200, 3))
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_legacy(hit: SimilarityHit) -> InfringementType:
|
def _classify_legacy(hit: SimilarityHit) -> InfringementType:
|
||||||
"""후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용)."""
|
"""후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용)."""
|
||||||
elem = hit.element_sim
|
elem = hit.element_sim
|
||||||
|
|||||||
133
app/engine/legal_risk.py
Normal file
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),
|
||||||
|
)
|
||||||
267
app/engine/persistent_index.py
Normal file
267
app/engine/persistent_index.py
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
"""CPU 친화적인 영속 후보 검색 인덱스.
|
||||||
|
|
||||||
|
HashingVectorizer를 사용해 학습 vocabulary를 메모리에 들고 있지 않으며, sparse
|
||||||
|
행렬을 디스크에 저장한다. 기존 ID가 모두 유지된 경우 새 세그먼트만 transform하여
|
||||||
|
append하므로 문서 한 건 추가 시 전체 임베딩/인덱스를 재계산하지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.engine.provenance import CorpusStore, SegmentRecord
|
||||||
|
|
||||||
|
|
||||||
|
INDEX_VERSION = 1
|
||||||
|
VECTORIZER_CONFIG = {
|
||||||
|
"analyzer": "char_wb",
|
||||||
|
"ngram_range": [3, 4],
|
||||||
|
"alternate_sign": False,
|
||||||
|
"lowercase": False,
|
||||||
|
"norm": "l2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PersistentHit:
|
||||||
|
segment_id: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
score: float
|
||||||
|
evidence: list[dict]
|
||||||
|
coverage: float
|
||||||
|
longest_span: int
|
||||||
|
source_locator: str | None
|
||||||
|
coordinate_scope: str
|
||||||
|
page_number: int | None
|
||||||
|
paragraph_number: int | None
|
||||||
|
source_char_start: int | None
|
||||||
|
source_char_end: int | None
|
||||||
|
reference_text: str
|
||||||
|
|
||||||
|
|
||||||
|
def _vectorizer(n_features: int, config: dict | None = None):
|
||||||
|
from sklearn.feature_extraction.text import HashingVectorizer
|
||||||
|
|
||||||
|
cfg = config or VECTORIZER_CONFIG
|
||||||
|
return HashingVectorizer(
|
||||||
|
analyzer=cfg["analyzer"],
|
||||||
|
ngram_range=tuple(cfg["ngram_range"]),
|
||||||
|
n_features=n_features,
|
||||||
|
alternate_sign=cfg["alternate_sign"],
|
||||||
|
lowercase=cfg["lowercase"],
|
||||||
|
norm=cfg["norm"],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_spans(query: str, reference: str, min_match: int = 12, limit: int = 10) -> tuple[list[dict], float, int]:
|
||||||
|
"""원문 query 좌표의 공통 연속 구간과 coverage를 반환."""
|
||||||
|
if not query or not reference:
|
||||||
|
return [], 0.0, 0
|
||||||
|
blocks = SequenceMatcher(None, query, reference, autojunk=False).get_matching_blocks()
|
||||||
|
useful = [b for b in blocks if b.size >= min_match]
|
||||||
|
useful.sort(key=lambda b: (-b.size, b.a))
|
||||||
|
selected = sorted(useful[:limit], key=lambda b: b.a)
|
||||||
|
spans = [
|
||||||
|
{
|
||||||
|
"start": b.a,
|
||||||
|
"end": b.a + b.size,
|
||||||
|
"source_start": b.b,
|
||||||
|
"source_end": b.b + b.size,
|
||||||
|
"matched": query[b.a : b.a + b.size],
|
||||||
|
}
|
||||||
|
for b in selected
|
||||||
|
]
|
||||||
|
covered: set[int] = set()
|
||||||
|
for b in useful:
|
||||||
|
covered.update(range(b.a, b.a + b.size))
|
||||||
|
return spans, len(covered) / max(1, len(query)), max((b.size for b in useful), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PersistentCorpusIndex:
|
||||||
|
MATRIX_FILE = "lexical.npz"
|
||||||
|
META_FILE = "index.json"
|
||||||
|
|
||||||
|
def __init__(self, store_path: str | Path, index_dir: str | Path):
|
||||||
|
self.store = CorpusStore(store_path)
|
||||||
|
self.index_dir = Path(index_dir)
|
||||||
|
self._matrix = None
|
||||||
|
self._meta: dict = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
meta_path = self.index_dir / self.META_FILE
|
||||||
|
if not meta_path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
|
return self._matrix_path(meta).exists()
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return len(self._meta.get("segment_ids", []))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def document_count(self) -> int:
|
||||||
|
if "document_count" in self._meta:
|
||||||
|
return int(self._meta["document_count"])
|
||||||
|
return self.store.document_count()
|
||||||
|
|
||||||
|
def load(self) -> "PersistentCorpusIndex":
|
||||||
|
if not self.ready:
|
||||||
|
raise FileNotFoundError(f"영속 인덱스가 없습니다: {self.index_dir}")
|
||||||
|
from scipy.sparse import load_npz
|
||||||
|
|
||||||
|
self._meta = json.loads((self.index_dir / self.META_FILE).read_text(encoding="utf-8"))
|
||||||
|
if self._meta.get("version") != INDEX_VERSION:
|
||||||
|
raise ValueError("지원하지 않는 영속 인덱스 버전")
|
||||||
|
if self._meta.get("vectorizer_config") != VECTORIZER_CONFIG:
|
||||||
|
raise ValueError("인덱스 vectorizer 설정이 현재 코드와 달라 재빌드가 필요합니다")
|
||||||
|
self._matrix = load_npz(self._matrix_path(self._meta)).tocsr()
|
||||||
|
if self._matrix.shape[0] != len(self._meta["segment_ids"]):
|
||||||
|
raise ValueError("인덱스 행과 segment_ids 개수가 다릅니다")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _matrix_path(self, meta: dict) -> Path:
|
||||||
|
return self.index_dir / str(meta.get("matrix_file") or self.MATRIX_FILE)
|
||||||
|
|
||||||
|
def sync(self, n_features: int = 2**20) -> dict:
|
||||||
|
"""DB와 동기화. 추가만 있으면 append, 삭제/변경이면 안전하게 rebuild."""
|
||||||
|
from scipy.sparse import load_npz, save_npz, vstack
|
||||||
|
|
||||||
|
self.index_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
segments = list(self.store.iter_segments())
|
||||||
|
current = {s.segment_id: s.text_sha256 for s in segments}
|
||||||
|
mode = "rebuild"
|
||||||
|
existing_ids: list[str] = []
|
||||||
|
matrix = None
|
||||||
|
if self.ready:
|
||||||
|
old = json.loads((self.index_dir / self.META_FILE).read_text(encoding="utf-8"))
|
||||||
|
old_ids = old.get("segment_ids", [])
|
||||||
|
old_hashes = old.get("text_hashes", {})
|
||||||
|
if (
|
||||||
|
old.get("vectorizer_config") == VECTORIZER_CONFIG
|
||||||
|
and int(old.get("n_features", 0)) == n_features
|
||||||
|
and all(i in current and current[i] == old_hashes.get(i) for i in old_ids)
|
||||||
|
):
|
||||||
|
existing_ids = old_ids
|
||||||
|
matrix = load_npz(self._matrix_path(old)).tocsr()
|
||||||
|
mode = "append"
|
||||||
|
|
||||||
|
by_id = {s.segment_id: s for s in segments}
|
||||||
|
existing_set = set(existing_ids)
|
||||||
|
new_ids = [s.segment_id for s in segments if s.segment_id not in existing_set]
|
||||||
|
vectorizer = _vectorizer(n_features)
|
||||||
|
if matrix is None:
|
||||||
|
existing_ids = []
|
||||||
|
new_ids = [s.segment_id for s in segments]
|
||||||
|
matrix = vectorizer.transform([by_id[i].text for i in new_ids]).tocsr()
|
||||||
|
elif new_ids:
|
||||||
|
delta = vectorizer.transform([by_id[i].text for i in new_ids]).tocsr()
|
||||||
|
matrix = vstack([matrix, delta], format="csr")
|
||||||
|
ids = existing_ids + new_ids
|
||||||
|
generation_payload = json.dumps(
|
||||||
|
{"ids": ids, "hashes": {i: current[i] for i in ids},
|
||||||
|
"config": VECTORIZER_CONFIG, "features": n_features},
|
||||||
|
sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
generation = hashlib.sha256(generation_payload).hexdigest()[:16]
|
||||||
|
matrix_file = f"lexical-{generation}.npz"
|
||||||
|
meta = {
|
||||||
|
"version": INDEX_VERSION,
|
||||||
|
"backend": "hashing-char-3-4",
|
||||||
|
"n_features": n_features,
|
||||||
|
"vectorizer_config": VECTORIZER_CONFIG,
|
||||||
|
"segment_ids": ids,
|
||||||
|
"text_hashes": {i: current[i] for i in ids},
|
||||||
|
"document_count": self.store.document_count(),
|
||||||
|
"matrix_file": matrix_file,
|
||||||
|
}
|
||||||
|
matrix_tmp = self.index_dir / f"{matrix_file}.tmp.npz"
|
||||||
|
save_npz(matrix_tmp, matrix, compressed=False)
|
||||||
|
matrix_tmp.replace(self.index_dir / matrix_file)
|
||||||
|
tmp = self.index_dir / f"{self.META_FILE}.tmp"
|
||||||
|
tmp.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(self.index_dir / self.META_FILE)
|
||||||
|
self._matrix, self._meta = matrix, meta
|
||||||
|
return {"mode": mode, "total": len(ids), "added": len(new_ids)}
|
||||||
|
|
||||||
|
def query(self, text: str, top_k: int = 50, min_score: float = 0.0) -> list[PersistentHit]:
|
||||||
|
if self._matrix is None:
|
||||||
|
self.load()
|
||||||
|
if not text.strip() or self._matrix is None or self._matrix.shape[0] == 0:
|
||||||
|
return []
|
||||||
|
vectorizer = _vectorizer(
|
||||||
|
int(self._meta["n_features"]), self._meta["vectorizer_config"]
|
||||||
|
)
|
||||||
|
# 긴 원고 앞부분만 보거나 전체 벡터에 부분 복사가 희석되지 않도록 query도
|
||||||
|
# 1,200자/600 stride로 나누고 각 원문 세그먼트의 최대 점수를 사용한다.
|
||||||
|
query_chunks: list[tuple[int, str]] = []
|
||||||
|
for start in range(0, len(text), 600):
|
||||||
|
chunk = text[start : start + 1200]
|
||||||
|
if chunk.strip():
|
||||||
|
query_chunks.append((start, chunk))
|
||||||
|
if start + 1200 >= len(text):
|
||||||
|
break
|
||||||
|
scores = np.zeros(self._matrix.shape[0], dtype=np.float32)
|
||||||
|
best_chunk = np.zeros(self._matrix.shape[0], dtype=np.int32)
|
||||||
|
for batch_start in range(0, len(query_chunks), 32):
|
||||||
|
batch = query_chunks[batch_start : batch_start + 32]
|
||||||
|
queries = vectorizer.transform([chunk for _, chunk in batch])
|
||||||
|
block = (self._matrix @ queries.T).toarray()
|
||||||
|
local_argmax = block.argmax(axis=1)
|
||||||
|
local_scores = block[np.arange(block.shape[0]), local_argmax]
|
||||||
|
improved = local_scores > scores
|
||||||
|
scores[improved] = local_scores[improved]
|
||||||
|
best_chunk[improved] = batch_start + local_argmax[improved]
|
||||||
|
k = min(max(1, top_k), len(scores))
|
||||||
|
indexes = np.argpartition(scores, -k)[-k:]
|
||||||
|
indexes = indexes[np.argsort(scores[indexes])[::-1]]
|
||||||
|
ids = [self._meta["segment_ids"][int(i)] for i in indexes if scores[int(i)] >= min_score]
|
||||||
|
records = self.store.get_segments(ids)
|
||||||
|
hits: list[PersistentHit] = []
|
||||||
|
for i in indexes:
|
||||||
|
score = float(scores[int(i)])
|
||||||
|
if score < min_score:
|
||||||
|
continue
|
||||||
|
segment_id = self._meta["segment_ids"][int(i)]
|
||||||
|
record = records.get(segment_id)
|
||||||
|
if not record:
|
||||||
|
continue
|
||||||
|
chunk_start, chunk_text = query_chunks[int(best_chunk[int(i)])]
|
||||||
|
evidence, _, longest = _evidence_spans(chunk_text, record.text)
|
||||||
|
for span in evidence:
|
||||||
|
span["start"] += chunk_start
|
||||||
|
span["end"] += chunk_start
|
||||||
|
covered = sum(span["end"] - span["start"] for span in evidence)
|
||||||
|
coverage = min(1.0, covered / max(1, len(text)))
|
||||||
|
hits.append(self._to_hit(record, score, evidence, coverage, longest))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_hit(record: SegmentRecord, score: float, evidence: list[dict], coverage: float, longest: int) -> PersistentHit:
|
||||||
|
return PersistentHit(
|
||||||
|
segment_id=record.segment_id,
|
||||||
|
document_id=record.document_id,
|
||||||
|
title=str(record.metadata.get("document_title") or record.document_id),
|
||||||
|
score=max(0.0, min(1.0, score)),
|
||||||
|
evidence=evidence,
|
||||||
|
coverage=coverage,
|
||||||
|
longest_span=longest,
|
||||||
|
source_locator=record.source_locator,
|
||||||
|
coordinate_scope=record.coordinate_scope,
|
||||||
|
page_number=record.page_number,
|
||||||
|
paragraph_number=record.paragraph_number,
|
||||||
|
source_char_start=record.char_start,
|
||||||
|
source_char_end=record.char_end,
|
||||||
|
reference_text=record.text,
|
||||||
|
)
|
||||||
268
app/engine/provenance.py
Normal file
268
app/engine/provenance.py
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
"""원문 위치를 보존하는 SQLite 코퍼스 저장소.
|
||||||
|
|
||||||
|
검색용 전처리 텍스트와 증거 표시용 원문을 분리한다. 현재 수령한 XLSX처럼
|
||||||
|
페이지 정보가 없는 데이터도 ``coordinate_scope=episode`` 로 정직하게 기록하며,
|
||||||
|
향후 PDF/DOCX 재추출 시 동일 스키마에 page/paragraph/char offset을 채울 수 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def text_sha256(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def stable_id(prefix: str, *parts: str) -> str:
|
||||||
|
payload = "\x1f".join(parts).encode("utf-8")
|
||||||
|
return f"{prefix}-{hashlib.sha256(payload).hexdigest()[:20]}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DocumentRecord:
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
source_path: str | None = None
|
||||||
|
source_sha256: str | None = None
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SegmentRecord:
|
||||||
|
segment_id: str
|
||||||
|
document_id: str
|
||||||
|
text: str
|
||||||
|
ordinal: str
|
||||||
|
coordinate_scope: str = "episode"
|
||||||
|
page_number: int | None = None
|
||||||
|
paragraph_number: int | None = None
|
||||||
|
char_start: int | None = None
|
||||||
|
char_end: int | None = None
|
||||||
|
source_locator: str | None = None
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text_sha256(self) -> str:
|
||||||
|
return text_sha256(self.text)
|
||||||
|
|
||||||
|
|
||||||
|
class CorpusStore:
|
||||||
|
"""SQLite 기반 원문/세그먼트 저장소.
|
||||||
|
|
||||||
|
연결은 호출 단위로 열어 멀티프로세스 API에서도 안전하게 사용한다. WAL은
|
||||||
|
읽기 중 증분 적재를 허용한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
con = sqlite3.connect(self.path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
con.execute("PRAGMA journal_mode=WAL")
|
||||||
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return con
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
with self._connect() as con:
|
||||||
|
con.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS corpus_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
document_id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_path TEXT,
|
||||||
|
source_sha256 TEXT,
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS segments (
|
||||||
|
segment_id TEXT PRIMARY KEY,
|
||||||
|
document_id TEXT NOT NULL REFERENCES documents(document_id) ON DELETE CASCADE,
|
||||||
|
ordinal TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
text_sha256 TEXT NOT NULL,
|
||||||
|
coordinate_scope TEXT NOT NULL,
|
||||||
|
page_number INTEGER,
|
||||||
|
paragraph_number INTEGER,
|
||||||
|
char_start INTEGER,
|
||||||
|
char_end INTEGER,
|
||||||
|
source_locator TEXT,
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(document_id, text_sha256)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_segments_document ON segments(document_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_segments_hash ON segments(text_sha256);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"INSERT OR REPLACE INTO corpus_meta(key, value) VALUES('schema_version', ?)",
|
||||||
|
(str(SCHEMA_VERSION),),
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_document(self, record: DocumentRecord) -> None:
|
||||||
|
self.upsert_documents([record])
|
||||||
|
|
||||||
|
def upsert_documents(self, records: Iterable[DocumentRecord]) -> None:
|
||||||
|
self.initialize()
|
||||||
|
with self._connect() as con:
|
||||||
|
con.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO documents(document_id,title,source_path,source_sha256,metadata_json)
|
||||||
|
VALUES(?,?,?,?,?)
|
||||||
|
ON CONFLICT(document_id) DO UPDATE SET
|
||||||
|
title=excluded.title,
|
||||||
|
source_path=COALESCE(excluded.source_path, documents.source_path),
|
||||||
|
source_sha256=COALESCE(excluded.source_sha256, documents.source_sha256),
|
||||||
|
metadata_json=excluded.metadata_json,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
[(
|
||||||
|
record.document_id, record.title, record.source_path,
|
||||||
|
record.source_sha256,
|
||||||
|
json.dumps(record.metadata, ensure_ascii=False, sort_keys=True),
|
||||||
|
) for record in records],
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_segments(self, records: Iterable[SegmentRecord]) -> tuple[int, int]:
|
||||||
|
"""세그먼트를 추가하고 (inserted, duplicates) 반환."""
|
||||||
|
self.initialize()
|
||||||
|
inserted = duplicates = 0
|
||||||
|
with self._connect() as con:
|
||||||
|
for r in records:
|
||||||
|
before = con.total_changes
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO segments(
|
||||||
|
segment_id,document_id,ordinal,text,text_sha256,coordinate_scope,
|
||||||
|
page_number,paragraph_number,char_start,char_end,source_locator,metadata_json
|
||||||
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
r.segment_id,
|
||||||
|
r.document_id,
|
||||||
|
r.ordinal,
|
||||||
|
r.text,
|
||||||
|
r.text_sha256,
|
||||||
|
r.coordinate_scope,
|
||||||
|
r.page_number,
|
||||||
|
r.paragraph_number,
|
||||||
|
r.char_start,
|
||||||
|
r.char_end,
|
||||||
|
r.source_locator,
|
||||||
|
json.dumps(r.metadata, ensure_ascii=False, sort_keys=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if con.total_changes > before:
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
duplicates += 1
|
||||||
|
return inserted, duplicates
|
||||||
|
|
||||||
|
def iter_segments(self) -> Iterator[SegmentRecord]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return
|
||||||
|
with self._connect() as con:
|
||||||
|
rows = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.*, d.title
|
||||||
|
FROM segments s JOIN documents d USING(document_id)
|
||||||
|
ORDER BY s.document_id, s.ordinal, s.segment_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
metadata = json.loads(row["metadata_json"] or "{}")
|
||||||
|
metadata.setdefault("document_title", row["title"])
|
||||||
|
yield SegmentRecord(
|
||||||
|
segment_id=row["segment_id"],
|
||||||
|
document_id=row["document_id"],
|
||||||
|
text=row["text"],
|
||||||
|
ordinal=row["ordinal"],
|
||||||
|
coordinate_scope=row["coordinate_scope"],
|
||||||
|
page_number=row["page_number"],
|
||||||
|
paragraph_number=row["paragraph_number"],
|
||||||
|
char_start=row["char_start"],
|
||||||
|
char_end=row["char_end"],
|
||||||
|
source_locator=row["source_locator"],
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_segments(self, segment_ids: Iterable[str]) -> dict[str, SegmentRecord]:
|
||||||
|
ids = list(dict.fromkeys(segment_ids))
|
||||||
|
if not ids or not self.path.exists():
|
||||||
|
return {}
|
||||||
|
result: dict[str, SegmentRecord] = {}
|
||||||
|
with self._connect() as con:
|
||||||
|
for start in range(0, len(ids), 500):
|
||||||
|
batch = ids[start : start + 500]
|
||||||
|
marks = ",".join("?" for _ in batch)
|
||||||
|
rows = con.execute(
|
||||||
|
f"""SELECT s.*, d.title FROM segments s
|
||||||
|
JOIN documents d USING(document_id)
|
||||||
|
WHERE segment_id IN ({marks})""",
|
||||||
|
batch,
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
metadata = json.loads(row["metadata_json"] or "{}")
|
||||||
|
metadata.setdefault("document_title", row["title"])
|
||||||
|
result[row["segment_id"]] = SegmentRecord(
|
||||||
|
segment_id=row["segment_id"], document_id=row["document_id"],
|
||||||
|
text=row["text"], ordinal=row["ordinal"],
|
||||||
|
coordinate_scope=row["coordinate_scope"],
|
||||||
|
page_number=row["page_number"], paragraph_number=row["paragraph_number"],
|
||||||
|
char_start=row["char_start"], char_end=row["char_end"],
|
||||||
|
source_locator=row["source_locator"], metadata=metadata,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def stats(self) -> dict[str, int]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {"documents": 0, "segments": 0, "characters": 0}
|
||||||
|
with self._connect() as con:
|
||||||
|
documents = con.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
|
||||||
|
segments, characters = con.execute(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(LENGTH(text)),0) FROM segments"
|
||||||
|
).fetchone()
|
||||||
|
return {"documents": documents, "segments": segments, "characters": characters}
|
||||||
|
|
||||||
|
def document_count(self) -> int:
|
||||||
|
if not self.path.exists():
|
||||||
|
return 0
|
||||||
|
with self._connect() as con:
|
||||||
|
return int(con.execute("SELECT COUNT(*) FROM documents").fetchone()[0])
|
||||||
|
|
||||||
|
def list_documents(self) -> list[dict]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return []
|
||||||
|
with self._connect() as con:
|
||||||
|
rows = con.execute(
|
||||||
|
"""SELECT d.document_id, d.title, d.source_path,
|
||||||
|
COUNT(s.segment_id) AS segment_count,
|
||||||
|
COALESCE(SUM(LENGTH(s.text)),0) AS characters
|
||||||
|
FROM documents d LEFT JOIN segments s USING(document_id)
|
||||||
|
GROUP BY d.document_id ORDER BY d.title"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_document(self, document_id: str) -> bool:
|
||||||
|
if not self.path.exists():
|
||||||
|
return False
|
||||||
|
with self._connect() as con:
|
||||||
|
before = con.total_changes
|
||||||
|
con.execute("DELETE FROM documents WHERE document_id=?", (document_id,))
|
||||||
|
return con.total_changes > before
|
||||||
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}")
|
||||||
20
app/main.py
20
app/main.py
@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import hmac
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
@ -19,6 +22,11 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
if not settings.api_key.strip():
|
||||||
|
logging.critical(
|
||||||
|
"API_KEY is empty: all /v1 endpoints are unauthenticated. "
|
||||||
|
"Do not expose unpublished manuscripts publicly until client key rollout is complete."
|
||||||
|
)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.detector = PlagiarismDetector(settings=settings)
|
app.state.detector = PlagiarismDetector(settings=settings)
|
||||||
app.state.job_store = JobStore()
|
app.state.job_store = JobStore()
|
||||||
@ -55,6 +63,18 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def optional_api_key_auth(request: Request, call_next):
|
||||||
|
"""API_KEY가 설정된 운영 환경에서만 API 인증을 강제한다."""
|
||||||
|
configured = _settings.api_key.strip()
|
||||||
|
public_paths = {"/", "/v1/health", "/docs", "/openapi.json", "/redoc"}
|
||||||
|
if configured and request.url.path not in public_paths and request.url.path.startswith("/v1"):
|
||||||
|
supplied = request.headers.get("x-api-key", "")
|
||||||
|
if not hmac.compare_digest(configured, supplied):
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"})
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
if _STATIC_DIR.exists():
|
if _STATIC_DIR.exists():
|
||||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||||
|
|||||||
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
|
REFERENCE_CORPUS_DIR: /app/data/reference
|
||||||
TAXONOMY_DIR: /app/data/taxonomy
|
TAXONOMY_DIR: /app/data/taxonomy
|
||||||
AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt
|
AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt
|
||||||
|
CORPUS_DB_PATH: /app/data/runtime/corpus.sqlite3
|
||||||
|
PERSISTENT_INDEX_DIR: /app/data/runtime/index
|
||||||
|
PRECEDENTS_PATH: /app/data/precedents/precedents.jsonl
|
||||||
|
AI_DETECTOR_MODEL_PATH: /app/data/models/ai_detector.joblib
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
271
docs/AI_DETECTION.md
Normal file
271
docs/AI_DETECTION.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# 한국어 AI 생성 의심도 (ai_detector)
|
||||||
|
|
||||||
|
> **이 기능이 산출하는 값은 "AI가 썼다"는 판정이 아니라, 사람 검토를 어디에 먼저
|
||||||
|
> 배정할지 정하는 우선순위 점수입니다.** 저자 통보·계약 조치·출간 거부의 단독
|
||||||
|
> 근거로 사용할 수 없습니다. 이유는 아래 [3. 한계와 오탐 위험](#3-한계와-오탐-위험)에
|
||||||
|
> 정리했습니다.
|
||||||
|
|
||||||
|
관련 파일:
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `app/engine/ai_detector.py` | 특징 추출 + 점수 산출 + 구간 채점 |
|
||||||
|
| `scripts/build_ai_training_dataset.py` | xlsx(human) + JSONL/CSV(AI) → 학습셋 |
|
||||||
|
| `scripts/train_ai_detector.py` | CPU 학습 + 보정 + 지표/아티팩트 저장 |
|
||||||
|
| `tests/test_ai_detector.py` | 단위테스트 (외부 다운로드 불필요) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 동작 모드 — 점수를 지어내지 않는다
|
||||||
|
|
||||||
|
세 가지 모드가 있고, 결과 객체의 `is_stub` / `model_version` / `note` 로 **항상
|
||||||
|
구분 가능**합니다. 기존 `detector.py:_dummy_ai_generation_signal` 처럼 문자 코드
|
||||||
|
합으로 만든 임의 더미는 이 모듈에 없습니다.
|
||||||
|
|
||||||
|
| 모드 | 조건 | `available` | `score` | `is_stub` | `model_version` |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `unavailable` | 아티팩트 없음 + `allow_heuristic=False` (**기본**) | `False` | `None` | `False` | `"unavailable"` |
|
||||||
|
| `heuristic` | 아티팩트 없음 + `allow_heuristic=True` | `True` | 0~1 | `True` | `"heuristic-baseline-v1"` |
|
||||||
|
| `trained` | 학습 아티팩트 로드 성공 | `True` | 0~1 (보정됨) | `False` | 예: `logreg-nolen-kf-ko-v1-auroc0.912` |
|
||||||
|
|
||||||
|
기본값이 `unavailable` 인 이유는, 검증되지 않은 점수가 조용히 운영 화면에 노출되는
|
||||||
|
상황을 막기 위해서입니다. 휴리스틱을 켜려면 명시적으로 opt-in 해야 합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AI_DETECTOR_MODEL_PATH=/mnt/data1/o2o/ai_detector/model.joblib
|
||||||
|
export AI_DETECTOR_ALLOW_HEURISTIC=false # 기본값
|
||||||
|
```
|
||||||
|
|
||||||
|
### 휴리스틱 baseline 에 대한 경고
|
||||||
|
|
||||||
|
`heuristic` 모드는 문헌상 알려진 경향(문장 길이 균일성, 쉼표 과다 등)을 선형
|
||||||
|
위치로 환산해 평균하는 규칙 계층입니다. **참조 구간 값은 아직 어떤 데이터로도
|
||||||
|
캘리브레이션되지 않은 자리표시자입니다.** 79권 인간 저작 코퍼스로 FPR을 측정하기
|
||||||
|
전까지는 데모·개발 편의용으로만 쓰고, 대외 산출물이나 사용자 화면에 노출하지
|
||||||
|
마십시오.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 특징 (`kf-ko-v1`)
|
||||||
|
|
||||||
|
KatFishNet 계열의 한국어 표지를 결정적·설명 가능하게 구현했습니다. 총
|
||||||
|
**54개**(기본 37 + 품사 17)이며 `FEATURE_NAMES` 순서가 곧 모델 입력 벡터의
|
||||||
|
순서입니다. 이 순서는
|
||||||
|
아티팩트에 저장되어 로드 시 대조되며, 불일치하면 **로드를 거부**합니다.
|
||||||
|
|
||||||
|
| 계열 | 대표 특징 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 띄어쓰기 | `mean_eojeol_len`, `cv_eojeol_len`, `long_eojeol_ratio` | 생성문의 어절 길이 분포가 더 균일 |
|
||||||
|
| 문장 | `cv_sentence_len`, `std_sentence_len`, `mean_sentence_len` | **burstiness** — 사람 글은 문장 길이 기복이 큼 |
|
||||||
|
| 쉼표·부호 | `comma_per_sentence`, `comma_ratio`, `punct_diversity` | 생성문의 쉼표 과다 사용 경향 |
|
||||||
|
| 종결 | `end_da_ratio`, `end_yo_ratio`, `end_noun_ratio` | 종결어미 분포의 정형성 |
|
||||||
|
| 반복 | `char3gram_repeat_ratio`, `word_bigram_repeat_ratio`, `hapax_ratio` | 표현 재사용 |
|
||||||
|
| 품사 | `pos_bigram_entropy`, `pos_trigram_repeat_ratio`, `josa_diversity`, `eomi_diversity` | 품사 배열의 정형성, 조사·어미 다양도 |
|
||||||
|
| 길이 | `char_count`, `sentence_count`, `morph_count` 등 | 보조 — 아래 경고 참조 |
|
||||||
|
|
||||||
|
### 품사 특징 폴백
|
||||||
|
|
||||||
|
품사 특징은 `kiwipiepy` 가 있을 때만 산출됩니다. 미설치·초기화 실패·토큰화 예외
|
||||||
|
어느 경우에도 **예외를 밖으로 던지지 않고** 품사 특징을 0.0 으로 두며
|
||||||
|
`pos_available=0.0` 을 세웁니다. **특징 벡터의 길이와 순서는 절대 변하지
|
||||||
|
않습니다.** 모델이 품사로 학습됐는데(`requires_pos=True`) 실행 환경에 kiwi 가
|
||||||
|
없으면 `pos_required_by_model_but_missing` 경고가 붙습니다.
|
||||||
|
|
||||||
|
### ⚠️ 길이 특징 편향
|
||||||
|
|
||||||
|
human 에피소드와 AI 생성 표본의 **분량 분포가 다르면, 모델은 문체가 아니라
|
||||||
|
길이를 학습합니다.** 실제로 합성 데이터로 검증했을 때 상위 기여 특징이
|
||||||
|
`sentence_count`, `char_count`, `morph_count` 로 채워졌고,
|
||||||
|
`--drop-length-features` 를 켜자 `comma_ratio`, `josa_ratio`,
|
||||||
|
`std_sentence_len` 같은 실제 문체 지표로 바뀌었습니다.
|
||||||
|
|
||||||
|
**실데이터에서는 두 설정을 모두 돌려 성능 차이를 반드시 비교하십시오.** 길이를
|
||||||
|
빼도 성능이 유지되면 문체를 배운 것이고, 크게 떨어지면 길이만 보고 있었던
|
||||||
|
것입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 한계와 오탐 위험
|
||||||
|
|
||||||
|
이 기능을 도입하기 전에 반드시 합의해야 할 항목입니다.
|
||||||
|
|
||||||
|
1. **자서전은 대필·윤문이 일상적입니다.** 편집자가 다듬은 원고는 문체가 정제되어
|
||||||
|
AI 쪽으로 기울기 쉽습니다. 이 도메인에서 FP는 "저자가 AI로 썼다"는 통보로
|
||||||
|
이어질 수 있고, 그건 명예 문제입니다.
|
||||||
|
2. **학습에 쓰이지 않은 생성 모델에는 일반화가 잘 되지 않습니다.** 특정 모델의
|
||||||
|
출력으로 학습하면 그 모델만 잡습니다.
|
||||||
|
3. **가벼운 수정으로 회피됩니다.** 문장을 몇 개 쪼개고 쉼표만 지워도 주요 특징이
|
||||||
|
흔들립니다.
|
||||||
|
4. **짧은 글은 통계가 성립하지 않습니다.** `HARD_MIN_CHARS=120` 미만은 채점을
|
||||||
|
거부하고, `MIN_RELIABLE_CHARS=300` 미만은 `short_text_low_confidence` 경고를
|
||||||
|
붙입니다.
|
||||||
|
5. **표절 점수와 절대 합산하지 마십시오.** 세 점수(표절 / 침해위험 / AI의심)는
|
||||||
|
끝까지 독립 필드여야 합니다.
|
||||||
|
|
||||||
|
### 평가 기준은 F1 이 아니라 FPR
|
||||||
|
|
||||||
|
인간 저작을 AI로 오판하는 비용이 압도적으로 크므로, 임계값은 F1 최적점이 아니라
|
||||||
|
**목표 FPR 상한**에서 잡습니다. `train_ai_detector.py` 의 `--target-fpr`(기본
|
||||||
|
0.05, `low_cut`)과 `--high-fpr`(기본 0.01, `high_cut`)이 그 역할입니다. 모델은
|
||||||
|
train에서 적합하고 임계값은 모델이 보지 않은 val에서 정하며, test는 최종 보고에만
|
||||||
|
사용합니다. val human 표본이 100건 미만이면 FPR 컷이 불안정하다는 경고가 납니다.
|
||||||
|
|
||||||
|
`low_cut == high_cut` 경고가 뜨면 medium 배지가 사라진 상태입니다. 표본이 너무
|
||||||
|
쉽게 분리되거나 표본 수가 부족하다는 신호이니 데이터를 재점검하십시오.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. provenance (작성 경로 **추정**)
|
||||||
|
|
||||||
|
확정이 아니라 검토자 참고용 분류입니다. 구간 점수 분포에서 유도합니다.
|
||||||
|
|
||||||
|
| 값 | 규칙 | 검토자 해석 |
|
||||||
|
|---|---|---|
|
||||||
|
| `human` | 문서·구간 모두 low | 통상 검토 |
|
||||||
|
| `ai` | 문서 high, low 구간 없음 | 우선 검토 |
|
||||||
|
| `mixed` | high 구간과 low 구간이 공존 | **부분 삽입 의심** — 어느 구간인지 확인 |
|
||||||
|
| `edited` | 전 구간이 medium 에 몰림 | AI 초안 + 사람 윤문(또는 역) 가능성 |
|
||||||
|
| `unknown` | 채점 가능한 구간 없음 / 모델 미가용 | 판단 보류 |
|
||||||
|
|
||||||
|
`mixed` 가 실무상 가장 유용합니다. 문서 전체 점수 하나로는 "30만 자 중 한 챕터만
|
||||||
|
생성물"을 절대 못 잡지만, 구간 점수는 잡습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 학습 절차
|
||||||
|
|
||||||
|
### 5.1 데이터 준비
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 컬럼 확인 (아무것도 쓰지 않음) — 반드시 먼저 실행
|
||||||
|
python scripts/build_ai_training_dataset.py --xlsx episodes.xlsx --inspect
|
||||||
|
|
||||||
|
# ② 빌드
|
||||||
|
python scripts/build_ai_training_dataset.py \
|
||||||
|
--xlsx episodes.xlsx \
|
||||||
|
--text-column "에피소드 본문" --book-column "도서명" \
|
||||||
|
--ai-jsonl data/training/ai_samples.jsonl \
|
||||||
|
--out data/training/ai_dataset.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 샘플 JSONL 형식** (`--ai-text-field`/`--ai-generator-field` 로 변경 가능):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"text": "생성된 본문 …", "generator": "gpt-4o-mini", "book": ""}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 누출 방지 — 이 설계의 핵심
|
||||||
|
|
||||||
|
분할 단위는 개별 텍스트가 아니라 **`source_group`** 입니다.
|
||||||
|
|
||||||
|
- human: `book:<도서명>` (도서 정보가 없으면 `sheet:<시트명>`)
|
||||||
|
- AI: `ai:<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 직렬화.
|
||||||
@ -64,7 +64,7 @@
|
|||||||
| 저작권 · 유사도 2% | 2 | `review_summary.similarity_percent` |
|
| 저작권 · 유사도 2% | 2 | `review_summary.similarity_percent` |
|
||||||
| "…3.5만 건과 대조한 결과 표절 의심 구간이 없습니다" | 35000 / false | `review_summary.compared_count`, `review_summary.has_suspicion` |
|
| "…3.5만 건과 대조한 결과 표절 의심 구간이 없습니다" | 35000 / false | `review_summary.compared_count`, `review_summary.has_suspicion` |
|
||||||
| 유사 문장 0건 | 0 | `review_summary.similar_sentence_count` |
|
| 유사 문장 0건 | 0 | `review_summary.similar_sentence_count` |
|
||||||
| AI 생성 의심도 낮음 | low | `review_summary.ai_suspicion_level` (`low/medium/high`=낮음/중간/높음) |
|
| AI 생성 의심도 | unknown | `review_summary.ai_suspicion_level` (`low/medium/high/unknown`) |
|
||||||
| (표절 시) 일치 구간 하이라이트 | start~end | `matches[].evidence_spans[]` |
|
| (표절 시) 일치 구간 하이라이트 | start~end | `matches[].evidence_spans[]` |
|
||||||
| (표절 시) 침해 유형 배지 | 복제권 | `matches[].tags[].label_ko` + `case_id` |
|
| (표절 시) 침해 유형 배지 | 복제권 | `matches[].tags[].label_ko` + `case_id` |
|
||||||
|
|
||||||
@ -114,7 +114,7 @@
|
|||||||
"has_suspicion": false,
|
"has_suspicion": false,
|
||||||
"ai_suspicion_level": "low"
|
"ai_suspicion_level": "low"
|
||||||
},
|
},
|
||||||
"ai_generation": { "suspicion_level": "low", "score": 0.06, "is_stub": true, "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" },
|
"ai_generation": { "suspicion_level": "unknown", "score": null, "available": false, "is_stub": false, "model_version": "unavailable", "note": "학습된 모델이 없어 채점하지 않음" },
|
||||||
"matches": [],
|
"matches": [],
|
||||||
"extracted_elements": { "characters": [], "motifs": ["비밀의 정원"], "genre": null, "keywords": ["숲","전설"] },
|
"extracted_elements": { "characters": [], "motifs": ["비밀의 정원"], "genre": null, "keywords": ["숲","전설"] },
|
||||||
"ccl_basis": null,
|
"ccl_basis": null,
|
||||||
@ -130,7 +130,7 @@
|
|||||||
| 필드 | 타입 | 의미 | 화면 사용 |
|
| 필드 | 타입 | 의미 | 화면 사용 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `doc_id` | string | 요청의 doc_id 그대로 | 응답 매칭 |
|
| `doc_id` | string | 요청의 doc_id 그대로 | 응답 매칭 |
|
||||||
| `is_infringement` | bool | 표절 판정 여부(임계 초과 매칭 존재). `review_summary.has_suspicion`과 동일 | — |
|
| `is_infringement` | bool | 후방호환 필드. 법적 침해 확정이 아니라 임계 초과 매칭 존재 여부 | — |
|
||||||
| `confidence` | float 0~1 | 최상위 매칭의 **결합 유사도 원값**. ⚠️ 임베딩 성분 때문에 무관한 글도 높게 나옴 → **화면 표시는 `review_summary` 사용, `confidence` 직접 표시 금지** | ✗ |
|
| `confidence` | float 0~1 | 최상위 매칭의 **결합 유사도 원값**. ⚠️ 임베딩 성분 때문에 무관한 글도 높게 나옴 → **화면 표시는 `review_summary` 사용, `confidence` 직접 표시 금지** | ✗ |
|
||||||
| `review_summary` | object | **저작권 탭 UI 요약** (아래 상세) | ★ |
|
| `review_summary` | object | **저작권 탭 UI 요약** (아래 상세) | ★ |
|
||||||
| `ai_generation` | object | AI 생성 의심도 상세 (아래 상세) | 참고 |
|
| `ai_generation` | object | AI 생성 의심도 상세 (아래 상세) | 참고 |
|
||||||
@ -151,7 +151,7 @@
|
|||||||
| `similar_sentence_count` | int | **유사 문장 건수** = 임계 초과 매칭 수 |
|
| `similar_sentence_count` | int | **유사 문장 건수** = 임계 초과 매칭 수 |
|
||||||
| `compared_count` | int | **대조한 원본(코퍼스) 건수** |
|
| `compared_count` | int | **대조한 원본(코퍼스) 건수** |
|
||||||
| `has_suspicion` | bool | **표절 의심 구간 존재 여부** |
|
| `has_suspicion` | bool | **표절 의심 구간 존재 여부** |
|
||||||
| `ai_suspicion_level` | `low/medium/high` | **AI 생성 의심도**(낮음/중간/높음). ⚠️ 현재 더미 |
|
| `ai_suspicion_level` | `low/medium/high/unknown` | **AI 생성 의심도**. 미학습/채점 불가는 `unknown` |
|
||||||
|
|
||||||
**`ai_generation` (AI 생성 의심도 상세)**
|
**`ai_generation` (AI 생성 의심도 상세)**
|
||||||
|
|
||||||
@ -159,11 +159,14 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `suspicion_level` | `low/medium/high` | 의심도 등급 |
|
| `suspicion_level` | `low/medium/high` | 의심도 등급 |
|
||||||
| `score` | float 0~1 | 참고 점수 |
|
| `score` | float 0~1 | 참고 점수 |
|
||||||
| `is_stub` | bool | **`true`면 더미(미구현)**. 정식 구현 시 `false` |
|
| `available` | bool | 학습 모델 또는 명시적으로 활성화한 baseline으로 채점했는지 |
|
||||||
|
| `is_stub` | bool | `true`면 미검증 휴리스틱 baseline. 학습 모델은 `false` |
|
||||||
|
| `model_version` | string | 학습 아티팩트/특징 버전 추적 |
|
||||||
| `note` | string | 안내 문구 |
|
| `note` | string | 안내 문구 |
|
||||||
|
|
||||||
> ⚠️ **AI 생성 의심도는 현재 스텁**이다. `is_stub=true`이며 요청마다 더미 값을 낸다.
|
> 학습 모델이 없으면 점수를 임의 생성하지 않고 `available=false`, `score=null`,
|
||||||
> UI 배지는 붙여두되 **출판 승인 게이트로 쓰지 말 것**(참고용).
|
> `suspicion_level=unknown`을 반환한다. 학습 후에도 **출판 승인 게이트나 저자 제재의
|
||||||
|
> 단독 근거로 쓰지 말 것**(사람 검토 우선순위용).
|
||||||
|
|
||||||
**`matches[]` (표절 의심 시 채워짐)**
|
**`matches[]` (표절 의심 시 채워짐)**
|
||||||
|
|
||||||
|
|||||||
@ -32,9 +32,9 @@
|
|||||||
| 표절 의심 구간 **없음** | false | `review_summary.has_suspicion` |
|
| 표절 의심 구간 **없음** | false | `review_summary.has_suspicion` |
|
||||||
| AI 생성 의심도 **낮음** | low | `review_summary.ai_suspicion_level` |
|
| AI 생성 의심도 **낮음** | low | `review_summary.ai_suspicion_level` |
|
||||||
|
|
||||||
> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음
|
> `ai_suspicion_level` 값 매핑: `low`=낮음, `medium`=중간, `high`=높음,
|
||||||
> **⚠️ AI 생성 의심도는 현재 더미(스텁) 값입니다.** UI 연동 계약 확정용이며, 정식 판별
|
> `unknown`=미학습 또는 채점 불가. 학습 모델이 없으면 점수를 만들지 않고
|
||||||
> 로직(워터마킹+언어특징 분류) 적용 전까지 `ai_generation.is_stub=true` 로 반환됩니다.
|
> `ai_generation.available=false`, `score=null`을 반환합니다.
|
||||||
> UI는 지금 그대로 붙여두면 되고, 정식 구현 시 값만 실제로 바뀝니다.
|
> UI는 지금 그대로 붙여두면 되고, 정식 구현 시 값만 실제로 바뀝니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -88,7 +88,9 @@
|
|||||||
"suspicion_level": "low",
|
"suspicion_level": "low",
|
||||||
"score": 0.06,
|
"score": 0.06,
|
||||||
"is_stub": true,
|
"is_stub": true,
|
||||||
"note": "더미 응답 — 실제 AI 생성 판별 결과가 아님"
|
"available": false,
|
||||||
|
"model_version": "unavailable",
|
||||||
|
"note": "학습된 모델이 없어 채점하지 않음"
|
||||||
},
|
},
|
||||||
"matches": [],
|
"matches": [],
|
||||||
"extracted_elements": {
|
"extracted_elements": {
|
||||||
@ -117,7 +119,7 @@
|
|||||||
"has_suspicion": true,
|
"has_suspicion": true,
|
||||||
"ai_suspicion_level": "low"
|
"ai_suspicion_level": "low"
|
||||||
},
|
},
|
||||||
"ai_generation": { "suspicion_level": "low", "score": 0.06, "is_stub": true, "note": "더미 응답 — 실제 AI 생성 판별 결과가 아님" },
|
"ai_generation": { "suspicion_level": "unknown", "score": null, "available": false, "is_stub": false, "model_version": "unavailable", "note": "학습된 모델이 없어 채점하지 않음" },
|
||||||
"matches": [
|
"matches": [
|
||||||
{
|
{
|
||||||
"source_doc": "auto-0003",
|
"source_doc": "auto-0003",
|
||||||
@ -151,10 +153,10 @@
|
|||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `is_infringement` | bool | 표절 판정 여부 (= `review_summary.has_suspicion`) |
|
| `is_infringement` | bool | 후방호환용 임계 초과 매칭 여부. 법적 침해 확정 아님 |
|
||||||
| `confidence` | float | 최상위 매칭 결합 유사도 원값(0~1). **화면 표시는 `review_summary` 사용 권장** |
|
| `confidence` | float | 최상위 매칭 결합 유사도 원값(0~1). **화면 표시는 `review_summary` 사용 권장** |
|
||||||
| `review_summary` | object | **저작권 탭 UI 직접 매핑** (§1 표 참조) |
|
| `review_summary` | object | **저작권 탭 UI 직접 매핑** (§1 표 참조) |
|
||||||
| `ai_generation` | object | AI 생성 의심도 상세. `is_stub=true`면 더미 |
|
| `ai_generation` | object | AI 생성 의심도 상세. 미학습이면 `available=false`, 휴리스틱이면 `is_stub=true` |
|
||||||
| `matches[]` | array | 매칭된 원본별 상세 (표절 시). 태그·케이스·근거 구간 포함 |
|
| `matches[]` | array | 매칭된 원본별 상세 (표절 시). 태그·케이스·근거 구간 포함 |
|
||||||
| `matches[].tags[]` | array | 법령 태그. `role`: `primary`(주)/`secondary`(보조), `label_ko` 한글 표기 |
|
| `matches[].tags[]` | array | 법령 태그. `role`: `primary`(주)/`secondary`(보조), `label_ko` 한글 표기 |
|
||||||
| `matches[].case_id` | string | 39종 침해 케이스 ID (예: A6) |
|
| `matches[].case_id` | string | 39종 침해 케이스 ID (예: A6) |
|
||||||
@ -196,7 +198,7 @@ FastAPI 표준. HTTP 상태코드 + `detail`.
|
|||||||
|
|
||||||
## 5. 연동 시 주의
|
## 5. 연동 시 주의
|
||||||
|
|
||||||
1. **AI 생성 의심도는 현재 더미** — `is_stub` 로 판별 후, UI에는 표시하되 "참고용"으로 둘 것.
|
1. **AI 생성 의심도는 확정값이 아님** — `available`/`is_stub`/`model_version`을 확인하고 사람 검토 우선순위로만 사용할 것.
|
||||||
출판 승인/거절 게이트로 쓰지 말 것(오탐 리스크).
|
출판 승인/거절 게이트로 쓰지 말 것(오탐 리스크).
|
||||||
2. **화면 값은 `review_summary` 사용** — `confidence`(원값)는 임베딩 성분 때문에 무관한 글도
|
2. **화면 값은 `review_summary` 사용** — `confidence`(원값)는 임베딩 성분 때문에 무관한 글도
|
||||||
높게 나오므로 직접 표시 금지. 독창성 환산은 오투오가 `review_summary`에서 완료해 제공.
|
높게 나오므로 직접 표시 금지. 독창성 환산은 오투오가 `review_summary`에서 완료해 제공.
|
||||||
|
|||||||
122
docs/IMPLEMENTATION_RUNBOOK.md
Normal file
122
docs/IMPLEMENTATION_RUNBOOK.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# O2O 표절·AI 의심도·판례 위험도 운영 런북
|
||||||
|
|
||||||
|
## 안전한 제품 경계
|
||||||
|
|
||||||
|
세 결과는 서로 다른 증거를 사용하며 합쳐서 하나의 `침해 확정` 값으로 만들지 않는다.
|
||||||
|
|
||||||
|
1. **유사구간 검색**: 현재 등록 코퍼스 안에서 발견된 후보와 원문 위치
|
||||||
|
2. **AI 생성 의심도**: 원문 문체 특징에 대한 검토 우선순위(확정 판정 금지)
|
||||||
|
3. **법적 위험도**: 등록 판례와 판단 요소에 대한 검토 보조(법률 자문 아님)
|
||||||
|
|
||||||
|
후방 호환을 위해 `is_infringement` 필드는 유지하지만 실제 의미는 임계값을 넘은
|
||||||
|
`has_similarity_match`와 같다. 신규 연동은 `has_similarity_match`,
|
||||||
|
`corpus_scope_note`, `legal_risk`를 사용한다.
|
||||||
|
|
||||||
|
## 수령 데이터 실사 결과
|
||||||
|
|
||||||
|
- XLSX 본 데이터 34,105행, 원천 도서 79권
|
||||||
|
- 고유 에피소드 31,560개, 같은 책 내부 중복 추가 행 2,545개
|
||||||
|
- 고유 본문 약 2,946만 자
|
||||||
|
- 페이지/문단 원문 좌표 없음: XLSX 적재 결과는 `coordinate_scope=episode`
|
||||||
|
|
||||||
|
## King 서버 데이터 적재
|
||||||
|
|
||||||
|
원고 데이터와 학습 산출물은 Git에 커밋하지 않는다. `/app/data` Docker 볼륨 아래의
|
||||||
|
`runtime`, `models`, `input`을 사용한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m scripts.ingest_o2o_xlsx \
|
||||||
|
data/input/o2o_episodes.xlsx \
|
||||||
|
--database data/runtime/corpus.sqlite3
|
||||||
|
|
||||||
|
python -m scripts.build_persistent_index \
|
||||||
|
--database data/runtime/corpus.sqlite3 \
|
||||||
|
--index-dir data/runtime/index
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 DB에 신규 세그먼트만 추가한 뒤 `build_persistent_index`를 다시 실행하면 결과의
|
||||||
|
`mode`가 `append`이며 기존 행을 다시 벡터화하지 않는다. 삭제 또는 본문 변경이
|
||||||
|
감지된 경우에만 `rebuild`한다.
|
||||||
|
|
||||||
|
`.env`에서 다음을 설정하고 재기동한다.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
USE_PERSISTENT_INDEX=true
|
||||||
|
CORPUS_DB_PATH=/app/data/runtime/corpus.sqlite3
|
||||||
|
PERSISTENT_INDEX_DIR=/app/data/runtime/index
|
||||||
|
PERSISTENT_SIMILARITY_THRESHOLD=0.65
|
||||||
|
# 바이칼과 키 전달을 합의한 뒤 활성화
|
||||||
|
API_KEY=<secret-manager-or-protected-env-value>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 인증·방화벽·키 회전을 별도 운영 작업으로 완료해야 한다.
|
||||||
@ -10,3 +10,8 @@ httpx>=0.27
|
|||||||
kiwipiepy>=0.18
|
kiwipiepy>=0.18
|
||||||
datasketch>=1.6
|
datasketch>=1.6
|
||||||
sentence-transformers>=3.0
|
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())
|
||||||
31
scripts/build_persistent_index.py
Normal file
31
scripts/build_persistent_index.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SQLite 코퍼스의 CPU 영속 후보 인덱스를 신규 구축하거나 증분 동기화한다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.engine.persistent_index import PersistentCorpusIndex
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument("--database", type=Path, required=True)
|
||||||
|
p.add_argument("--index-dir", type=Path, required=True)
|
||||||
|
p.add_argument("--features", type=int, default=2**20)
|
||||||
|
args = p.parse_args()
|
||||||
|
if not args.database.exists():
|
||||||
|
p.error(f"database does not exist: {args.database}")
|
||||||
|
result = PersistentCorpusIndex(args.database, args.index_dir).sync(args.features)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
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())
|
||||||
513
tests/test_ai_detector.py
Normal file
513
tests/test_ai_detector.py
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
"""AI 생성 의심도 모듈 단위테스트.
|
||||||
|
|
||||||
|
원칙: 외부 모델 다운로드·네트워크·sklearn 설치 없이 전부 통과해야 한다.
|
||||||
|
학습 모델이 필요한 경로는 stub estimator 로 대체해 검증한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import ai_detector as ad
|
||||||
|
from app.engine.ai_detector import (
|
||||||
|
FEATURE_NAMES,
|
||||||
|
HARD_MIN_CHARS,
|
||||||
|
AiGenerationDetector,
|
||||||
|
ModelArtifact,
|
||||||
|
extract_features,
|
||||||
|
features_to_vector,
|
||||||
|
load_artifact,
|
||||||
|
normalize_text,
|
||||||
|
split_paragraphs,
|
||||||
|
split_sentences,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 샘플 텍스트
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 문장 길이가 균일하고 쉼표가 많은 글 (AI 경향 쪽)
|
||||||
|
UNIFORM_TEXT = (
|
||||||
|
"그날의 기억은 오래도록 남아, 지금까지도 선명하게 떠오르는 장면이 되었다. "
|
||||||
|
"아침의 공기는 서늘했고, 골목을 지나는 사람들의 발걸음은 조용히 이어졌다. "
|
||||||
|
"어머니는 부엌에서 국을 끓였고, 그 냄새는 온 집안을 천천히 채워 나갔다. "
|
||||||
|
"나는 책상에 앉아 공책을 펼쳤고, 연필을 쥔 손에는 힘이 들어가 있었다. "
|
||||||
|
"창밖으로는 햇빛이 들어왔고, 마당의 나무는 잎을 조금씩 흔들고 있었다. "
|
||||||
|
"그 시절의 하루는 언제나, 비슷한 순서로 조용하게 흘러가고 있었다."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 문장 길이가 들쭉날쭉하고 쉼표가 적은 글 (사람 경향 쪽)
|
||||||
|
BURSTY_TEXT = (
|
||||||
|
"비가 왔다. "
|
||||||
|
"나는 그날 아침에 학교에 가지 않았고 대신 뒷산에 올라가서 온종일 아무것도 하지 않은 채로 "
|
||||||
|
"그냥 젖은 흙냄새를 맡으며 앉아 있었는데 지금 생각하면 그게 무슨 의미였는지 잘 모르겠다. "
|
||||||
|
"춥지는 않았다. "
|
||||||
|
"형이 나를 찾으러 왔다. "
|
||||||
|
"우리는 말없이 내려왔고 집에 도착했을 때 어머니는 아무 말도 하지 않으셨다. "
|
||||||
|
"저녁을 먹었다. "
|
||||||
|
"그게 전부였다."
|
||||||
|
)
|
||||||
|
|
||||||
|
LONG_TEXT = "\n\n".join([UNIFORM_TEXT, BURSTY_TEXT, UNIFORM_TEXT, BURSTY_TEXT])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 정규화 / 분해
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_normalize_collapses_whitespace():
|
||||||
|
assert normalize_text("가나 다\r\n라") == "가나 다\n라"
|
||||||
|
assert normalize_text("") == ""
|
||||||
|
assert normalize_text(" \n ") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_sentences_and_paragraphs():
|
||||||
|
assert len(split_sentences("첫 문장이다. 둘째 문장이다! 셋째인가?")) == 3
|
||||||
|
assert len(split_paragraphs("문단 하나.\n\n문단 둘.")) == 2
|
||||||
|
# 빈 입력에도 죽지 않아야 한다
|
||||||
|
assert split_sentences("") == []
|
||||||
|
assert split_paragraphs("") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 특징 추출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_features_cover_all_names_and_are_finite():
|
||||||
|
feats = extract_features(UNIFORM_TEXT)
|
||||||
|
assert set(feats) == set(FEATURE_NAMES), "특징 키 누락/초과"
|
||||||
|
for name, val in feats.items():
|
||||||
|
assert isinstance(val, float), f"{name} 이 float 이 아님"
|
||||||
|
assert val == val, f"{name} 이 NaN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_vector_length_and_order_stable():
|
||||||
|
feats = extract_features(UNIFORM_TEXT)
|
||||||
|
vec = features_to_vector(feats)
|
||||||
|
assert len(vec) == len(FEATURE_NAMES)
|
||||||
|
# 순서 계약: 벡터 i번째는 FEATURE_NAMES[i] 값
|
||||||
|
for i, name in enumerate(FEATURE_NAMES):
|
||||||
|
assert vec[i] == pytest.approx(feats[name])
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeroed_features_are_masked_but_length_preserved():
|
||||||
|
"""길이 특징 제외 시에도 벡터 길이는 불변이어야 한다 (모델 입력 계약)."""
|
||||||
|
feats = extract_features(UNIFORM_TEXT)
|
||||||
|
full = features_to_vector(feats)
|
||||||
|
masked = features_to_vector(feats, ad.LENGTH_FEATURES)
|
||||||
|
assert len(masked) == len(full) == len(FEATURE_NAMES)
|
||||||
|
for i, name in enumerate(FEATURE_NAMES):
|
||||||
|
if name in ad.LENGTH_FEATURES:
|
||||||
|
assert masked[i] == 0.0, f"{name} 이 0으로 눌리지 않음"
|
||||||
|
else:
|
||||||
|
assert masked[i] == pytest.approx(full[i])
|
||||||
|
assert full != masked, "길이 특징이 원래 0이면 이 테스트가 무의미해진다"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detector_applies_artifact_zeroed_features():
|
||||||
|
"""학습 때 제외한 특징이 추론에서도 동일하게 제외되어야 한다."""
|
||||||
|
det = _stub_detector()
|
||||||
|
# 모든 특징에 균등 계수를 줘야 '제외되지 않으면 상위에 뜬다'가 성립한다
|
||||||
|
det.artifact.estimator.coef_ = [[1.0] * len(FEATURE_NAMES)]
|
||||||
|
det.artifact.zeroed_features = ad.LENGTH_FEATURES
|
||||||
|
res = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
names = {n for n, _ in res.top_contributions}
|
||||||
|
assert names, "기여도가 비어 있으면 검증이 성립하지 않는다"
|
||||||
|
assert not (names & set(ad.LENGTH_FEATURES)), (
|
||||||
|
"제외된 길이 특징이 기여도에 나타나면 학습/추론 불일치"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_features_is_deterministic():
|
||||||
|
a = extract_features(UNIFORM_TEXT)
|
||||||
|
b = extract_features(UNIFORM_TEXT)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text_yields_zero_vector():
|
||||||
|
feats = extract_features("")
|
||||||
|
assert all(v == 0.0 for v in feats.values())
|
||||||
|
assert len(features_to_vector(feats)) == len(FEATURE_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uniform_text_has_lower_sentence_variance_than_bursty():
|
||||||
|
"""문장 길이 변동계수는 AI 탐지의 핵심 표지 — 방향이 맞는지 확인."""
|
||||||
|
uniform = extract_features(UNIFORM_TEXT)
|
||||||
|
bursty = extract_features(BURSTY_TEXT)
|
||||||
|
assert uniform["cv_sentence_len"] < bursty["cv_sentence_len"]
|
||||||
|
assert uniform["comma_per_sentence"] > bursty["comma_per_sentence"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_depend_on_structure_not_characters():
|
||||||
|
"""해시 더미가 아님을 보증 — 구조가 같으면 구조 특징도 같아야 한다."""
|
||||||
|
base = "가가가 가가가 가가가다. 가가가 가가가 가가가다. 가가가 가가가 가가가다."
|
||||||
|
swapped = base.replace("가", "나")
|
||||||
|
fa, fb = extract_features(base), extract_features(swapped)
|
||||||
|
for key in ("cv_sentence_len", "mean_sentence_len", "mean_eojeol_len",
|
||||||
|
"space_ratio", "sentence_count", "eojeol_count"):
|
||||||
|
assert fa[key] == pytest.approx(fb[key]), key
|
||||||
|
|
||||||
|
|
||||||
|
def test_pos_features_absent_when_kiwi_unavailable(monkeypatch):
|
||||||
|
monkeypatch.setattr(ad, "_get_kiwi", lambda: None)
|
||||||
|
feats = extract_features(UNIFORM_TEXT)
|
||||||
|
assert feats["pos_available"] == 0.0
|
||||||
|
assert feats["morph_count"] == 0.0
|
||||||
|
# 벡터 길이는 그대로여야 한다 (모델 입력 계약)
|
||||||
|
assert len(features_to_vector(feats)) == len(FEATURE_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pos_extraction_survives_tokenizer_exception(monkeypatch):
|
||||||
|
class Boom:
|
||||||
|
def tokenize(self, text):
|
||||||
|
raise RuntimeError("tokenizer exploded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ad, "_get_kiwi", lambda: Boom())
|
||||||
|
feats = extract_features(UNIFORM_TEXT) # 예외가 새어 나오면 실패
|
||||||
|
assert feats["pos_available"] == 0.0
|
||||||
|
assert feats["char_count"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 미가용 모드 — 점수를 지어내지 않는다
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_unavailable_when_no_model(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=False)
|
||||||
|
assert det.mode == "unavailable"
|
||||||
|
res = det.detect(UNIFORM_TEXT)
|
||||||
|
assert res.available is False
|
||||||
|
assert res.score is None
|
||||||
|
assert res.suspicion_level is None
|
||||||
|
assert res.provenance == "unknown"
|
||||||
|
assert res.model_version == "unavailable"
|
||||||
|
assert "model_not_trained" in res.warnings
|
||||||
|
assert res.is_stub is False # 더미를 준 게 아니라 아예 안 준 것
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_artifact_missing_file_returns_none(tmp_path):
|
||||||
|
assert load_artifact(tmp_path / "absent.joblib") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 휴리스틱 baseline 모드 — 반드시 스텁임이 드러나야 한다
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_heuristic_mode_is_flagged_as_stub(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
assert det.mode == "heuristic"
|
||||||
|
res = det.detect(UNIFORM_TEXT)
|
||||||
|
assert res.available is True
|
||||||
|
assert res.is_stub is True
|
||||||
|
assert res.model_version == "heuristic-baseline-v1"
|
||||||
|
assert "휴리스틱" in res.note
|
||||||
|
assert 0.0 <= res.score <= 1.0
|
||||||
|
assert res.suspicion_level in ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_heuristic_is_deterministic(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
first = det.detect(UNIFORM_TEXT)
|
||||||
|
second = det.detect(UNIFORM_TEXT)
|
||||||
|
assert first.score == second.score
|
||||||
|
assert first.provenance == second.provenance
|
||||||
|
|
||||||
|
|
||||||
|
def test_heuristic_ranks_uniform_above_bursty(tmp_path):
|
||||||
|
"""규칙이 실제로 언어특징을 반영하는지 (방향성 검증)."""
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
uniform = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
bursty = det.detect(BURSTY_TEXT, with_segments=False)
|
||||||
|
assert uniform.score > bursty.score
|
||||||
|
|
||||||
|
|
||||||
|
def test_heuristic_score_is_pure_function_of_features():
|
||||||
|
low = dict.fromkeys(FEATURE_NAMES, 0.0)
|
||||||
|
low.update({"cv_sentence_len": 0.75, "comma_per_sentence": 0.60,
|
||||||
|
"hapax_ratio": 0.78, "mean_sentence_len": 38.0})
|
||||||
|
high = dict.fromkeys(FEATURE_NAMES, 0.0)
|
||||||
|
high.update({"cv_sentence_len": 0.35, "comma_per_sentence": 1.40,
|
||||||
|
"hapax_ratio": 0.60, "mean_sentence_len": 58.0})
|
||||||
|
assert ad._heuristic_score(high) > ad._heuristic_score(low)
|
||||||
|
assert ad._heuristic_score(low) == ad._heuristic_score(dict(low))
|
||||||
|
|
||||||
|
|
||||||
|
def test_heuristic_contributions_are_reported(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
res = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
assert res.top_contributions, "설명 근거가 비어 있으면 안 된다"
|
||||||
|
names = {n for n, _ in res.top_contributions}
|
||||||
|
assert names <= set(FEATURE_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 짧은 텍스트 처리
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_too_short_text_refuses_to_score(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
res = det.detect("짧은 글이다. 이건 통계가 안 나온다.")
|
||||||
|
assert res.available is False
|
||||||
|
assert res.score is None
|
||||||
|
assert "text_too_short" in res.warnings
|
||||||
|
assert str(HARD_MIN_CHARS) in res.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_but_scorable_text_gets_warning(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
text = "오늘은 비가 내렸다. " * 12 # HARD_MIN 초과, MIN_RELIABLE 미만 구간
|
||||||
|
assert HARD_MIN_CHARS <= len(text.strip()) < ad.MIN_RELIABLE_CHARS
|
||||||
|
res = det.detect(text)
|
||||||
|
assert res.available is True
|
||||||
|
assert "short_text_low_confidence" in res.warnings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 구간(segment) 채점
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_segments_have_valid_offsets(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
res = det.detect(LONG_TEXT)
|
||||||
|
assert res.segments, "긴 글은 구간이 나와야 한다"
|
||||||
|
norm = normalize_text(LONG_TEXT)
|
||||||
|
for seg in res.segments:
|
||||||
|
assert 0 <= seg.start <= seg.end <= len(norm)
|
||||||
|
assert seg.char_count > 0
|
||||||
|
if seg.scored:
|
||||||
|
assert seg.score is not None and 0.0 <= seg.score <= 1.0
|
||||||
|
assert seg.suspicion_level in ("low", "medium", "high")
|
||||||
|
else:
|
||||||
|
assert seg.score is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_segments_can_be_disabled(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
res = det.detect(LONG_TEXT, with_segments=False)
|
||||||
|
assert res.segments == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# provenance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_provenance_is_valid_value(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
for text in (UNIFORM_TEXT, BURSTY_TEXT, LONG_TEXT):
|
||||||
|
res = det.detect(text)
|
||||||
|
assert res.provenance in ("human", "ai", "mixed", "edited", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_mixed_when_segments_span_both_ends(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
segs = [
|
||||||
|
ad.SegmentScore(index=0, start=0, end=10, char_count=600, score=0.9,
|
||||||
|
suspicion_level="high", scored=True),
|
||||||
|
ad.SegmentScore(index=1, start=10, end=20, char_count=600, score=0.1,
|
||||||
|
suspicion_level="low", scored=True),
|
||||||
|
]
|
||||||
|
assert det._infer_provenance(0.5, segs) == "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_human_when_all_low(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
segs = [
|
||||||
|
ad.SegmentScore(index=i, start=0, end=10, char_count=600, score=0.1,
|
||||||
|
suspicion_level="low", scored=True)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
assert det._infer_provenance(0.1, segs) == "human"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_edited_when_all_medium(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
|
||||||
|
segs = [
|
||||||
|
ad.SegmentScore(index=i, start=0, end=10, char_count=600, score=0.55,
|
||||||
|
suspicion_level="medium", scored=True)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
assert det._infer_provenance(0.55, segs) == "edited"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 학습 모델 경로 (stub estimator — sklearn/joblib 불필요)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _StubEstimator:
|
||||||
|
"""coef_ 를 가진 최소 선형 분류기 흉내. 첫 특징만 보고 확률을 낸다."""
|
||||||
|
|
||||||
|
def __init__(self, probability: float = 0.82):
|
||||||
|
self.probability = probability
|
||||||
|
self.coef_ = [[0.0] * len(FEATURE_NAMES)]
|
||||||
|
self.coef_[0][FEATURE_NAMES.index("cv_sentence_len")] = -1.5
|
||||||
|
self.coef_[0][FEATURE_NAMES.index("comma_per_sentence")] = 1.2
|
||||||
|
|
||||||
|
def predict_proba(self, X):
|
||||||
|
return [[1.0 - self.probability, self.probability] for _ in X]
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_detector(probability: float = 0.82, requires_pos: bool = False):
|
||||||
|
det = AiGenerationDetector(model_path="/nonexistent", allow_heuristic=False)
|
||||||
|
det.artifact = ModelArtifact(
|
||||||
|
estimator=_StubEstimator(probability),
|
||||||
|
feature_names=FEATURE_NAMES,
|
||||||
|
feature_set_version=ad.FEATURE_SET_VERSION,
|
||||||
|
model_version="stub-test-v1",
|
||||||
|
requires_pos=requires_pos,
|
||||||
|
low_cut=0.40,
|
||||||
|
high_cut=0.70,
|
||||||
|
metrics={},
|
||||||
|
)
|
||||||
|
return det
|
||||||
|
|
||||||
|
|
||||||
|
def test_trained_mode_reports_not_stub():
|
||||||
|
det = _stub_detector(0.82)
|
||||||
|
assert det.mode == "trained"
|
||||||
|
res = det.detect(UNIFORM_TEXT)
|
||||||
|
assert res.available is True
|
||||||
|
assert res.is_stub is False
|
||||||
|
assert res.model_version == "stub-test-v1"
|
||||||
|
assert res.score == pytest.approx(0.82)
|
||||||
|
assert res.suspicion_level == "high"
|
||||||
|
assert "확정 판정이 아닙니다" in res.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_trained_mode_uses_artifact_cuts():
|
||||||
|
assert _stub_detector(0.10).detect(UNIFORM_TEXT).suspicion_level == "low"
|
||||||
|
assert _stub_detector(0.55).detect(UNIFORM_TEXT).suspicion_level == "medium"
|
||||||
|
assert _stub_detector(0.95).detect(UNIFORM_TEXT).suspicion_level == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_linear_contributions_extracted_from_estimator():
|
||||||
|
det = _stub_detector()
|
||||||
|
res = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
names = {n for n, _ in res.top_contributions}
|
||||||
|
# 계수가 0이 아닌 특징이 상위에 올라와야 한다
|
||||||
|
assert {"cv_sentence_len", "comma_per_sentence"} & names
|
||||||
|
|
||||||
|
|
||||||
|
def test_pos_mismatch_warning_when_model_requires_pos(monkeypatch):
|
||||||
|
monkeypatch.setattr(ad, "_get_kiwi", lambda: None)
|
||||||
|
det = _stub_detector(requires_pos=True)
|
||||||
|
res = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
assert "pos_required_by_model_but_missing" in res.warnings
|
||||||
|
assert res.pos_available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_inference_failure_is_reported_not_swallowed():
|
||||||
|
class Exploding:
|
||||||
|
def predict_proba(self, X):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
det = _stub_detector()
|
||||||
|
det.artifact.estimator = Exploding()
|
||||||
|
res = det.detect(UNIFORM_TEXT)
|
||||||
|
assert res.available is False
|
||||||
|
assert res.score is None
|
||||||
|
assert "inference_failed" in res.warnings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 직렬화
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_to_dict_is_json_serializable():
|
||||||
|
det = _stub_detector()
|
||||||
|
payload = det.detect(LONG_TEXT).to_dict()
|
||||||
|
encoded = json.dumps(payload, ensure_ascii=False)
|
||||||
|
restored = json.loads(encoded)
|
||||||
|
assert restored["model_version"] == "stub-test-v1"
|
||||||
|
assert restored["is_stub"] is False
|
||||||
|
assert restored["provenance"] in ("human", "ai", "mixed", "edited", "unknown")
|
||||||
|
assert isinstance(restored["segments"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_dict_of_unavailable_result(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=False)
|
||||||
|
payload = det.detect(UNIFORM_TEXT).to_dict()
|
||||||
|
json.dumps(payload, ensure_ascii=False) # 예외 없으면 통과
|
||||||
|
assert payload["available"] is False
|
||||||
|
assert payload["score"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 싱글턴 접근자
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_ai_detector_respects_env(monkeypatch, tmp_path):
|
||||||
|
ad.reset_detector_cache()
|
||||||
|
monkeypatch.setenv("AI_DETECTOR_ALLOW_HEURISTIC", "true")
|
||||||
|
monkeypatch.setenv(ad.ENV_MODEL_PATH, str(tmp_path / "none.joblib"))
|
||||||
|
det = ad.get_ai_detector()
|
||||||
|
assert det.allow_heuristic is True
|
||||||
|
assert det.mode == "heuristic"
|
||||||
|
ad.reset_detector_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ai_detector_defaults_to_unavailable(monkeypatch, tmp_path):
|
||||||
|
ad.reset_detector_cache()
|
||||||
|
monkeypatch.delenv("AI_DETECTOR_ALLOW_HEURISTIC", raising=False)
|
||||||
|
monkeypatch.setenv(ad.ENV_MODEL_PATH, str(tmp_path / "none.joblib"))
|
||||||
|
det = ad.get_ai_detector()
|
||||||
|
assert det.allow_heuristic is False
|
||||||
|
assert det.mode == "unavailable"
|
||||||
|
ad.reset_detector_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 아티팩트 파일 검증 (joblib 있을 때만)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
try:
|
||||||
|
import joblib
|
||||||
|
except ImportError: # joblib 미설치 환경에서도 나머지 테스트는 돌아야 한다
|
||||||
|
joblib = None
|
||||||
|
|
||||||
|
requires_joblib = pytest.mark.skipif(
|
||||||
|
joblib is None, reason="joblib 미설치 — 파일 아티팩트 검증 생략"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@requires_joblib
|
||||||
|
def test_malformed_artifact_returns_none(tmp_path):
|
||||||
|
path = tmp_path / "bad.joblib"
|
||||||
|
joblib.dump({"not_an_estimator": 1}, path)
|
||||||
|
assert load_artifact(path) is None
|
||||||
|
|
||||||
|
|
||||||
|
@requires_joblib
|
||||||
|
def test_feature_mismatch_artifact_is_rejected(tmp_path):
|
||||||
|
path = tmp_path / "mismatch.joblib"
|
||||||
|
joblib.dump(
|
||||||
|
{"estimator": _StubEstimator(), "feature_names": ("only_one_feature",)},
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
assert load_artifact(path) is None, "특징 불일치 아티팩트는 로드 거부되어야 한다"
|
||||||
|
|
||||||
|
|
||||||
|
@requires_joblib
|
||||||
|
def test_valid_artifact_roundtrip(tmp_path):
|
||||||
|
path = tmp_path / "ok.joblib"
|
||||||
|
joblib.dump(
|
||||||
|
{
|
||||||
|
"estimator": _StubEstimator(0.66),
|
||||||
|
"feature_names": tuple(FEATURE_NAMES),
|
||||||
|
"feature_set_version": ad.FEATURE_SET_VERSION,
|
||||||
|
"model_version": "roundtrip-v1",
|
||||||
|
"requires_pos": False,
|
||||||
|
"low_cut": 0.4,
|
||||||
|
"high_cut": 0.7,
|
||||||
|
"metrics": {"test": {"auroc": 0.9}},
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
art = load_artifact(path)
|
||||||
|
assert art is not None
|
||||||
|
assert art.model_version == "roundtrip-v1"
|
||||||
|
det = AiGenerationDetector(model_path=path, allow_heuristic=False)
|
||||||
|
assert det.mode == "trained"
|
||||||
|
assert det.detect(UNIFORM_TEXT, with_segments=False).score == pytest.approx(0.66)
|
||||||
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
|
||||||
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))
|
||||||
Loading…
Reference in New Issue
Block a user