Initial commit: O2O 저작권 침해 여부 탐지 API
PDF v1.2 요구사항 반영 완료: - 10종 법령 메타 태그 + 39개 케이스 분류체계 - 3단 캐스케이딩: MinHash+LSH → 삼중 유사도 → 분류 - 자서전 특화: 공통 표현 사전 제거 + NER 마스킹 - KoSimCSE 한국어 임베딩 (자체 산출물 방어) - 보수적 임계값 0.85 - 검토 콘솔 UI (탐지 + 코퍼스 관리 탭) - Docker 배포 패키지 + 31개 테스트 통과main
commit
3b69bdf0f0
|
|
@ -0,0 +1,7 @@
|
|||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
.env
|
||||
.git
|
||||
tests
|
||||
scripts
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
API_KEYS=combooks-key-change-me,baikal-key-change-me
|
||||
ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
|
||||
REFERENCE_CORPUS_DIR=./data/reference
|
||||
TAXONOMY_DIR=./data/taxonomy
|
||||
AUTOBIOGRAPHY_PATTERNS_PATH=./data/autobiography/common_patterns.txt
|
||||
|
||||
# PDF VII-4 권장 보수적 임계값 (정밀도 우선)
|
||||
SIMILARITY_THRESHOLD=0.85
|
||||
|
||||
# KoSimCSE / KoSBERT (PDF VII-3 권장 - 한국어 오픈소스 임베딩, 자체 산출물)
|
||||
USE_KOSIMCSE=true
|
||||
KOSIMCSE_MODEL=BM-K/KoSimCSE-roberta-multitask
|
||||
KOSIMCSE_MAX_LENGTH=512
|
||||
|
||||
# OpenAI 연동 (옵션). KoSimCSE 있으면 임베딩은 비활성 권장.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_EXTRACTION_MODEL=gpt-4o-mini
|
||||
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
USE_LLM_EXTRACTOR=false
|
||||
USE_EMBEDDING_SIMILARITY=false
|
||||
|
||||
# 삼중 유사도 가중치 (합이 1.0)
|
||||
WEIGHT_TEXT_SIM=0.30
|
||||
WEIGHT_LEMMA_SIM=0.45
|
||||
WEIGHT_CHAR_SIM=0.15
|
||||
WEIGHT_MOTIF_SIM=0.10
|
||||
|
||||
# PDF VII-3 3단 캐스케이딩 - MinHash + LSH 1차 필터
|
||||
USE_LSH_FILTER=true
|
||||
LSH_THRESHOLD=0.3
|
||||
LSH_TOP_K=50
|
||||
|
||||
# PDF VII-4 자서전 특화 모드 (공통 표현 제거 + NER 마스킹)
|
||||
AUTOBIOGRAPHY_MODE=true
|
||||
ENABLE_ENTITY_MASKING=true
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# 환경 / 키 (민감) — 절대 커밋 금지
|
||||
.env
|
||||
.env.local
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# OS / 에디터
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# 평가 산출물 (재생성 가능)
|
||||
data/training/*.jsonl
|
||||
data/training/*.json
|
||||
data/training/*.csv
|
||||
!data/training/.gitkeep
|
||||
|
||||
# 사용자 업로드 자서전 (개인정보) — 샘플 외 제외
|
||||
data/reference/autobio-*.txt
|
||||
data/reference/corpus-*.txt
|
||||
|
||||
# 시각화 리포트 — PNG/MD 함께 보존 (재생성도 가능)
|
||||
|
||||
# 로그
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Hugging Face 모델 캐시
|
||||
.cache/
|
||||
~/.cache/huggingface/
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# kiwipiepy/scikit-learn 빌드에 필요한 시스템 패키지
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY data ./data
|
||||
|
||||
# 코퍼스 업로드 디렉토리는 볼륨 마운트 권장
|
||||
VOLUME ["/app/data/reference"]
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/v1/health').read()" || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
# O2O 저작권 침해 여부 탐지 API
|
||||
|
||||
KOCCA 출판환경변화 과제 2단계 - 오투오 산출물. 자서전 본문을 입력하면 저작권 침해 여부를 판정해 법령 기반 메타 태그와 케이스 ID로 반환합니다.
|
||||
|
||||
## 기능
|
||||
|
||||
- **삼중 유사도 탐지** — 표면(임베딩) + 구조(형태소 lemma 교집합) + 요소(인물·모티프) 결합 판정
|
||||
- **3단 캐스케이딩** — MinHash+LSH 1차 필터 → 정밀 비교 → 침해 유형 분류
|
||||
- **법령 기반 10종 메타 태그** — 복제권 / 공중송신권 / 배포권 / 2차적저작물작성권 / 공표권 / 성명표시권 / 동일성유지권 / 인용 표시 누락 / 자기 창작인 양 표시 / 2차적저작물 미달 가공
|
||||
- **39개 침해 케이스 자동 매핑** — 그룹 A(저자 가해) ~ X(분류체계 외)
|
||||
- **자서전 특화 처리** — 공통 표현 사전 제거 + NER 마스킹으로 오탐 방지
|
||||
- **검토 콘솔 웹 UI** — 브라우저에서 본문 입력·검사·결과 분석, 코퍼스 관리
|
||||
- **코퍼스 직접 관리** — 사용자가 자서전을 직접 업로드/삭제, 인덱스 자동 재빌드
|
||||
- **배치 처리** — 비동기 잡으로 최대 500건 일괄 검사
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env 에서 OPENAI_API_KEY 설정 (선택)
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### 로컬 (Python 3.11+)
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
접속:
|
||||
- `http://localhost:8000/` — 검토 콘솔 (브라우저용)
|
||||
- `http://localhost:8000/docs` — API 명세 (Swagger)
|
||||
|
||||
## 사용법
|
||||
|
||||
### 1. 브라우저에서 사용 (검토 콘솔)
|
||||
|
||||
`http://localhost:8000/` 접속.
|
||||
|
||||
**[탐지 검토] 탭**
|
||||
1. 본문 텍스트 붙여넣기 (또는 샘플 시나리오 버튼 클릭)
|
||||
2. "검사 시작" → 결과 표시
|
||||
- 큰 판정 배지 (침해 여부 + 결합 유사도 %)
|
||||
- 점수 분석 (text / lemma / character / motif 4개 막대)
|
||||
- 매칭된 레퍼런스 + **법령 태그 chip (주/보조)** + **케이스 ID**
|
||||
- 본문에 일치 구간 하이라이트
|
||||
|
||||
**[코퍼스 관리] 탭**
|
||||
1. 좌측 폼에서 자서전 신규 등록 (제목 + 본문 또는 .txt 파일)
|
||||
2. 우측 테이블에서 등록된 자서전 확인 / 삭제
|
||||
3. 업로드/삭제 시 인덱스 자동 재빌드
|
||||
|
||||
### 2. API로 사용 (curl)
|
||||
|
||||
```bash
|
||||
# 탐지
|
||||
curl -X POST http://localhost:8000/v1/plagiarism/detect \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: combooks-key-change-me" \
|
||||
-d '{
|
||||
"doc_id": "test-001",
|
||||
"text": "검사할 본문 텍스트…",
|
||||
"metadata": {"title": "작품명", "author": "저자"}
|
||||
}'
|
||||
|
||||
# 자서전 업로드
|
||||
curl -X POST http://localhost:8000/v1/corpus \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: combooks-key-change-me" \
|
||||
-d '{"title": "김OO 자서전", "text": "본문…"}'
|
||||
|
||||
# .txt 파일 업로드
|
||||
curl -X POST http://localhost:8000/v1/corpus/file \
|
||||
-H "X-API-Key: combooks-key-change-me" \
|
||||
-F "title=김OO 자서전" \
|
||||
-F "file=@autobiography.txt"
|
||||
|
||||
# 코퍼스 목록
|
||||
curl -H "X-API-Key: combooks-key-change-me" http://localhost:8000/v1/corpus
|
||||
|
||||
# 분류체계 조회 (10종 태그 + 39 케이스)
|
||||
curl http://localhost:8000/v1/taxonomy
|
||||
```
|
||||
|
||||
## 엔드포인트 요약
|
||||
|
||||
| Method | Path | 용도 | 인증 |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | 검토 콘솔 (HTML) | - |
|
||||
| GET | `/docs` | Swagger UI | - |
|
||||
| GET | `/v1/health` | 헬스체크 + 엔진 정보 | - |
|
||||
| GET | `/v1/taxonomy` | 분류체계 조회 | - |
|
||||
| POST | `/v1/plagiarism/detect` | 단건 동기 탐지 | ✅ |
|
||||
| POST | `/v1/plagiarism/batch` | 배치 잡 등록 (≤500건) | ✅ |
|
||||
| GET | `/v1/plagiarism/batch/{job_id}` | 배치 결과 조회 | ✅ |
|
||||
| GET | `/v1/corpus` | 코퍼스 목록 | ✅ |
|
||||
| POST | `/v1/corpus` | JSON 업로드 | ✅ |
|
||||
| POST | `/v1/corpus/file` | .txt 파일 업로드 | ✅ |
|
||||
| DELETE | `/v1/corpus/{doc_id}` | 삭제 | ✅ |
|
||||
|
||||
인증: `X-API-Key` 헤더에 발급된 키 (기관별 분리 발급 가능, 콤마 구분).
|
||||
|
||||
## 응답 스키마 (탐지 결과)
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "test-001",
|
||||
"is_infringement": true,
|
||||
"confidence": 0.85,
|
||||
"extracted_elements": {
|
||||
"characters": ["..."],
|
||||
"motifs": ["..."],
|
||||
"genre": "...",
|
||||
"keywords": ["..."]
|
||||
},
|
||||
"matches": [{
|
||||
"source_doc": "ref-0001",
|
||||
"source_title": "원본 작품명",
|
||||
"similarity": 0.85,
|
||||
"tags": [
|
||||
{"tag": "reproduction", "role": "primary", "label_ko": "복제권"},
|
||||
{"tag": "citation_missing", "role": "primary", "label_ko": "인용 표시 누락"},
|
||||
{"tag": "public_transmission", "role": "secondary", "label_ko": "공중송신권"}
|
||||
],
|
||||
"case_id": "A1",
|
||||
"case_title": "시·노래 가사 본문 무단 인용",
|
||||
"evidence_spans": [{"start": 21, "end": 34, "matched": "…"}],
|
||||
"score_breakdown": {
|
||||
"text_sim": 0.43, "lemma_sim": 0.75,
|
||||
"character_sim": 0.0, "motif_sim": 0.67,
|
||||
"lsh_jaccard": 0.07
|
||||
}
|
||||
}],
|
||||
"ccl_basis": "…",
|
||||
"autobiography_mode": true,
|
||||
"engine_version": "o2o-plagiarism-2.0.0-pdf-v1.2"
|
||||
}
|
||||
```
|
||||
|
||||
## 환경변수 (`.env`)
|
||||
|
||||
| 변수 | 기본 | 설명 |
|
||||
|---|---|---|
|
||||
| `API_KEYS` | `combooks-key-…,baikal-key-…` | 콤마 구분 다중 키 |
|
||||
| `SIMILARITY_THRESHOLD` | `0.85` | 침해 판정 임계값 (정밀도 우선) |
|
||||
| `AUTOBIOGRAPHY_MODE` | `true` | 자서전 특화 전처리 (공통표현+NER) |
|
||||
| `USE_LSH_FILTER` | `true` | MinHash+LSH 1차 필터 |
|
||||
| `USE_LLM_EXTRACTOR` | `false` | OpenAI 기반 요소 추출 (키 필요) |
|
||||
| `USE_EMBEDDING_SIMILARITY` | `false` | OpenAI 임베딩 유사도 (키 필요) |
|
||||
| `OPENAI_API_KEY` | (빈값) | 비우면 룰 기반 + TF-IDF 폴백 |
|
||||
| `WEIGHT_TEXT_SIM` 등 | 0.30/0.45/0.15/0.10 | 삼중 유사도 가중치 (합 1.0) |
|
||||
|
||||
요청 단위 override 가능 항목:
|
||||
- `options.threshold` — 임계값
|
||||
- `options.autobiography_mode` — 자서전 모드 ON/OFF
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
# 31 passed — API, 삼중 유사도, 형태소 분석, PDF 분류체계, LSH, 자서전 필터, 다중 태그 부여
|
||||
```
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
app/
|
||||
├── main.py # FastAPI 앱
|
||||
├── api/{routes,schemas}.py # 엔드포인트 + Pydantic 모델
|
||||
├── core/{config,auth}.py # 설정 + API Key 인증
|
||||
├── engine/
|
||||
│ ├── detector.py # 탐지 파이프라인 오케스트레이션
|
||||
│ ├── extractor.py # 콘텐츠 요소 추출 (룰 또는 OpenAI)
|
||||
│ ├── similarity.py # 삼중 유사도 + TF-IDF/임베딩 백엔드
|
||||
│ ├── structural.py # 형태소 lemma 교집합
|
||||
│ ├── lsh_filter.py # MinHash+LSH 1차 필터
|
||||
│ ├── autobiography_filter.py # 자서전 공통표현 제거 + NER 마스킹
|
||||
│ ├── corpus.py # 코퍼스 CRUD
|
||||
│ └── taxonomy.py # 10종 태그 + 39 케이스 매핑
|
||||
├── jobs/store.py # 배치 잡 in-memory 스토어
|
||||
└── static/index.html # 검토 콘솔 UI
|
||||
data/
|
||||
├── reference/ # 자서전 코퍼스 (업로드 저장 위치)
|
||||
├── taxonomy/{meta_tags,cases}.json # PDF v1.2 분류체계
|
||||
└── autobiography/common_patterns.txt # 자서전 빈출 표현 사전
|
||||
```
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
|
||||
from app.api.schemas import (
|
||||
BatchCreatedResponse,
|
||||
BatchRequest,
|
||||
BatchStatusResponse,
|
||||
CorpusItem,
|
||||
CorpusListResponse,
|
||||
CorpusUploadRequest,
|
||||
CorpusUploadResponse,
|
||||
DetectRequest,
|
||||
DetectResponse,
|
||||
HealthResponse,
|
||||
TaxonomyResponse,
|
||||
)
|
||||
from app.core.auth import require_api_key
|
||||
from app.core.config import get_settings
|
||||
from app.engine.corpus import add_document, delete_document, list_documents
|
||||
from app.engine.detector import PlagiarismDetector
|
||||
from app.jobs.store import JobStore
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
|
||||
def _detector(request: Request) -> PlagiarismDetector:
|
||||
return request.app.state.detector
|
||||
|
||||
|
||||
def _job_store(request: Request) -> JobStore:
|
||||
return request.app.state.job_store
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, tags=["meta"])
|
||||
async def health(request: Request) -> HealthResponse:
|
||||
settings = get_settings()
|
||||
det: PlagiarismDetector = request.app.state.detector
|
||||
taxonomy_version = None
|
||||
if det.taxonomy:
|
||||
taxonomy_version = f"meta_tags_{det.taxonomy.meta_tags_version}, cases_{det.taxonomy.cases_version}"
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
engine_version=settings.engine_version,
|
||||
corpus_size=det.corpus_size,
|
||||
taxonomy_version=taxonomy_version,
|
||||
autobiography_mode=settings.autobiography_mode,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/taxonomy", response_model=TaxonomyResponse, tags=["meta"])
|
||||
async def taxonomy(request: Request) -> TaxonomyResponse:
|
||||
"""분류체계 조회 - 컴북스/바이칼이 동일 라벨링 공유용."""
|
||||
det: PlagiarismDetector = request.app.state.detector
|
||||
if not det.taxonomy:
|
||||
raise HTTPException(status_code=503, detail="Taxonomy not loaded")
|
||||
return TaxonomyResponse(
|
||||
meta_tags_version=det.taxonomy.meta_tags_version,
|
||||
cases_version=det.taxonomy.cases_version,
|
||||
meta_tags=[
|
||||
{"id": t.id, "label_ko": t.label_ko, "category": t.category,
|
||||
"law_ref": t.law_ref, "scope": t.scope, "description": t.description}
|
||||
for t in det.taxonomy.meta_tags
|
||||
],
|
||||
cases=[
|
||||
{"case_id": c.case_id, "old_no": c.old_no, "subgroup": c.subgroup,
|
||||
"title": c.title, "actor": c.actor,
|
||||
"primary_tags": list(c.primary_tags), "secondary_tags": list(c.secondary_tags),
|
||||
"detectable_internal": c.detectable_internal, "high_risk": c.high_risk,
|
||||
"note": c.note}
|
||||
for c in det.taxonomy.cases
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plagiarism/detect",
|
||||
response_model=DetectResponse,
|
||||
tags=["plagiarism"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def detect(req: DetectRequest, request: Request) -> DetectResponse:
|
||||
return _detector(request).detect_request(req)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plagiarism/batch",
|
||||
response_model=BatchCreatedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["plagiarism"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def batch_create(
|
||||
req: BatchRequest,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> BatchCreatedResponse:
|
||||
store = _job_store(request)
|
||||
detector = _detector(request)
|
||||
job = store.create(total=len(req.items))
|
||||
background_tasks.add_task(_run_batch, store, detector, job.job_id, req)
|
||||
return BatchCreatedResponse(
|
||||
job_id=job.job_id,
|
||||
status=job.status,
|
||||
total=job.total,
|
||||
created_at=job.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plagiarism/batch/{job_id}",
|
||||
response_model=BatchStatusResponse,
|
||||
tags=["plagiarism"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def batch_status(job_id: str, request: Request) -> BatchStatusResponse:
|
||||
job = _job_store(request).get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return BatchStatusResponse(
|
||||
job_id=job.job_id,
|
||||
status=job.status,
|
||||
total=job.total,
|
||||
processed=job.processed,
|
||||
created_at=job.created_at,
|
||||
finished_at=job.finished_at,
|
||||
results=job.results if job.status == "completed" else None,
|
||||
error=job.error,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 코퍼스 관리 ----------
|
||||
|
||||
def _rebuild(request: Request) -> int:
|
||||
from app.main import rebuild_detector
|
||||
return rebuild_detector(request.app)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/corpus",
|
||||
response_model=CorpusListResponse,
|
||||
tags=["corpus"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def corpus_list(request: Request) -> CorpusListResponse:
|
||||
settings = get_settings()
|
||||
docs = list_documents(settings.corpus_path)
|
||||
return CorpusListResponse(
|
||||
total=len(docs),
|
||||
docs=[CorpusItem(**d) for d in docs],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/corpus",
|
||||
response_model=CorpusUploadResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["corpus"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
|
||||
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
|
||||
settings = get_settings()
|
||||
try:
|
||||
doc = add_document(settings.corpus_path, 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))
|
||||
|
||||
new_size = _rebuild(request)
|
||||
return CorpusUploadResponse(
|
||||
doc_id=doc.doc_id, title=doc.title,
|
||||
size_bytes=len(doc.text.encode("utf-8")),
|
||||
corpus_size_after=new_size, rebuilt=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/corpus/file",
|
||||
response_model=CorpusUploadResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["corpus"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def corpus_upload_file(
|
||||
request: Request,
|
||||
title: str = Form(..., description="자서전 제목"),
|
||||
doc_id: str | None = Form(default=None, description="비우면 자동 생성"),
|
||||
file: UploadFile = File(..., description=".txt 파일"),
|
||||
) -> CorpusUploadResponse:
|
||||
"""multipart로 .txt 파일 업로드 (큰 자서전 파일용)."""
|
||||
settings = get_settings()
|
||||
raw = await file.read()
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.")
|
||||
|
||||
try:
|
||||
doc = add_document(settings.corpus_path, 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))
|
||||
|
||||
new_size = _rebuild(request)
|
||||
return CorpusUploadResponse(
|
||||
doc_id=doc.doc_id, title=doc.title,
|
||||
size_bytes=len(doc.text.encode("utf-8")),
|
||||
corpus_size_after=new_size, rebuilt=True,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/corpus/{doc_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
tags=["corpus"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
async def corpus_delete(doc_id: str, request: Request) -> None:
|
||||
settings = get_settings()
|
||||
if not delete_document(settings.corpus_path, doc_id):
|
||||
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
|
||||
_rebuild(request)
|
||||
|
||||
|
||||
def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: BatchRequest) -> None:
|
||||
store.update(job_id, status="running")
|
||||
try:
|
||||
for item in req.items:
|
||||
result = detector.detect(
|
||||
doc_id=item.doc_id,
|
||||
text=item.text,
|
||||
metadata=item.metadata,
|
||||
options=req.options,
|
||||
)
|
||||
store.append_result(job_id, result)
|
||||
store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc))
|
||||
except Exception as exc:
|
||||
store.update(
|
||||
job_id,
|
||||
status="failed",
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
error=str(exc),
|
||||
)
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 법령 기반 10종 메타 태그 (PDF IV장)
|
||||
LegalTag = Literal[
|
||||
"reproduction", # 복제권
|
||||
"public_transmission", # 공중송신권
|
||||
"distribution", # 배포권
|
||||
"derivative_work", # 2차적저작물작성권
|
||||
"publication", # 공표권
|
||||
"attribution", # 성명표시권
|
||||
"integrity", # 동일성유지권
|
||||
"citation_missing", # 인용 표시 누락
|
||||
"false_authorship", # 자기 창작인 양 표시
|
||||
"substandard_derivative", # 2차적저작물 미달 가공
|
||||
]
|
||||
|
||||
TAG_LABEL_KO: dict[str, str] = {
|
||||
"reproduction": "복제권",
|
||||
"public_transmission": "공중송신권",
|
||||
"distribution": "배포권",
|
||||
"derivative_work": "2차적저작물작성권",
|
||||
"publication": "공표권",
|
||||
"attribution": "성명표시권",
|
||||
"integrity": "동일성유지권",
|
||||
"citation_missing": "인용 표시 누락",
|
||||
"false_authorship": "자기 창작인 양 표시",
|
||||
"substandard_derivative": "2차적저작물 미달 가공",
|
||||
}
|
||||
|
||||
# 후방 호환용
|
||||
InfringementType = Literal[
|
||||
"copy", "transform", "plot", "character", "background", "unknown",
|
||||
]
|
||||
|
||||
|
||||
class DocumentMetadata(BaseModel):
|
||||
title: str | None = None
|
||||
author: str | None = None
|
||||
genre: str | None = None
|
||||
publisher: str | None = None
|
||||
publication_year: int | None = None
|
||||
|
||||
|
||||
class DetectOptions(BaseModel):
|
||||
return_evidence: bool = True
|
||||
threshold: float | None = Field(default=None, ge=0.0, le=1.0,
|
||||
description="None이면 서버 설정 사용. PDF VII-4 권장 0.85")
|
||||
top_k: int = Field(default=5, ge=1, le=50)
|
||||
autobiography_mode: bool | None = Field(
|
||||
default=None,
|
||||
description="None이면 서버 설정 사용. 명시하면 요청 단위 override.",
|
||||
)
|
||||
|
||||
|
||||
class DetectRequest(BaseModel):
|
||||
doc_id: str
|
||||
text: str = Field(..., min_length=1)
|
||||
metadata: DocumentMetadata | None = None
|
||||
options: DetectOptions = Field(default_factory=DetectOptions)
|
||||
|
||||
|
||||
class EvidenceSpan(BaseModel):
|
||||
start: int
|
||||
end: int
|
||||
matched: str
|
||||
|
||||
|
||||
class InfringementTag(BaseModel):
|
||||
"""법령 기반 침해 태그. 주(primary) 또는 보조(secondary) 역할."""
|
||||
tag: LegalTag
|
||||
role: Literal["primary", "secondary"]
|
||||
label_ko: str
|
||||
|
||||
|
||||
class ScoreBreakdown(BaseModel):
|
||||
text_sim: float = Field(..., ge=0.0, le=1.0)
|
||||
lemma_sim: float = Field(..., ge=0.0, le=1.0)
|
||||
character_sim: float = Field(..., ge=0.0, le=1.0)
|
||||
motif_sim: float = Field(..., ge=0.0, le=1.0)
|
||||
lsh_jaccard: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class MatchResult(BaseModel):
|
||||
source_doc: str
|
||||
source_title: str | None = None
|
||||
similarity: float = Field(..., ge=0.0, le=1.0)
|
||||
tags: list[InfringementTag] = Field(default_factory=list)
|
||||
case_id: str | None = None
|
||||
case_title: str | None = None
|
||||
infringement_type: InfringementType = "unknown"
|
||||
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
|
||||
score_breakdown: ScoreBreakdown | None = None
|
||||
|
||||
|
||||
class ExtractedElements(BaseModel):
|
||||
characters: list[str] = Field(default_factory=list)
|
||||
motifs: list[str] = Field(default_factory=list)
|
||||
genre: str | None = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DetectResponse(BaseModel):
|
||||
doc_id: str
|
||||
is_infringement: bool
|
||||
confidence: float = Field(..., ge=0.0, le=1.0)
|
||||
extracted_elements: ExtractedElements
|
||||
matches: list[MatchResult]
|
||||
ccl_basis: str | None = None
|
||||
autobiography_mode: bool = False
|
||||
candidates_before_filter: int | None = None
|
||||
engine_version: str
|
||||
analyzed_at: datetime
|
||||
|
||||
|
||||
class BatchItem(BaseModel):
|
||||
doc_id: str
|
||||
text: str
|
||||
metadata: DocumentMetadata | None = None
|
||||
|
||||
|
||||
class BatchRequest(BaseModel):
|
||||
items: list[BatchItem] = Field(..., min_length=1, max_length=500)
|
||||
options: DetectOptions = Field(default_factory=DetectOptions)
|
||||
|
||||
|
||||
class BatchCreatedResponse(BaseModel):
|
||||
job_id: str
|
||||
status: Literal["queued", "running", "completed", "failed"]
|
||||
total: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BatchStatusResponse(BaseModel):
|
||||
job_id: str
|
||||
status: Literal["queued", "running", "completed", "failed"]
|
||||
total: int
|
||||
processed: int
|
||||
created_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
results: list[DetectResponse] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok"]
|
||||
engine_version: str
|
||||
corpus_size: int
|
||||
taxonomy_version: str | None = None
|
||||
autobiography_mode: bool = False
|
||||
|
||||
|
||||
class TaxonomyResponse(BaseModel):
|
||||
meta_tags_version: str
|
||||
cases_version: str
|
||||
meta_tags: list[dict]
|
||||
cases: list[dict]
|
||||
|
||||
|
||||
class CorpusItem(BaseModel):
|
||||
doc_id: str
|
||||
title: str
|
||||
size_bytes: int = 0
|
||||
filename: str | None = None
|
||||
|
||||
|
||||
class CorpusListResponse(BaseModel):
|
||||
total: int
|
||||
docs: list[CorpusItem]
|
||||
|
||||
|
||||
class CorpusUploadRequest(BaseModel):
|
||||
doc_id: str | None = Field(default=None, description="비우면 자동 생성")
|
||||
title: str = Field(..., min_length=1)
|
||||
text: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class CorpusUploadResponse(BaseModel):
|
||||
doc_id: str
|
||||
title: str
|
||||
size_bytes: int
|
||||
corpus_size_after: int
|
||||
rebuilt: bool
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
async def require_api_key(x_api_key: str | None = Header(default=None)) -> str:
|
||||
settings = get_settings()
|
||||
if not x_api_key or x_api_key not in settings.api_key_set:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing X-API-Key header",
|
||||
)
|
||||
return x_api_key
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
api_keys: str = "combooks-key-change-me,baikal-key-change-me"
|
||||
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
|
||||
reference_corpus_dir: str = "./data/reference"
|
||||
taxonomy_dir: str = "./data/taxonomy"
|
||||
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt"
|
||||
|
||||
# PDF VII-4 권장: 정밀도 우선 보수적 임계값
|
||||
similarity_threshold: float = 0.85
|
||||
|
||||
# KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩
|
||||
use_kosimcse: bool = True
|
||||
kosimcse_model: str = "BM-K/KoSimCSE-roberta-multitask"
|
||||
kosimcse_max_length: int = 512
|
||||
|
||||
# OpenAI (옵션 - 자체 모델 없을 때 폴백)
|
||||
openai_api_key: str = ""
|
||||
openai_extraction_model: str = "gpt-4o-mini"
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
use_llm_extractor: bool = False
|
||||
use_embedding_similarity: bool = False
|
||||
|
||||
# 삼중 유사도 가중치 (실측 기반)
|
||||
weight_text_sim: float = 0.30
|
||||
weight_lemma_sim: float = 0.45
|
||||
weight_char_sim: float = 0.15
|
||||
weight_motif_sim: float = 0.10
|
||||
|
||||
# PDF VII-3 캐스케이딩
|
||||
use_lsh_filter: bool = True
|
||||
lsh_threshold: float = 0.3 # 1차 필터는 느슨하게 (재현율 우선)
|
||||
lsh_top_k: int = 50
|
||||
|
||||
# PDF VII-4 자서전 모드
|
||||
autobiography_mode: bool = True
|
||||
enable_entity_masking: bool = True
|
||||
|
||||
@property
|
||||
def api_key_set(self) -> set[str]:
|
||||
return {k.strip() for k in self.api_keys.split(",") if k.strip()}
|
||||
|
||||
@property
|
||||
def corpus_path(self) -> Path:
|
||||
return Path(self.reference_corpus_dir).resolve()
|
||||
|
||||
@property
|
||||
def taxonomy_path(self) -> Path:
|
||||
return Path(self.taxonomy_dir).resolve()
|
||||
|
||||
@property
|
||||
def has_openai(self) -> bool:
|
||||
return bool(self.openai_api_key.strip())
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"""자서전 특화 오탐 방지 (PDF VII-4).
|
||||
|
||||
자서전은 공통 경험(초등학교 입학, 결혼식, 군 입대 등)으로 인해
|
||||
표면 유사도가 자연스럽게 높아져 거짓 양성이 폭증한다.
|
||||
|
||||
본 모듈은 비교 전 전처리 단계로:
|
||||
1) 공통 표현 사전 (data/autobiography/common_patterns.txt) 제거
|
||||
2) 엔티티 마스킹 (NNP 고유명사 → [PERSON], 숫자/날짜 → [DATE], [NUM])
|
||||
|
||||
운영 모드(AUTOBIOGRAPHY_MODE=true)에서만 활성화. 일반 출판 검토 시에는 비활성.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from kiwipiepy import Kiwi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _kiwi() -> Kiwi:
|
||||
return Kiwi()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _common_patterns(path_str: str) -> list[str]:
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
logger.warning("Autobiography common patterns file not found: %s", path)
|
||||
return []
|
||||
patterns: list[str] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
patterns.append(line)
|
||||
# 긴 패턴 먼저 (overlap 방지)
|
||||
patterns.sort(key=len, reverse=True)
|
||||
logger.info("Loaded %d autobiography common patterns from %s", len(patterns), path)
|
||||
return patterns
|
||||
|
||||
|
||||
def remove_common_patterns(text: str, patterns_path: str) -> str:
|
||||
"""공통 표현 제거. 매칭된 자리를 공백으로 치환."""
|
||||
result = text
|
||||
for p in _common_patterns(patterns_path):
|
||||
result = result.replace(p, " ")
|
||||
return re.sub(r"\s+", " ", result).strip()
|
||||
|
||||
|
||||
# 마스킹 토큰 — 자카드 비교 시 동일 토큰으로 일치 처리
|
||||
_TOKEN_PERSON = "[PERSON]"
|
||||
_TOKEN_PLACE = "[PLACE]"
|
||||
_TOKEN_DATE = "[DATE]"
|
||||
_TOKEN_NUM = "[NUM]"
|
||||
|
||||
_DATE_PATTERN = re.compile(r"\b(19|20)\d{2}\s*년|\d{1,2}\s*월\s*\d{1,2}\s*일|\d{4}-\d{2}-\d{2}")
|
||||
_NUM_PATTERN = re.compile(r"\b\d{2,}\b")
|
||||
|
||||
|
||||
def mask_entities(text: str) -> str:
|
||||
"""인명/지명/날짜/숫자 마스킹.
|
||||
|
||||
PDF VII-4: "1단계 NER 자산(메타데이터 추출 F1 81%) 활용해 마스킹".
|
||||
kiwipiepy의 형태소 태그를 NER 대용으로 사용:
|
||||
NNP (고유명사) → [PERSON] or [PLACE] 추정 (구분 어려움 → 모두 PERSON으로)
|
||||
위치 표지(-시, -구, -동, -로) 패턴 → [PLACE]
|
||||
"""
|
||||
# 날짜·숫자 먼저 (kiwi 토큰화 전에)
|
||||
masked = _DATE_PATTERN.sub(_TOKEN_DATE, text)
|
||||
masked = _NUM_PATTERN.sub(_TOKEN_NUM, masked)
|
||||
|
||||
# 형태소 분석 → NNP 마스킹
|
||||
tokens = _kiwi().tokenize(masked)
|
||||
parts: list[tuple[int, int, str]] = [] # (start, end, replacement)
|
||||
for t in tokens:
|
||||
if t.tag == "NNP":
|
||||
# 지명 휴리스틱: 다음 토큰이 지역 접미사면 PLACE
|
||||
replacement = _TOKEN_PERSON # 단순화: 모두 PERSON
|
||||
parts.append((t.start, t.start + t.len, replacement))
|
||||
|
||||
if not parts:
|
||||
return masked
|
||||
|
||||
# 뒤에서부터 치환 (인덱스 보존)
|
||||
parts.sort(key=lambda p: p[0], reverse=True)
|
||||
chars = list(masked)
|
||||
for start, end, repl in parts:
|
||||
chars[start:end] = list(repl)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def preprocess_for_autobiography(text: str, patterns_path: str, enable_mask: bool = True) -> str:
|
||||
"""자서전 모드 전처리 = 공통 표현 제거 + 엔티티 마스킹."""
|
||||
if not text:
|
||||
return text
|
||||
cleaned = remove_common_patterns(text, patterns_path)
|
||||
if enable_mask:
|
||||
cleaned = mask_entities(cleaned)
|
||||
return cleaned
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"""레퍼런스 코퍼스 로더 + 관리(CRUD).
|
||||
|
||||
저장 형식: data/reference/<doc_id>__<title>.txt
|
||||
컴북스가 자서전을 업로드하면 이 디렉토리에 파일 생성 → detector 재빌드 트리거.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReferenceDoc:
|
||||
doc_id: str
|
||||
title: str
|
||||
text: str
|
||||
|
||||
|
||||
def load_corpus(directory: Path) -> list[ReferenceDoc]:
|
||||
if not directory.exists():
|
||||
logger.warning("Reference corpus dir %s does not exist; running with empty corpus", directory)
|
||||
return []
|
||||
|
||||
docs: list[ReferenceDoc] = []
|
||||
for path in sorted(directory.glob("*.txt")):
|
||||
stem = path.stem
|
||||
if "__" in stem:
|
||||
doc_id, title = stem.split("__", 1)
|
||||
else:
|
||||
doc_id, title = stem, stem
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("Skipping non-utf8 file: %s", path)
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
docs.append(ReferenceDoc(doc_id=doc_id, title=title, text=text))
|
||||
|
||||
logger.info("Loaded %d reference docs from %s", len(docs), directory)
|
||||
return docs
|
||||
|
||||
|
||||
# ---------- CRUD ----------
|
||||
|
||||
_INVALID_FN = re.compile(r"[^\w가-힣\-_.]")
|
||||
|
||||
|
||||
def _safe_filename(s: str) -> str:
|
||||
"""파일명 안전화: 한글/영숫자/일부 기호만 허용."""
|
||||
return _INVALID_FN.sub("_", s).strip("._")
|
||||
|
||||
|
||||
def _path_for(directory: Path, doc_id: str, title: str) -> Path:
|
||||
fn = f"{_safe_filename(doc_id)}__{_safe_filename(title)}.txt"
|
||||
return directory / fn
|
||||
|
||||
|
||||
def add_document(directory: Path, doc_id: str | None, title: str, text: str) -> ReferenceDoc:
|
||||
"""신규 자서전 추가. doc_id None이면 자동 생성."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not doc_id or not doc_id.strip():
|
||||
doc_id = f"corpus-{uuid.uuid4().hex[:8]}"
|
||||
doc_id = doc_id.strip()
|
||||
title = (title or doc_id).strip()
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("text is empty")
|
||||
|
||||
# 중복 검사
|
||||
for existing in directory.glob(f"{_safe_filename(doc_id)}__*.txt"):
|
||||
raise FileExistsError(f"doc_id '{doc_id}' already exists at {existing}")
|
||||
|
||||
target = _path_for(directory, doc_id, title)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
logger.info("Added corpus doc: %s (%s) → %s", doc_id, title, target.name)
|
||||
return ReferenceDoc(doc_id=doc_id, title=title, text=text)
|
||||
|
||||
|
||||
def delete_document(directory: Path, doc_id: str) -> bool:
|
||||
"""doc_id에 해당하는 파일 삭제. 삭제 성공 시 True."""
|
||||
safe = _safe_filename(doc_id)
|
||||
deleted = False
|
||||
for path in directory.glob(f"{safe}__*.txt"):
|
||||
path.unlink()
|
||||
deleted = True
|
||||
logger.info("Deleted corpus doc: %s", path.name)
|
||||
return deleted
|
||||
|
||||
|
||||
def list_documents(directory: Path) -> list[dict]:
|
||||
"""가벼운 목록 조회 (본문 미포함)."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
out = []
|
||||
for path in sorted(directory.glob("*.txt")):
|
||||
stem = path.stem
|
||||
if "__" in stem:
|
||||
doc_id, title = stem.split("__", 1)
|
||||
else:
|
||||
doc_id, title = stem, stem
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
out.append({"doc_id": doc_id, "title": title, "size_bytes": size, "filename": path.name})
|
||||
return out
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
"""저작권 침해 탐지 파이프라인 (PDF VII장 권장 아키텍처).
|
||||
|
||||
3단 캐스케이딩:
|
||||
1차) MinHash + LSH 1차 필터 — 대규모 코퍼스에서 후보 N건 빠르게 추출
|
||||
2차) 자서전 모드 전처리 (옵션) — 공통 표현 제거 + NER 마스킹
|
||||
3차) 삼중 유사도 정밀 비교 — text(임베딩) + lemma(형태소) + element(자카드)
|
||||
4차) 분류 — 10종 법령 메타 태그 (주/보조) + 케이스 매핑
|
||||
|
||||
후방 호환:
|
||||
- infringement_type (5종 enum): 기존 UI/통합 코드용으로 유지
|
||||
- tags + case_id: PDF 분류체계 신규 필드
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.api.schemas import (
|
||||
TAG_LABEL_KO,
|
||||
DetectOptions,
|
||||
DetectRequest,
|
||||
DetectResponse,
|
||||
DocumentMetadata,
|
||||
InfringementTag,
|
||||
InfringementType,
|
||||
MatchResult,
|
||||
ScoreBreakdown,
|
||||
)
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.engine.autobiography_filter import preprocess_for_autobiography
|
||||
from app.engine.corpus import load_corpus
|
||||
from app.engine.extractor import Extractor, get_extractor
|
||||
from app.engine.lsh_filter import LshIndex
|
||||
from app.engine.similarity import (
|
||||
DualSimilarityIndex,
|
||||
SimilarityHit,
|
||||
build_text_backend,
|
||||
)
|
||||
from app.engine.structural import extract_lemmas
|
||||
from app.engine.taxonomy import Taxonomy, load_taxonomy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlagiarismDetector:
|
||||
def __init__(self, settings: Settings | None = None, extractor: Extractor | None = None):
|
||||
self.settings = settings or get_settings()
|
||||
self._extractor: Extractor = extractor or get_extractor(self.settings)
|
||||
self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path)
|
||||
|
||||
self._corpus = load_corpus(self.settings.corpus_path)
|
||||
|
||||
# 자서전 모드면 코퍼스도 동일 전처리 적용 후 인덱싱
|
||||
logger.info("Building corpus indexes (autobiography_mode=%s)", self.settings.autobiography_mode)
|
||||
if self.settings.autobiography_mode:
|
||||
self._corpus_preprocessed_texts = [
|
||||
preprocess_for_autobiography(
|
||||
d.text,
|
||||
self.settings.autobiography_patterns_path,
|
||||
self.settings.enable_entity_masking,
|
||||
)
|
||||
for d in self._corpus
|
||||
]
|
||||
else:
|
||||
self._corpus_preprocessed_texts = [d.text for d in self._corpus]
|
||||
|
||||
# 정밀 비교용 인덱스 (전처리된 텍스트 사용)
|
||||
from app.engine.corpus import ReferenceDoc
|
||||
preprocessed_docs = [
|
||||
ReferenceDoc(doc_id=d.doc_id, title=d.title, text=pt)
|
||||
for d, pt in zip(self._corpus, self._corpus_preprocessed_texts)
|
||||
]
|
||||
self._corpus_elements = [self._extractor.extract(t) for t in self._corpus_preprocessed_texts]
|
||||
self._corpus_lemmas = [extract_lemmas(t) for t in self._corpus_preprocessed_texts]
|
||||
text_backend = build_text_backend(preprocessed_docs, self.settings)
|
||||
self._index = DualSimilarityIndex(
|
||||
docs=preprocessed_docs,
|
||||
doc_elements=self._corpus_elements,
|
||||
doc_lemmas=self._corpus_lemmas,
|
||||
settings=self.settings,
|
||||
text_backend=text_backend,
|
||||
)
|
||||
|
||||
# 1차 LSH 필터 (PDF VII-3)
|
||||
self._lsh: LshIndex | None = None
|
||||
if self.settings.use_lsh_filter:
|
||||
self._lsh = LshIndex(preprocessed_docs, threshold=self.settings.lsh_threshold)
|
||||
|
||||
# source_doc → ReferenceDoc 매핑
|
||||
self._docs_by_id = {d.doc_id: d for d in self._corpus}
|
||||
|
||||
@property
|
||||
def corpus_size(self) -> int:
|
||||
return len(self._corpus)
|
||||
|
||||
def detect(
|
||||
self,
|
||||
doc_id: str,
|
||||
text: str,
|
||||
metadata: DocumentMetadata | None = None,
|
||||
options: DetectOptions | None = None,
|
||||
) -> DetectResponse:
|
||||
opts = options or DetectOptions()
|
||||
threshold = opts.threshold if opts.threshold is not None else self.settings.similarity_threshold
|
||||
|
||||
# 요청 단위 자서전 모드 override
|
||||
autobio_mode = (
|
||||
self.settings.autobiography_mode if opts.autobiography_mode is None
|
||||
else opts.autobiography_mode
|
||||
)
|
||||
|
||||
# 자서전 모드 전처리
|
||||
query_text = (
|
||||
preprocess_for_autobiography(
|
||||
text, self.settings.autobiography_patterns_path,
|
||||
self.settings.enable_entity_masking,
|
||||
)
|
||||
if autobio_mode else text
|
||||
)
|
||||
|
||||
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
|
||||
elements = self._extractor.extract(text)
|
||||
|
||||
# 1차 LSH 필터 (옵션)
|
||||
candidate_ids: set[str] | None = None
|
||||
candidates_count: int | None = None
|
||||
lsh_jaccards: dict[str, float] = {}
|
||||
if self._lsh:
|
||||
cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k)
|
||||
candidate_ids = {c.doc_id for c in cands}
|
||||
candidates_count = len(cands)
|
||||
lsh_jaccards = {c.doc_id: c.jaccard for c in cands}
|
||||
|
||||
# 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔)
|
||||
hits = self._index.query(query_text, elements, top_k=opts.top_k)
|
||||
if candidate_ids is not None:
|
||||
hits = [h for h in hits if h.doc_id in candidate_ids]
|
||||
|
||||
matches = [
|
||||
self._to_match(h, opts.return_evidence, lsh_jaccards.get(h.doc_id))
|
||||
for h in hits if h.score >= threshold
|
||||
]
|
||||
confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0)
|
||||
is_infringement = bool(matches)
|
||||
ccl_basis = self._build_ccl_basis(matches) if is_infringement else None
|
||||
|
||||
return DetectResponse(
|
||||
doc_id=doc_id,
|
||||
is_infringement=is_infringement,
|
||||
confidence=round(confidence, 4),
|
||||
extracted_elements=elements,
|
||||
matches=matches,
|
||||
ccl_basis=ccl_basis,
|
||||
autobiography_mode=autobio_mode,
|
||||
candidates_before_filter=candidates_count,
|
||||
engine_version=self.settings.engine_version,
|
||||
analyzed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def detect_request(self, req: DetectRequest) -> DetectResponse:
|
||||
return self.detect(req.doc_id, req.text, req.metadata, req.options)
|
||||
|
||||
def _to_match(self, hit: SimilarityHit, return_evidence: bool, lsh_j: float | None) -> MatchResult:
|
||||
legacy_type = _classify_legacy(hit)
|
||||
tags = self._assign_tags(hit, legacy_type)
|
||||
case = self.taxonomy.find_case([t.tag for t in tags if t.role == "primary"]) if self.taxonomy else None
|
||||
|
||||
return MatchResult(
|
||||
source_doc=hit.doc_id,
|
||||
source_title=hit.title,
|
||||
similarity=round(hit.score, 4),
|
||||
tags=tags,
|
||||
case_id=case.case_id if case else None,
|
||||
case_title=case.title if case else None,
|
||||
infringement_type=legacy_type,
|
||||
evidence_spans=hit.evidence if return_evidence else [],
|
||||
score_breakdown=ScoreBreakdown(
|
||||
text_sim=round(hit.text_sim, 4),
|
||||
lemma_sim=round(hit.lemma_sim, 4),
|
||||
character_sim=round(hit.element_sim.get("characters", 0.0), 4),
|
||||
motif_sim=round(hit.element_sim.get("motifs", 0.0), 4),
|
||||
lsh_jaccard=round(lsh_j, 4) if lsh_j is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
def _assign_tags(self, hit: SimilarityHit, legacy: InfringementType) -> list[InfringementTag]:
|
||||
"""삼중 유사도 분포 → 10종 법령 태그(주/보조) 매핑.
|
||||
|
||||
규칙(PDF IX장 매핑표 기반):
|
||||
- copy/패러프레이즈 수준 표절 (lemma↑ or text↑) → 복제권(주) + 공중송신권(보조)
|
||||
- lemma만 매우 높음 → 인용 표시 누락(주 보조)
|
||||
- 인물 일치도 매우 높음(서사·구조 차용) → 2차적저작물작성권(주) + 자기창작인양표시(보조)
|
||||
- 구조 미달 가공 신호 (text 낮음 + lemma 중간) → 2차적저작물 미달 가공
|
||||
"""
|
||||
text_sim = hit.text_sim
|
||||
lemma_sim = hit.lemma_sim
|
||||
char_sim = hit.element_sim.get("characters", 0.0)
|
||||
motif_sim = hit.element_sim.get("motifs", 0.0)
|
||||
|
||||
primary: list[str] = []
|
||||
secondary: list[str] = []
|
||||
|
||||
# 복제권: 표면 또는 lemma가 강하게 일치
|
||||
if lemma_sim >= 0.70 or text_sim >= 0.70:
|
||||
primary.append("reproduction")
|
||||
secondary.append("public_transmission") # 전자책 게재 가정
|
||||
# 표절 실무 - 인용 누락
|
||||
primary.append("citation_missing")
|
||||
|
||||
# 2차적저작물작성권: 구조·서사 차용 (인물/모티프 일치 + 표면은 낮음)
|
||||
elif (char_sim >= 0.40 or motif_sim >= 0.50) and text_sim < 0.40:
|
||||
primary.append("derivative_work")
|
||||
secondary.append("attribution")
|
||||
secondary.append("citation_missing")
|
||||
# 미달 가공 가능성
|
||||
if text_sim < 0.30 and lemma_sim < 0.50:
|
||||
secondary.append("substandard_derivative")
|
||||
|
||||
# 부분 변형 (text 중간 + 인물 일치)
|
||||
elif text_sim >= 0.40 and char_sim >= 0.30:
|
||||
primary.append("reproduction")
|
||||
secondary.append("derivative_work")
|
||||
secondary.append("citation_missing")
|
||||
|
||||
# 낮은 매칭이지만 임계 통과한 경우
|
||||
else:
|
||||
secondary.append("reproduction")
|
||||
|
||||
# 중복 제거 + 태그 객체화
|
||||
primary = list(dict.fromkeys(primary))
|
||||
secondary = [s for s in dict.fromkeys(secondary) if s not in primary]
|
||||
|
||||
out: list[InfringementTag] = []
|
||||
for t in primary:
|
||||
out.append(InfringementTag(tag=t, role="primary", label_ko=TAG_LABEL_KO[t]))
|
||||
for t in secondary:
|
||||
out.append(InfringementTag(tag=t, role="secondary", label_ko=TAG_LABEL_KO[t]))
|
||||
return out
|
||||
|
||||
def _build_ccl_basis(self, matches: list[MatchResult]) -> str:
|
||||
top = matches[0]
|
||||
sb = top.score_breakdown
|
||||
breakdown = ""
|
||||
if sb:
|
||||
breakdown = (
|
||||
f" [text={sb.text_sim:.2f} / lemma={sb.lemma_sim:.2f} "
|
||||
f"/ char={sb.character_sim:.2f} / motif={sb.motif_sim:.2f}]"
|
||||
)
|
||||
primary_labels = [t.label_ko for t in top.tags if t.role == "primary"]
|
||||
tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요"
|
||||
case_part = f" 추정 케이스 {top.case_id} ({top.case_title})." if top.case_id else ""
|
||||
return (
|
||||
f"'{top.source_title}'와 결합 유사도 {top.similarity:.2%}로 매칭. "
|
||||
f"주 침해 태그: {tag_summary}.{case_part}{breakdown}"
|
||||
)
|
||||
|
||||
|
||||
def _classify_legacy(hit: SimilarityHit) -> InfringementType:
|
||||
"""후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용)."""
|
||||
elem = hit.element_sim
|
||||
char_sim = elem.get("characters", 0.0)
|
||||
motif_sim = elem.get("motifs", 0.0)
|
||||
|
||||
if hit.lemma_sim >= 0.70:
|
||||
return "copy"
|
||||
if hit.text_sim >= 0.70:
|
||||
return "copy"
|
||||
if hit.text_sim >= 0.40 and char_sim >= 0.30:
|
||||
return "transform"
|
||||
if hit.lemma_sim >= 0.40 and char_sim < 0.20:
|
||||
return "plot"
|
||||
if motif_sim >= 0.50 and char_sim < 0.20:
|
||||
return "plot"
|
||||
if char_sim >= 0.40:
|
||||
return "character"
|
||||
return "unknown"
|
||||
|
||||
|
||||
# 후방 호환 alias (테스트가 _classify import)
|
||||
_classify = _classify_legacy
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
"""콘텐츠 구성요소 추출.
|
||||
|
||||
두 가지 구현:
|
||||
- RuleExtractor: 형태소분석기 없이 동작하는 폴백 (의존성/비용 0)
|
||||
- OpenAIExtractor: gpt-4o-mini 기반. JSON mode로 인물/장르/모티프/플롯 요약 추출
|
||||
→ 추후 1단계 사내 sLLM(KLUE NER 파인튜닝)으로 교체할 자리.
|
||||
|
||||
두 구현 모두 ExtractedElements를 반환하므로 detector.py 입장에서는 swap-in 가능.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Protocol
|
||||
|
||||
from app.api.schemas import ExtractedElements
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Extractor(Protocol):
|
||||
def extract(self, text: str) -> ExtractedElements: ...
|
||||
|
||||
|
||||
# ---------- 룰 기반 폴백 ----------
|
||||
|
||||
_NAME_PATTERN = re.compile(r"[가-힣]{2,4}(?=(?:[은는이가을를과와의]|\s|$))")
|
||||
_QUOTED_PATTERN = re.compile(r"[\"“”]([^\"“”]{1,20})[\"“”]")
|
||||
_KEYWORD_PATTERN = re.compile(r"[가-힣A-Za-z]{2,}")
|
||||
|
||||
_GENRE_HINTS = {
|
||||
"판타지": ["마법", "마왕", "용사", "엘프", "드래곤", "차원"],
|
||||
"SF": ["우주", "외계", "로봇", "AI", "안드로이드", "행성"],
|
||||
"로맨스": ["사랑", "연인", "키스", "고백", "결혼"],
|
||||
"추리": ["살인", "탐정", "용의자", "범인", "수사", "단서"],
|
||||
"에세이": ["나는", "내가", "생각", "느낀", "삶"],
|
||||
}
|
||||
_MOTIF_HINTS = {
|
||||
"여행": ["여행", "떠나", "기차", "비행기"],
|
||||
"성장": ["자라", "성장", "어른", "배우"],
|
||||
"복수": ["복수", "원수", "되갚"],
|
||||
"우정": ["친구", "우정", "동료"],
|
||||
"이별": ["이별", "헤어", "떠나보"],
|
||||
}
|
||||
_STOPWORDS = {
|
||||
"그리고", "그러나", "그래서", "하지만", "그런데", "이것", "그것", "저것",
|
||||
"이는", "그는", "저는", "있다", "없다", "이다", "있는", "하는", "되는",
|
||||
}
|
||||
|
||||
|
||||
class RuleExtractor:
|
||||
def extract(self, text: str) -> ExtractedElements:
|
||||
characters = self._characters(text)
|
||||
return ExtractedElements(
|
||||
characters=characters[:10],
|
||||
motifs=self._motifs(text)[:5],
|
||||
genre=self._genre(text),
|
||||
keywords=self._keywords(text)[:10],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _characters(text: str) -> list[str]:
|
||||
raw = _NAME_PATTERN.findall(text) + [m.group(1) for m in _QUOTED_PATTERN.finditer(text)]
|
||||
cnt = Counter(w for w in raw if w not in _STOPWORDS and len(w) >= 2)
|
||||
return [w for w, _ in cnt.most_common(10)]
|
||||
|
||||
@staticmethod
|
||||
def _genre(text: str) -> str | None:
|
||||
scores = {g: sum(text.count(h) for h in hints) for g, hints in _GENRE_HINTS.items()}
|
||||
best = max(scores.items(), key=lambda kv: kv[1])
|
||||
return best[0] if best[1] > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _motifs(text: str) -> list[str]:
|
||||
found: list[tuple[str, int]] = []
|
||||
for motif, hints in _MOTIF_HINTS.items():
|
||||
score = sum(text.count(h) for h in hints)
|
||||
if score > 0:
|
||||
found.append((motif, score))
|
||||
found.sort(key=lambda kv: kv[1], reverse=True)
|
||||
return [m for m, _ in found]
|
||||
|
||||
@staticmethod
|
||||
def _keywords(text: str) -> list[str]:
|
||||
tokens = [t for t in _KEYWORD_PATTERN.findall(text) if len(t) >= 2 and t not in _STOPWORDS]
|
||||
cnt = Counter(tokens)
|
||||
return [w for w, _ in cnt.most_common(20)]
|
||||
|
||||
|
||||
# ---------- OpenAI 기반 ----------
|
||||
|
||||
_EXTRACTION_PROMPT = """다음 출판 콘텐츠 텍스트에서 저작권 침해 판정에 사용할 구성요소를 추출하라.
|
||||
|
||||
[규칙]
|
||||
- 결과는 반드시 JSON으로만 출력.
|
||||
- 인물은 텍스트에 명시적으로 등장한 고유명사 인물만. 일반명사(소년/사람 등) 제외.
|
||||
- 모티프는 추상적 주제(여행, 성장, 복수, 우정, 이별, 정의, 희생 등).
|
||||
- 장르는 단일 값 (소설/에세이/판타지/SF/로맨스/추리/역사/사회 중 하나, 또는 null).
|
||||
- 키워드는 작품 특유의 명사구 위주.
|
||||
|
||||
[출력 스키마]
|
||||
{
|
||||
"characters": ["..."],
|
||||
"motifs": ["..."],
|
||||
"genre": "..." | null,
|
||||
"keywords": ["..."]
|
||||
}
|
||||
|
||||
[텍스트]
|
||||
"""
|
||||
|
||||
|
||||
class OpenAIExtractor:
|
||||
def __init__(self, settings: Settings):
|
||||
from openai import OpenAI # 지연 import — 미설치 환경에서도 RuleExtractor 사용 가능
|
||||
|
||||
self._client = OpenAI(api_key=settings.openai_api_key)
|
||||
self._model = settings.openai_extraction_model
|
||||
self._fallback = RuleExtractor()
|
||||
|
||||
def extract(self, text: str) -> ExtractedElements:
|
||||
truncated = text[:8000]
|
||||
try:
|
||||
resp = self._client.chat.completions.create(
|
||||
model=self._model,
|
||||
temperature=0.0,
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a precise literary metadata extractor."},
|
||||
{"role": "user", "content": _EXTRACTION_PROMPT + truncated},
|
||||
],
|
||||
)
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
data = json.loads(raw)
|
||||
return ExtractedElements(
|
||||
characters=_as_str_list(data.get("characters")),
|
||||
motifs=_as_str_list(data.get("motifs")),
|
||||
genre=_as_optional_str(data.get("genre")),
|
||||
keywords=_as_str_list(data.get("keywords")),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("OpenAI extraction failed, falling back to rule-based: %s", exc)
|
||||
return self._fallback.extract(text)
|
||||
|
||||
|
||||
def _as_str_list(value, limit: int = 10) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v).strip() for v in value if isinstance(v, (str, int)) and str(v).strip()][:limit]
|
||||
|
||||
|
||||
def _as_optional_str(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def get_extractor(settings: Settings | None = None) -> Extractor:
|
||||
s = settings or get_settings()
|
||||
if s.use_llm_extractor and s.has_openai:
|
||||
logger.info("Using OpenAIExtractor (model=%s)", s.openai_extraction_model)
|
||||
return OpenAIExtractor(s)
|
||||
logger.info("Using RuleExtractor (fallback)")
|
||||
return RuleExtractor()
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"""MinHash + LSH 1차 필터 (PDF VII-3 권장 아키텍처).
|
||||
|
||||
대규모 코퍼스(자서전 수천~수만 건) 대비, 풀 스캔 대신 ms 단위로 후보 N건만 추출.
|
||||
|
||||
흐름:
|
||||
shingling (3~5자 n-gram) → MinHash (num_perm=128) → LSH 인덱스
|
||||
query → query MinHash → lsh.query() → 후보 doc_id 리스트
|
||||
→ 그 후보들만 DualSimilarityIndex로 정밀 비교
|
||||
|
||||
운영 코퍼스가 작을 때(< 100건)는 사실상 풀 스캔과 동일하지만,
|
||||
파이프라인 형태가 PDF 권장 아키텍처와 동일해 평가 시 방어 용이.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from datasketch import MinHash, MinHashLSH
|
||||
|
||||
from app.engine.corpus import ReferenceDoc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NGRAM = 5 # PDF: n-gram Shingling (5-gram 권장)
|
||||
_NUM_PERM = 128 # MinHash 시그니처 길이
|
||||
|
||||
|
||||
def _shingles(text: str, n: int = _NGRAM) -> set[str]:
|
||||
cleaned = re.sub(r"\s+", " ", text).strip()
|
||||
if len(cleaned) < n:
|
||||
return {cleaned} if cleaned else set()
|
||||
return {cleaned[i : i + n] for i in range(len(cleaned) - n + 1)}
|
||||
|
||||
|
||||
def _make_minhash(text: str) -> MinHash:
|
||||
mh = MinHash(num_perm=_NUM_PERM)
|
||||
for s in _shingles(text):
|
||||
mh.update(s.encode("utf-8"))
|
||||
return mh
|
||||
|
||||
|
||||
@dataclass
|
||||
class LshCandidate:
|
||||
doc_id: str
|
||||
title: str
|
||||
jaccard: float # MinHash 추정 자카드
|
||||
|
||||
|
||||
class LshIndex:
|
||||
"""대규모 코퍼스용 1차 후보 추출 인덱스."""
|
||||
|
||||
def __init__(self, docs: list[ReferenceDoc], threshold: float = 0.5):
|
||||
self._docs = docs
|
||||
self._lsh = MinHashLSH(threshold=threshold, num_perm=_NUM_PERM)
|
||||
self._minhashes: dict[str, MinHash] = {}
|
||||
for doc in docs:
|
||||
mh = _make_minhash(doc.text)
|
||||
self._lsh.insert(doc.doc_id, mh)
|
||||
self._minhashes[doc.doc_id] = mh
|
||||
logger.info("LSH index built: docs=%d, threshold=%.2f", len(docs), threshold)
|
||||
|
||||
def query(self, text: str, top_k: int = 50) -> list[LshCandidate]:
|
||||
if not self._docs:
|
||||
return []
|
||||
qmh = _make_minhash(text)
|
||||
hit_ids = self._lsh.query(qmh)
|
||||
# 후보가 너무 적으면 (소규모 코퍼스), 전체 fallback
|
||||
if len(hit_ids) < min(top_k, len(self._docs)):
|
||||
hit_ids = [d.doc_id for d in self._docs]
|
||||
# 추정 자카드로 정렬
|
||||
scored: list[LshCandidate] = []
|
||||
title_map = {d.doc_id: d.title for d in self._docs}
|
||||
for did in hit_ids:
|
||||
mh = self._minhashes.get(did)
|
||||
if mh is None:
|
||||
continue
|
||||
j = float(qmh.jaccard(mh))
|
||||
scored.append(LshCandidate(doc_id=did, title=title_map.get(did, did), jaccard=j))
|
||||
scored.sort(key=lambda c: c.jaccard, reverse=True)
|
||||
return scored[:top_k]
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
"""삼중 유사도 알고리즘.
|
||||
|
||||
계획서 p.21 "이중 유사도 분석" + 전임자 지침 (lemma 교집합 구조 분석) 결합:
|
||||
① text_sim : 표면 의미 유사도 (TF-IDF / 임베딩 코사인)
|
||||
② lemma_sim : 형태소 기본형 교집합 비율 (복붙+말투변경 탐지)
|
||||
③ element_sim: 인물/모티프 자카드 (구조 분석)
|
||||
|
||||
최종 score = w1·text + w2·lemma + w3·char + w4·motif
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
|
||||
from app.api.schemas import EvidenceSpan, ExtractedElements
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.engine.corpus import ReferenceDoc
|
||||
from app.engine.structural import extract_lemmas, lemma_overlap_ratio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimilarityHit:
|
||||
doc_id: str
|
||||
title: str
|
||||
score: float # 결합 유사도
|
||||
text_sim: float # ① 표면
|
||||
lemma_sim: float # ② 구조 (lemma 교집합 비율)
|
||||
element_sim: dict[str, float] # ③ 인물/모티프/장르
|
||||
evidence: list[EvidenceSpan]
|
||||
|
||||
|
||||
# ---------- ① 표면 유사도 백엔드 ----------
|
||||
|
||||
class TextSimilarityBackend(Protocol):
|
||||
def query(self, text: str) -> np.ndarray: ...
|
||||
|
||||
|
||||
def _korean_tokenizer(text: str) -> list[str]:
|
||||
words = re.findall(r"[가-힣A-Za-z0-9]+", text)
|
||||
grams: list[str] = []
|
||||
for w in words:
|
||||
if len(w) <= 3:
|
||||
grams.append(w)
|
||||
else:
|
||||
grams.extend(w[i : i + 3] for i in range(len(w) - 2))
|
||||
return grams
|
||||
|
||||
|
||||
class TfidfBackend:
|
||||
def __init__(self, docs: list[ReferenceDoc]):
|
||||
self._docs = docs
|
||||
if not docs:
|
||||
self._vectorizer = None
|
||||
self._matrix = None
|
||||
return
|
||||
self._vectorizer = TfidfVectorizer(
|
||||
tokenizer=_korean_tokenizer, lowercase=False, token_pattern=None, min_df=1,
|
||||
)
|
||||
self._matrix = self._vectorizer.fit_transform([d.text for d in docs])
|
||||
|
||||
def query(self, text: str) -> np.ndarray:
|
||||
if not self._docs or self._vectorizer is None:
|
||||
return np.array([])
|
||||
vec = self._vectorizer.transform([text])
|
||||
return cosine_similarity(vec, self._matrix).ravel()
|
||||
|
||||
|
||||
class EmbeddingBackend:
|
||||
def __init__(self, docs: list[ReferenceDoc], settings: Settings):
|
||||
from openai import OpenAI
|
||||
|
||||
self._docs = docs
|
||||
self._model = settings.openai_embedding_model
|
||||
self._client = OpenAI(api_key=settings.openai_api_key)
|
||||
if not docs:
|
||||
self._matrix = None
|
||||
return
|
||||
self._matrix = self._embed_batch([d.text[:8000] for d in docs])
|
||||
|
||||
def _embed_batch(self, texts: list[str]) -> np.ndarray:
|
||||
resp = self._client.embeddings.create(model=self._model, input=texts)
|
||||
arr = np.array([item.embedding for item in resp.data], dtype=np.float32)
|
||||
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1.0
|
||||
return arr / norms
|
||||
|
||||
def query(self, text: str) -> np.ndarray:
|
||||
if not self._docs or self._matrix is None:
|
||||
return np.array([])
|
||||
q = self._embed_batch([text[:8000]])
|
||||
return (q @ self._matrix.T).ravel()
|
||||
|
||||
|
||||
class KoSimCSEBackend:
|
||||
"""한국어 오픈소스 임베딩 (PDF VII-3 권장 - KoSimCSE/KoSBERT).
|
||||
|
||||
BM-K/KoSimCSE-roberta-multitask 등 sentence-transformers 호환 모델 사용.
|
||||
OpenAI 의존 없이 로컬에서 동작 → 데이터 외부 노출 0, 호출 비용 0.
|
||||
첫 호출 시 모델 자동 다운로드 (~500MB).
|
||||
"""
|
||||
|
||||
_model_cache: dict[str, object] = {}
|
||||
|
||||
def __init__(self, docs: list[ReferenceDoc], settings: Settings):
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
self._docs = docs
|
||||
self._model_name = settings.kosimcse_model
|
||||
self._max_length = settings.kosimcse_max_length
|
||||
|
||||
# 모델 재사용 (코퍼스 재빌드 시 매번 로드 방지)
|
||||
if self._model_name not in KoSimCSEBackend._model_cache:
|
||||
logger.info("Loading SentenceTransformer model: %s", self._model_name)
|
||||
KoSimCSEBackend._model_cache[self._model_name] = SentenceTransformer(self._model_name)
|
||||
self._model = KoSimCSEBackend._model_cache[self._model_name]
|
||||
|
||||
if not docs:
|
||||
self._matrix = None
|
||||
return
|
||||
texts = [d.text[: self._max_length * 4] for d in docs] # 문자 기준 잘림 (토큰화 후 다시 잘림)
|
||||
self._matrix = self._encode(texts)
|
||||
|
||||
def _encode(self, texts: list[str]) -> np.ndarray:
|
||||
emb = self._model.encode(
|
||||
texts,
|
||||
normalize_embeddings=True,
|
||||
show_progress_bar=False,
|
||||
batch_size=8,
|
||||
)
|
||||
return np.array(emb, dtype=np.float32)
|
||||
|
||||
def query(self, text: str) -> np.ndarray:
|
||||
if not self._docs or self._matrix is None:
|
||||
return np.array([])
|
||||
q = self._encode([text[: self._max_length * 4]])
|
||||
return (q @ self._matrix.T).ravel()
|
||||
|
||||
|
||||
# ---------- ③ 요소 유사도 ----------
|
||||
|
||||
def _jaccard(a: list[str], b: list[str]) -> float:
|
||||
sa, sb = set(s.lower() for s in a), set(s.lower() for s in b)
|
||||
if not sa and not sb:
|
||||
return 0.0
|
||||
return len(sa & sb) / max(1, len(sa | sb))
|
||||
|
||||
|
||||
def _element_similarities(q: ExtractedElements, c: ExtractedElements) -> dict[str, float]:
|
||||
return {
|
||||
"characters": _jaccard(q.characters, c.characters),
|
||||
"motifs": _jaccard(q.motifs, c.motifs),
|
||||
"keywords": _jaccard(q.keywords, c.keywords),
|
||||
"genre": 1.0 if q.genre and q.genre == c.genre else 0.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------- 삼중 유사도 인덱스 ----------
|
||||
|
||||
class DualSimilarityIndex:
|
||||
"""text_sim × lemma_sim × element_sim 가중 결합."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
docs: list[ReferenceDoc],
|
||||
doc_elements: list[ExtractedElements],
|
||||
doc_lemmas: list[list[str]],
|
||||
settings: Settings,
|
||||
text_backend: TextSimilarityBackend,
|
||||
):
|
||||
if not (len(docs) == len(doc_elements) == len(doc_lemmas)):
|
||||
raise ValueError("docs/elements/lemmas length mismatch")
|
||||
self._docs = docs
|
||||
self._doc_elements = doc_elements
|
||||
self._doc_lemmas = doc_lemmas
|
||||
self._settings = settings
|
||||
self._text_backend = text_backend
|
||||
|
||||
def query(
|
||||
self,
|
||||
text: str,
|
||||
query_elements: ExtractedElements,
|
||||
top_k: int = 5,
|
||||
) -> list[SimilarityHit]:
|
||||
if not self._docs:
|
||||
return []
|
||||
|
||||
text_scores = self._text_backend.query(text)
|
||||
if text_scores.size == 0:
|
||||
return []
|
||||
|
||||
query_lemmas = extract_lemmas(text)
|
||||
|
||||
s = self._settings
|
||||
hits: list[SimilarityHit] = []
|
||||
for idx, doc in enumerate(self._docs):
|
||||
text_sim = float(text_scores[idx])
|
||||
lemma_sim = lemma_overlap_ratio(query_lemmas, self._doc_lemmas[idx])
|
||||
elem_sim = _element_similarities(query_elements, self._doc_elements[idx])
|
||||
|
||||
combined = (
|
||||
s.weight_text_sim * text_sim
|
||||
+ s.weight_lemma_sim * lemma_sim
|
||||
+ s.weight_char_sim * elem_sim["characters"]
|
||||
+ s.weight_motif_sim * elem_sim["motifs"]
|
||||
)
|
||||
if combined <= 0:
|
||||
continue
|
||||
hits.append(
|
||||
SimilarityHit(
|
||||
doc_id=doc.doc_id,
|
||||
title=doc.title,
|
||||
score=combined,
|
||||
text_sim=text_sim,
|
||||
lemma_sim=lemma_sim,
|
||||
element_sim=elem_sim,
|
||||
evidence=_find_evidence_spans(text, doc.text),
|
||||
)
|
||||
)
|
||||
|
||||
hits.sort(key=lambda h: h.score, reverse=True)
|
||||
return hits[:top_k]
|
||||
|
||||
|
||||
def _find_evidence_spans(query: str, reference: str, ngram: int = 6, max_spans: int = 5) -> list[EvidenceSpan]:
|
||||
if len(query) < ngram or len(reference) < ngram:
|
||||
return []
|
||||
ref_grams = {reference[i : i + ngram] for i in range(len(reference) - ngram + 1)}
|
||||
spans: list[EvidenceSpan] = []
|
||||
i = 0
|
||||
while i <= len(query) - ngram and len(spans) < max_spans:
|
||||
if query[i : i + ngram] in ref_grams:
|
||||
start = i
|
||||
end = i + ngram
|
||||
while end < len(query) and query[end - ngram + 1 : end + 1] in ref_grams:
|
||||
end += 1
|
||||
spans.append(EvidenceSpan(start=start, end=end, matched=query[start:end]))
|
||||
i = end
|
||||
else:
|
||||
i += 1
|
||||
return spans
|
||||
|
||||
|
||||
def build_text_backend(docs: list[ReferenceDoc], settings: Settings) -> TextSimilarityBackend:
|
||||
"""우선순위: KoSimCSE (자체) → OpenAI 임베딩 → TF-IDF (폴백).
|
||||
|
||||
PDF VII-3 권장은 KoSimCSE/KoSBERT. 자체 모델/오픈소스 우선.
|
||||
"""
|
||||
if settings.use_kosimcse:
|
||||
try:
|
||||
return KoSimCSEBackend(docs, settings)
|
||||
except Exception as exc:
|
||||
logger.warning("KoSimCSE backend init failed, trying next: %s", exc)
|
||||
if settings.use_embedding_similarity and settings.has_openai:
|
||||
try:
|
||||
logger.info("Using EmbeddingBackend (model=%s)", settings.openai_embedding_model)
|
||||
return EmbeddingBackend(docs, settings)
|
||||
except Exception as exc:
|
||||
logger.warning("Embedding backend init failed, falling back to TF-IDF: %s", exc)
|
||||
logger.info("Using TfidfBackend")
|
||||
return TfidfBackend(docs)
|
||||
|
||||
|
||||
SimilarityIndex = DualSimilarityIndex # 후방 호환
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
"""구조 분석 - 형태소 분석 + lemma 교집합 비율.
|
||||
|
||||
전임자 가이드: 의미분석(임베딩)과 별도로, 내용어 형태소 기본형(lemma) 단위 교집합 비율을
|
||||
보면 "원본을 복붙하고 말투(어미/조사)만 바꾼 표절"을 결정적으로 탐지할 수 있다.
|
||||
|
||||
예) "갔다" / "가던" / "가버렸다" → lemma 모두 "가다"
|
||||
"빼앗았다" / "빼앗는다" → lemma 모두 "빼앗다"
|
||||
임베딩 유사도로는 어미 변경만으로도 점수가 떨어지지만, lemma 교집합은 그대로 잡힌다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import Counter
|
||||
from functools import lru_cache
|
||||
|
||||
from kiwipiepy import Kiwi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 내용어 태그 - 명사/동사/형용사/부사. (조사 JK*, 어미 EF/EC/EP 등 기능어는 제외)
|
||||
_CONTENT_TAGS = ("NNG", "NNP", "VV", "VA", "MAG", "VV-R", "VV-I", "VA-R", "VA-I")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_kiwi() -> Kiwi:
|
||||
logger.info("Initializing Kiwi morphological analyzer")
|
||||
return Kiwi()
|
||||
|
||||
|
||||
def extract_lemmas(text: str, min_length: int = 1) -> list[str]:
|
||||
"""텍스트에서 내용어 lemma 리스트 반환 (등장 순서, 중복 포함)."""
|
||||
if not text.strip():
|
||||
return []
|
||||
tokens = _get_kiwi().tokenize(text)
|
||||
lemmas: list[str] = []
|
||||
for t in tokens:
|
||||
if not any(t.tag.startswith(prefix) for prefix in ("NNG", "NNP", "VV", "VA", "MAG")):
|
||||
continue
|
||||
# kiwi 0.18+ Token.lemma 가 기본형 제공. 형용사/동사도 "-다" 형태.
|
||||
lemma = getattr(t, "lemma", None) or t.form
|
||||
if len(lemma) >= min_length:
|
||||
lemmas.append(lemma)
|
||||
return lemmas
|
||||
|
||||
|
||||
def lemma_overlap_ratio(query_lemmas: list[str], ref_lemmas: list[str]) -> float:
|
||||
"""query 기준 다중집합 교집합 비율 = |Q ∩ R (다중집합)| / |Q|.
|
||||
|
||||
한쪽 기준 비율을 쓰는 이유:
|
||||
자카드(|Q∩R|/|Q∪R|)는 레퍼런스가 query보다 훨씬 길 때 점수가 깎인다.
|
||||
"검사 대상이 레퍼런스에서 얼마나 가져왔는가"를 묻는 게 표절 판정 목적이므로
|
||||
query 기준 precision-side 비율이 더 정확.
|
||||
"""
|
||||
if not query_lemmas:
|
||||
return 0.0
|
||||
qc = Counter(query_lemmas)
|
||||
rc = Counter(ref_lemmas)
|
||||
intersection = sum((qc & rc).values())
|
||||
total = sum(qc.values())
|
||||
return intersection / total if total else 0.0
|
||||
|
||||
|
||||
def lemma_jaccard(query_lemmas: list[str], ref_lemmas: list[str]) -> float:
|
||||
"""양방향 자카드. 보조 지표용."""
|
||||
sq, sr = set(query_lemmas), set(ref_lemmas)
|
||||
if not sq and not sr:
|
||||
return 0.0
|
||||
return len(sq & sr) / max(1, len(sq | sr))
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
"""10종 메타 태그 분류체계 + 38개 케이스 로더.
|
||||
|
||||
PDF IV/IX장 그대로 JSON으로 보관 (data/taxonomy/). 부팅 시 1회 로드.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from app.api.schemas import LegalTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetaTagDef:
|
||||
id: str
|
||||
label_ko: str
|
||||
category: str
|
||||
law_ref: str
|
||||
scope: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaseDef:
|
||||
case_id: str # A1, A7, B1, ...
|
||||
old_no: int
|
||||
subgroup: str
|
||||
title: str
|
||||
actor: str
|
||||
primary_tags: tuple[str, ...]
|
||||
secondary_tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
detectable_internal: bool = False
|
||||
high_risk: bool = False
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Taxonomy:
|
||||
meta_tags_version: str
|
||||
cases_version: str
|
||||
meta_tags: tuple[MetaTagDef, ...]
|
||||
cases: tuple[CaseDef, ...]
|
||||
|
||||
def tag_label(self, tag_id: str) -> str:
|
||||
for t in self.meta_tags:
|
||||
if t.id == tag_id:
|
||||
return t.label_ko
|
||||
return tag_id
|
||||
|
||||
def find_case(self, primary_tags: Iterable[str]) -> CaseDef | None:
|
||||
"""주 태그 조합이 가장 잘 매칭되는 케이스 추정.
|
||||
|
||||
완벽 일치 우선, 없으면 부분 일치 (jaccard).
|
||||
내부 검출 가능 케이스(A1~A5, A24, A25, B1, B2, D1)에 가중치.
|
||||
"""
|
||||
target = set(primary_tags)
|
||||
if not target:
|
||||
return None
|
||||
best: CaseDef | None = None
|
||||
best_score = -1.0
|
||||
for c in self.cases:
|
||||
primary = set(c.primary_tags)
|
||||
if not primary:
|
||||
continue
|
||||
inter = len(target & primary)
|
||||
union = len(target | primary)
|
||||
score = inter / max(1, union)
|
||||
if c.detectable_internal:
|
||||
score += 0.1
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = c
|
||||
return best if best_score > 0.3 else None
|
||||
|
||||
|
||||
def load_taxonomy(taxonomy_dir: Path) -> Taxonomy | None:
|
||||
mt_path = taxonomy_dir / "meta_tags_v1.0.json"
|
||||
cs_path = taxonomy_dir / "cases_v1.2.json"
|
||||
if not mt_path.exists() or not cs_path.exists():
|
||||
logger.warning("Taxonomy files not found in %s", taxonomy_dir)
|
||||
return None
|
||||
|
||||
mt_data = json.loads(mt_path.read_text(encoding="utf-8"))
|
||||
cs_data = json.loads(cs_path.read_text(encoding="utf-8"))
|
||||
|
||||
meta_tags = tuple(
|
||||
MetaTagDef(
|
||||
id=t["id"], label_ko=t["label_ko"], category=t["category"],
|
||||
law_ref=t["law_ref"], scope=t["scope"], description=t["description"],
|
||||
)
|
||||
for t in mt_data["tags"]
|
||||
)
|
||||
cases = tuple(
|
||||
CaseDef(
|
||||
case_id=c["case_id"],
|
||||
old_no=c.get("old_no", 0),
|
||||
subgroup=c.get("subgroup", ""),
|
||||
title=c["title"],
|
||||
actor=c.get("actor", ""),
|
||||
primary_tags=tuple(c.get("primary_tags", [])),
|
||||
secondary_tags=tuple(c.get("secondary_tags", [])),
|
||||
detectable_internal=c.get("detectable_internal", False),
|
||||
high_risk=c.get("high_risk", False),
|
||||
note=c.get("note"),
|
||||
)
|
||||
for c in cs_data["cases"]
|
||||
)
|
||||
|
||||
tax = Taxonomy(
|
||||
meta_tags_version=mt_data["version"],
|
||||
cases_version=cs_data["version"],
|
||||
meta_tags=meta_tags,
|
||||
cases=cases,
|
||||
)
|
||||
logger.info("Taxonomy loaded: tags=%s cases=%s (%d tags, %d cases)",
|
||||
tax.meta_tags_version, tax.cases_version, len(meta_tags), len(cases))
|
||||
return tax
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""배치 잡 in-memory 스토어. 운영 시 Redis/PostgreSQL 등으로 교체."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from app.api.schemas import DetectResponse
|
||||
|
||||
JobStatus = Literal["queued", "running", "completed", "failed"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
job_id: str
|
||||
status: JobStatus
|
||||
total: int
|
||||
processed: int = 0
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
finished_at: datetime | None = None
|
||||
results: list[DetectResponse] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class JobStore:
|
||||
def __init__(self):
|
||||
self._jobs: dict[str, Job] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def create(self, total: int) -> Job:
|
||||
job = Job(job_id=str(uuid.uuid4()), status="queued", total=total)
|
||||
with self._lock:
|
||||
self._jobs[job.job_id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> Job | None:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job_id: str, **fields) -> None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
for key, value in fields.items():
|
||||
setattr(job, key, value)
|
||||
|
||||
def append_result(self, job_id: str, result: DetectResponse) -> None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.results.append(result)
|
||||
job.processed = len(job.results)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.routes import router as api_router
|
||||
from app.core.config import get_settings
|
||||
from app.engine.detector import PlagiarismDetector
|
||||
from app.jobs.store import JobStore
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
app.state.settings = settings
|
||||
app.state.detector = PlagiarismDetector(settings=settings)
|
||||
app.state.job_store = JobStore()
|
||||
import threading
|
||||
app.state.detector_lock = threading.Lock()
|
||||
logging.info(
|
||||
"Engine ready: version=%s, corpus_size=%d",
|
||||
settings.engine_version,
|
||||
app.state.detector.corpus_size,
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def rebuild_detector(app: FastAPI) -> int:
|
||||
"""코퍼스 변경 후 인덱스 재빌드. 호출 시점에 lock으로 보호."""
|
||||
settings = app.state.settings
|
||||
with app.state.detector_lock:
|
||||
app.state.detector = PlagiarismDetector(settings=settings)
|
||||
return app.state.detector.corpus_size
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="O2O 저작권 침해 여부 탐지 API",
|
||||
description=(
|
||||
"오투오 1단계 산출물 - 콘텐츠 표절 여부 AI 탐지 모듈. "
|
||||
"본 응답 스키마는 커뮤니케이션북스(아카이빙) 및 바이칼AI(분석 보고서) 통합 기준."
|
||||
),
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
if _STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root() -> FileResponse:
|
||||
"""검토 콘솔 페이지 — 컴북스 측이 브라우저에서 직접 엔진 성능 확인."""
|
||||
index = _STATIC_DIR / "index.html"
|
||||
if index.exists():
|
||||
return FileResponse(str(index))
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"service": "o2o-plagiarism-api", "docs": "/docs"})
|
||||
|
|
@ -0,0 +1,762 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>O2O 저작권 침해 여부 탐지 — 검토 콘솔</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--panel-2: #1c2128;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--danger: #f85149;
|
||||
--warning: #d29922;
|
||||
--success: #3fb950;
|
||||
--copy: #f85149;
|
||||
--transform: #ff8c42;
|
||||
--plot: #d29922;
|
||||
--character: #a371f7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
font-size: 14px; line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
padding: 18px 28px; display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
header h1 { font-size: 18px; margin: 0; font-weight: 600; }
|
||||
header .subtitle { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
header .badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 20px;
|
||||
font-size: 11px; background: var(--panel-2); border: 1px solid var(--border);
|
||||
margin-left: 10px;
|
||||
}
|
||||
header .badge.ok { color: var(--success); border-color: var(--success); }
|
||||
header .badge.err { color: var(--danger); border-color: var(--danger); }
|
||||
main { padding: 24px 28px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 24px; max-width: 1600px; margin: 0 auto; }
|
||||
@media (max-width: 980px) { main { grid-template-columns: 1fr; } }
|
||||
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.panel h2 { font-size: 14px; margin: 0 0 14px; color: var(--accent); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
label { display: block; margin-top: 12px; font-size: 12px; color: var(--muted); }
|
||||
input[type="text"], textarea {
|
||||
width: 100%; padding: 10px 12px; margin-top: 4px;
|
||||
background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-family: inherit; font-size: 13px;
|
||||
}
|
||||
textarea { min-height: 220px; resize: vertical; line-height: 1.6; }
|
||||
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||
button {
|
||||
margin-top: 16px; padding: 10px 22px; background: var(--accent); color: #0d1117;
|
||||
border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { background: #79b8ff; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--border); }
|
||||
button.ghost:hover { background: var(--panel-2); }
|
||||
|
||||
.sample-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.sample-buttons button {
|
||||
margin-top: 0; padding: 4px 10px; font-size: 11px;
|
||||
background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
|
||||
}
|
||||
.sample-buttons button:hover { color: var(--text); border-color: var(--accent); }
|
||||
|
||||
/* 결과 영역 */
|
||||
.verdict {
|
||||
padding: 24px; border-radius: 8px; text-align: center; margin-bottom: 18px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.verdict.infringement { background: rgba(248, 81, 73, 0.08); border-color: var(--danger); }
|
||||
.verdict.clean { background: rgba(63, 185, 80, 0.08); border-color: var(--success); }
|
||||
.verdict.idle { background: var(--panel-2); color: var(--muted); }
|
||||
.verdict h3 { font-size: 22px; margin: 0 0 6px; font-weight: 600; }
|
||||
.verdict.infringement h3 { color: var(--danger); }
|
||||
.verdict.clean h3 { color: var(--success); }
|
||||
.verdict .conf-line { font-size: 13px; color: var(--muted); }
|
||||
.verdict .conf-number { font-size: 36px; font-weight: 700; margin-top: 6px; }
|
||||
|
||||
.scorebar-row { display: grid; grid-template-columns: 100px 1fr 70px; align-items: center; gap: 10px; margin: 6px 0; font-size: 12px; }
|
||||
.scorebar { height: 8px; background: var(--panel-2); border-radius: 4px; overflow: hidden; }
|
||||
.scorebar > div { height: 100%; background: linear-gradient(90deg, var(--accent), #79b8ff); }
|
||||
.scorebar-row .label { color: var(--muted); }
|
||||
.scorebar-row .value { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.match-card {
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 14px; margin-top: 10px;
|
||||
}
|
||||
.match-card .title { font-weight: 600; }
|
||||
.match-card .meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.type-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
|
||||
font-weight: 600; margin-left: 8px; text-transform: uppercase;
|
||||
}
|
||||
.type-copy { background: rgba(248, 81, 73, 0.15); color: var(--copy); border: 1px solid var(--copy); }
|
||||
.type-transform { background: rgba(255, 140, 66, 0.15); color: var(--transform); border: 1px solid var(--transform); }
|
||||
.type-plot { background: rgba(210, 153, 34, 0.15); color: var(--plot); border: 1px solid var(--plot); }
|
||||
.type-character { background: rgba(163, 113, 247, 0.15); color: var(--character); border: 1px solid var(--character); }
|
||||
.type-unknown { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
|
||||
|
||||
/* PDF v1.2 — 다중 법령 태그 표시 */
|
||||
.tags-row { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.tag-group { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
||||
.tag-group-label { font-size: 10px; color: var(--muted); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.tag-chip-legal {
|
||||
display: inline-block; padding: 3px 9px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.tag-primary { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid var(--danger); }
|
||||
.tag-secondary { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
|
||||
.case-id-badge {
|
||||
font-size: 11px; color: var(--accent); margin-left: 8px;
|
||||
padding: 2px 8px; background: rgba(88, 166, 255, 0.1);
|
||||
border-radius: 3px; font-weight: 500;
|
||||
}
|
||||
.mode-indicator {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 10px; background: rgba(210, 153, 34, 0.15); color: var(--warning);
|
||||
border: 1px solid var(--warning); margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Tab navigation */
|
||||
.tab-nav { display: flex; gap: 4px; }
|
||||
.tab-btn {
|
||||
margin: 0; padding: 6px 14px; background: transparent;
|
||||
color: var(--muted); border: 1px solid var(--border);
|
||||
border-radius: 6px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.tab-btn.active { background: var(--accent); color: #0d1117; border-color: var(--accent); }
|
||||
.tab-btn:hover:not(.active) { color: var(--text); border-color: var(--accent); }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Corpus management */
|
||||
.corpus-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 24px; }
|
||||
@media (max-width: 980px) { .corpus-grid { grid-template-columns: 1fr; } }
|
||||
.corpus-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 10px; }
|
||||
.corpus-table th, .corpus-table td {
|
||||
text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.corpus-table th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; }
|
||||
.corpus-table tr:hover { background: var(--panel-2); }
|
||||
.corpus-table .doc-id { font-family: ui-monospace, monospace; font-size: 11px; color: var(--accent); }
|
||||
.btn-danger {
|
||||
margin: 0; padding: 3px 10px; background: transparent; color: var(--danger);
|
||||
border: 1px solid var(--danger); border-radius: 4px; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover { background: var(--danger); color: white; }
|
||||
.upload-status { margin-top: 12px; padding: 10px; border-radius: 4px; font-size: 12px; }
|
||||
.upload-status.ok { background: rgba(63, 185, 80, 0.1); color: var(--success); border-left: 3px solid var(--success); }
|
||||
.upload-status.err { background: rgba(248, 81, 73, 0.1); color: var(--danger); border-left: 3px solid var(--danger); }
|
||||
|
||||
.evidence-text {
|
||||
margin-top: 10px; padding: 10px; background: var(--bg); border-radius: 4px;
|
||||
font-size: 12px; line-height: 1.8; max-height: 200px; overflow-y: auto;
|
||||
}
|
||||
.evidence-text mark { background: rgba(248, 81, 73, 0.25); color: var(--text); padding: 1px 2px; border-radius: 2px; }
|
||||
|
||||
.chip {
|
||||
display: inline-block; padding: 3px 8px; margin: 2px;
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ccl-basis {
|
||||
padding: 10px 12px; background: rgba(88, 166, 255, 0.08); border-left: 3px solid var(--accent);
|
||||
border-radius: 4px; font-size: 12px; line-height: 1.6; margin-top: 14px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid var(--muted); border-top-color: var(--text);
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
footer {
|
||||
text-align: center; padding: 18px; color: var(--muted); font-size: 11px;
|
||||
border-top: 1px solid var(--border); margin-top: 20px;
|
||||
}
|
||||
footer a { color: var(--accent); text-decoration: none; }
|
||||
pre.raw {
|
||||
background: var(--bg); padding: 10px; border-radius: 4px; font-size: 11px;
|
||||
overflow-x: auto; max-height: 200px; color: var(--muted);
|
||||
}
|
||||
.row { display: flex; gap: 10px; align-items: center; }
|
||||
.row > * { flex: 1; }
|
||||
.toggle-raw { font-size: 11px; color: var(--accent); cursor: pointer; user-select: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>O2O 저작권 침해 여부 탐지 — 검토 콘솔</h1>
|
||||
<div class="subtitle">KOCCA 출판환경변화 과제 · 오투오 1단계 산출물 (콘텐츠 표절 여부 AI 탐지 모듈)</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<nav class="tab-nav">
|
||||
<button class="tab-btn active" data-tab="detect">탐지 검토</button>
|
||||
<button class="tab-btn" data-tab="corpus">코퍼스 관리</button>
|
||||
</nav>
|
||||
<span id="health-badge" class="badge">엔진 확인 중…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="tab-detect" class="tab-content active">
|
||||
<section class="panel" id="input-panel">
|
||||
<h2>① 검사 대상 입력</h2>
|
||||
|
||||
<label>샘플 시나리오 (클릭하면 자동 채움)</label>
|
||||
<div class="sample-buttons">
|
||||
<button data-sample="copy">어미만 변경 표절</button>
|
||||
<button data-sample="char">인물 치환 표절</button>
|
||||
<button data-sample="paraphrase">패러프레이즈</button>
|
||||
<button data-sample="unrelated">무관한 텍스트</button>
|
||||
<button data-sample="clear">비우기</button>
|
||||
</div>
|
||||
|
||||
<label>문서 ID (선택)</label>
|
||||
<input type="text" id="doc-id" placeholder="자동 생성됩니다">
|
||||
|
||||
<label>제목 (선택)</label>
|
||||
<input type="text" id="title" placeholder="예: 새 단편 〈잊혀진 사람들〉">
|
||||
|
||||
<label>저자 (선택)</label>
|
||||
<input type="text" id="author" placeholder="예: 김작가">
|
||||
|
||||
<label>본문 텍스트 <span style="color: var(--accent)">*</span></label>
|
||||
<textarea id="text" placeholder="검사할 본문을 붙여넣으세요. 최소 1자."></textarea>
|
||||
|
||||
<label>API Key</label>
|
||||
<input type="text" id="api-key" value="combooks-key-change-me">
|
||||
|
||||
<details style="margin-top: 14px;">
|
||||
<summary style="cursor: pointer; font-size: 12px; color: var(--accent); user-select: none;">⚙ 고급 옵션 (임계값, 자서전 모드)</summary>
|
||||
<div style="margin-top: 10px; padding: 12px; background: var(--panel-2); border-radius: 6px;">
|
||||
<label style="margin-top: 0;">임계값 (similarity threshold) <span id="threshold-value" style="color: var(--accent); font-weight: 600;">0.85</span></label>
|
||||
<input type="range" id="threshold-slider" min="0.05" max="0.99" step="0.01" value="0.85" style="width: 100%; margin-top: 6px;">
|
||||
<div style="font-size: 11px; color: var(--muted); margin-top: 4px;">
|
||||
낮을수록 더 많이 매칭 (재현율↑) · 높을수록 엄격 (정밀도↑) · PDF v1.2 권장 0.85
|
||||
</div>
|
||||
|
||||
<label style="margin-top: 12px;">자서전 모드</label>
|
||||
<div style="display: flex; gap: 12px; margin-top: 4px;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
|
||||
<input type="radio" name="autobio-mode" value="default" checked> 서버 기본값 사용
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
|
||||
<input type="radio" name="autobio-mode" value="true"> 강제 ON
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
|
||||
<input type="radio" name="autobio-mode" value="false"> 강제 OFF
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--muted); margin-top: 4px;">
|
||||
ON일 때 공통 표현 사전 + NER 마스킹으로 거짓 양성 방지
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button id="submit-btn">검사 시작</button>
|
||||
<button class="ghost" id="clear-btn">결과 초기화</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="result-panel">
|
||||
<h2>② 검사 결과</h2>
|
||||
<div id="verdict" class="verdict idle">
|
||||
<h3>아직 검사하지 않았습니다</h3>
|
||||
<div class="conf-line">좌측에서 본문을 입력하고 “검사 시작”을 누르세요.</div>
|
||||
</div>
|
||||
|
||||
<div id="result-body" style="display: none;">
|
||||
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">점수 분석 (삼중 유사도 결합)</h3>
|
||||
<div id="score-breakdown"></div>
|
||||
|
||||
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">매칭된 레퍼런스</h3>
|
||||
<div id="matches"></div>
|
||||
|
||||
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">추출된 구성요소</h3>
|
||||
<div id="elements"></div>
|
||||
|
||||
<div id="ccl-basis"></div>
|
||||
|
||||
<div style="margin-top: 18px;">
|
||||
<span class="toggle-raw" onclick="document.getElementById('raw-json').style.display = document.getElementById('raw-json').style.display === 'none' ? 'block' : 'none'">
|
||||
원본 JSON 응답 보기/숨기기
|
||||
</span>
|
||||
<pre id="raw-json" class="raw" style="display: none;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="tab-corpus" class="tab-content" style="max-width: 1600px; margin: 0 auto; padding: 24px 28px;">
|
||||
<div class="corpus-grid">
|
||||
<section class="panel">
|
||||
<h2>새 자서전 추가</h2>
|
||||
<p style="color: var(--muted); font-size: 12px; margin-top: 0;">
|
||||
업로드된 자서전은 검사 비교 기준이 됩니다. 컴북스 측이 직접 자서전을 등록·관리할 수 있도록 설계되었습니다.
|
||||
업로드 직후 인덱스가 자동 재빌드됩니다.
|
||||
</p>
|
||||
|
||||
<label>문서 ID (선택)</label>
|
||||
<input type="text" id="corpus-doc-id" placeholder="비우면 자동 생성됩니다">
|
||||
|
||||
<label>제목 <span style="color: var(--accent)">*</span></label>
|
||||
<input type="text" id="corpus-title" placeholder="예: 김OO 자서전">
|
||||
|
||||
<label>본문 텍스트 <span style="color: var(--accent)">*</span></label>
|
||||
<textarea id="corpus-text" placeholder="자서전 본문을 붙여넣으세요." style="min-height: 280px;"></textarea>
|
||||
|
||||
<label>.txt 파일 업로드 (또는)</label>
|
||||
<input type="file" id="corpus-file" accept=".txt">
|
||||
|
||||
<label>API Key</label>
|
||||
<input type="text" id="corpus-api-key" value="combooks-key-change-me">
|
||||
|
||||
<div class="row">
|
||||
<button id="corpus-upload-btn">업로드</button>
|
||||
<button class="ghost" id="corpus-refresh-btn">목록 새로고침</button>
|
||||
</div>
|
||||
|
||||
<div id="corpus-status"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>현재 코퍼스 (<span id="corpus-count">0</span>건)</h2>
|
||||
<p style="color: var(--muted); font-size: 12px; margin-top: 0;">
|
||||
탐지 검사 시 비교 대상이 되는 자서전 목록입니다.
|
||||
</p>
|
||||
<table class="corpus-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>doc_id</th>
|
||||
<th>제목</th>
|
||||
<th>크기</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="corpus-tbody">
|
||||
<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="/docs">API 문서 (Swagger)</a> ·
|
||||
<a href="/openapi.json">OpenAPI 스펙</a> ·
|
||||
엔진 버전: <span id="engine-version">…</span> ·
|
||||
레퍼런스 코퍼스: <span id="corpus-size">…</span>건
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const SAMPLES = {
|
||||
copy: {
|
||||
title: "어미만 변경한 표절 (홍길동전 기반)",
|
||||
author: "테스트",
|
||||
text: "홍길동이 서자로 태어나 활빈당을 만들고 탐관오리의 재물을 빼앗으며 가난한 백성에게 나누어 주었다. 조정은 그를 잡으려 했지만 도술로 빠져나갔다. 결국 율도국으로 떠나 왕이 되었다.",
|
||||
},
|
||||
char: {
|
||||
title: "인물 이름만 치환한 표절",
|
||||
author: "테스트",
|
||||
text: "김민수는 서자로 태어나 정의단을 만들어 부패한 관리의 재물을 빼앗아 가난한 백성에게 나누어 준다. 조정은 그를 잡으려 하지만 도술로 빠져나간다. 결국 신대륙으로 떠나 왕이 된다.",
|
||||
},
|
||||
paraphrase: {
|
||||
title: "패러프레이즈 (어휘 전체 교체)",
|
||||
author: "테스트",
|
||||
text: "한 사생아가 무리를 조직해 부패한 관료들의 돈을 약탈하여 빈민에게 분배했다. 정부는 그를 체포하려 했지만 마법으로 도망쳤다. 결국 새 대륙으로 건너가 군주가 됐다.",
|
||||
},
|
||||
unrelated: {
|
||||
title: "무관한 일상 텍스트",
|
||||
author: "테스트",
|
||||
text: "오늘 아침에 커피를 마시면서 신문을 읽었다. 날씨가 좋아서 산책을 나갔다. 공원에서 강아지와 노는 어린이들을 보았다.",
|
||||
},
|
||||
clear: { title: "", author: "", text: "" },
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
copy: "복제 (Copy)",
|
||||
transform: "변형 (Transform)",
|
||||
plot: "플롯 차용 (Plot)",
|
||||
character: "인물 차용 (Character)",
|
||||
background: "배경 차용 (Background)",
|
||||
unknown: "확인 필요 (Unknown)",
|
||||
};
|
||||
|
||||
document.querySelectorAll(".sample-buttons button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const s = SAMPLES[btn.dataset.sample];
|
||||
document.getElementById("title").value = s.title;
|
||||
document.getElementById("author").value = s.author;
|
||||
document.getElementById("text").value = s.text;
|
||||
document.getElementById("doc-id").value = "";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
document.getElementById("verdict").className = "verdict idle";
|
||||
document.getElementById("verdict").innerHTML = `
|
||||
<h3>아직 검사하지 않았습니다</h3>
|
||||
<div class="conf-line">좌측에서 본문을 입력하고 “검사 시작”을 누르세요.</div>`;
|
||||
document.getElementById("result-body").style.display = "none";
|
||||
});
|
||||
|
||||
document.getElementById("submit-btn").addEventListener("click", runDetect);
|
||||
|
||||
async function runDetect() {
|
||||
const text = document.getElementById("text").value.trim();
|
||||
if (!text) {
|
||||
alert("본문 텍스트를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
const docId = document.getElementById("doc-id").value.trim() || `web-${Date.now()}`;
|
||||
const title = document.getElementById("title").value.trim();
|
||||
const author = document.getElementById("author").value.trim();
|
||||
const apiKey = document.getElementById("api-key").value.trim();
|
||||
|
||||
const thresholdVal = parseFloat(document.getElementById("threshold-slider").value);
|
||||
const autobioMode = document.querySelector('input[name="autobio-mode"]:checked').value;
|
||||
const options = { threshold: thresholdVal };
|
||||
if (autobioMode === "true") options.autobiography_mode = true;
|
||||
else if (autobioMode === "false") options.autobiography_mode = false;
|
||||
|
||||
const body = {
|
||||
doc_id: docId,
|
||||
text: text,
|
||||
metadata: (title || author) ? { title: title || null, author: author || null } : null,
|
||||
options: options,
|
||||
};
|
||||
|
||||
const btn = document.getElementById("submit-btn");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loader"></span> 검사 중…';
|
||||
|
||||
const verdict = document.getElementById("verdict");
|
||||
verdict.className = "verdict idle";
|
||||
verdict.innerHTML = `<h3><span class="loader"></span> 분석 중…</h3>
|
||||
<div class="conf-line">요소 추출 → 임베딩 → Lemma 분석 → 결합 판정</div>`;
|
||||
document.getElementById("result-body").style.display = "none";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/v1/plagiarism/detect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status} — ${detail}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
renderResult(data, text);
|
||||
} catch (err) {
|
||||
verdict.className = "verdict idle";
|
||||
verdict.innerHTML = `<h3 style="color: var(--danger);">검사 실패</h3>
|
||||
<div class="conf-line">${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "검사 시작";
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(data, originalText) {
|
||||
const verdict = document.getElementById("verdict");
|
||||
if (data.is_infringement) {
|
||||
verdict.className = "verdict infringement";
|
||||
verdict.innerHTML = `
|
||||
<h3>⚠ 저작권 침해 가능성 확인</h3>
|
||||
<div class="conf-line">결합 유사도</div>
|
||||
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`;
|
||||
} else {
|
||||
verdict.className = "verdict clean";
|
||||
verdict.innerHTML = `
|
||||
<h3>✓ 침해 신호 없음</h3>
|
||||
<div class="conf-line">최상위 유사도</div>
|
||||
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`;
|
||||
}
|
||||
|
||||
document.getElementById("result-body").style.display = "block";
|
||||
|
||||
// 매칭이 있으면 첫번째 매칭의 breakdown 사용
|
||||
const breakdownEl = document.getElementById("score-breakdown");
|
||||
if (data.matches && data.matches.length > 0 && data.matches[0].score_breakdown) {
|
||||
const sb = data.matches[0].score_breakdown;
|
||||
breakdownEl.innerHTML = `
|
||||
${scorebar("표면 유사도 (text)", sb.text_sim)}
|
||||
${scorebar("Lemma 교집합 (구조)", sb.lemma_sim)}
|
||||
${scorebar("인물 일치도", sb.character_sim)}
|
||||
${scorebar("모티프 일치도", sb.motif_sim)}`;
|
||||
} else {
|
||||
breakdownEl.innerHTML = '<div style="color: var(--muted); font-size: 12px;">매칭된 레퍼런스가 없어 점수 분석을 표시할 수 없습니다.</div>';
|
||||
}
|
||||
|
||||
// 매칭 카드
|
||||
const matchesEl = document.getElementById("matches");
|
||||
if (!data.matches || data.matches.length === 0) {
|
||||
matchesEl.innerHTML = '<div style="color: var(--muted); font-size: 12px;">레퍼런스 코퍼스에서 임계치를 넘는 매칭이 없습니다.</div>';
|
||||
} else {
|
||||
matchesEl.innerHTML = data.matches.map((m) => {
|
||||
const typeClass = `type-${m.infringement_type || "unknown"}`;
|
||||
const evidenceHtml = renderEvidence(originalText, m.evidence_spans);
|
||||
const tagsHtml = renderLegalTags(m.tags || []);
|
||||
const caseHtml = m.case_id
|
||||
? `<span class="case-id-badge">케이스 ${escapeHtml(m.case_id)}${m.case_title ? " · " + escapeHtml(m.case_title) : ""}</span>`
|
||||
: "";
|
||||
return `
|
||||
<div class="match-card">
|
||||
<div>
|
||||
<span class="title">${escapeHtml(m.source_title || m.source_doc)}</span>
|
||||
<span class="type-badge ${typeClass}">${TYPE_LABELS[m.infringement_type] || m.infringement_type}</span>
|
||||
${caseHtml}
|
||||
</div>
|
||||
<div class="meta">결합 유사도 ${(m.similarity * 100).toFixed(2)}% · doc_id: ${escapeHtml(m.source_doc)}</div>
|
||||
${tagsHtml}
|
||||
${evidenceHtml}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// 추출 요소
|
||||
const e = data.extracted_elements || {};
|
||||
const chips = (arr) => (arr || []).map(s => `<span class="chip">${escapeHtml(String(s))}</span>`).join("");
|
||||
document.getElementById("elements").innerHTML = `
|
||||
<div style="font-size: 12px;">
|
||||
<div style="margin-bottom: 8px;"><strong>인물:</strong> ${chips(e.characters) || '<span style="color: var(--muted)">없음</span>'}</div>
|
||||
<div style="margin-bottom: 8px;"><strong>모티프:</strong> ${chips(e.motifs) || '<span style="color: var(--muted)">없음</span>'}</div>
|
||||
<div style="margin-bottom: 8px;"><strong>장르:</strong> ${e.genre ? `<span class="chip">${escapeHtml(e.genre)}</span>` : '<span style="color: var(--muted)">미상</span>'}</div>
|
||||
<div><strong>키워드:</strong> ${chips(e.keywords) || '<span style="color: var(--muted)">없음</span>'}</div>
|
||||
</div>`;
|
||||
|
||||
// CCL 사유
|
||||
const cclEl = document.getElementById("ccl-basis");
|
||||
cclEl.innerHTML = data.ccl_basis ? `<div class="ccl-basis"><strong>CCL/계약 22조 기반 사유:</strong><br>${escapeHtml(data.ccl_basis)}</div>` : "";
|
||||
|
||||
document.getElementById("raw-json").textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function renderEvidence(originalText, spans) {
|
||||
if (!spans || spans.length === 0) return "";
|
||||
// span 정렬 후 마킹
|
||||
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
||||
let html = "";
|
||||
let cursor = 0;
|
||||
for (const s of sorted) {
|
||||
if (s.start < cursor) continue; // 겹침 방지
|
||||
html += escapeHtml(originalText.substring(cursor, s.start));
|
||||
html += `<mark>${escapeHtml(originalText.substring(s.start, s.end))}</mark>`;
|
||||
cursor = s.end;
|
||||
}
|
||||
html += escapeHtml(originalText.substring(cursor));
|
||||
return `<div class="evidence-text"><strong style="display: block; margin-bottom: 6px; font-size: 11px; color: var(--muted);">일치 구간 (${spans.length}개):</strong>${html}</div>`;
|
||||
}
|
||||
|
||||
function renderLegalTags(tags) {
|
||||
if (!tags || tags.length === 0) return "";
|
||||
const primary = tags.filter(t => t.role === "primary");
|
||||
const secondary = tags.filter(t => t.role === "secondary");
|
||||
let html = '<div class="tags-row">';
|
||||
if (primary.length) {
|
||||
html += '<div class="tag-group"><span class="tag-group-label">주 침해</span>';
|
||||
html += primary.map(t => `<span class="tag-chip-legal tag-primary" title="${escapeHtml(t.tag)}">${escapeHtml(t.label_ko)}</span>`).join("");
|
||||
html += '</div>';
|
||||
}
|
||||
if (secondary.length) {
|
||||
html += '<div class="tag-group"><span class="tag-group-label">보조</span>';
|
||||
html += secondary.map(t => `<span class="tag-chip-legal tag-secondary" title="${escapeHtml(t.tag)}">${escapeHtml(t.label_ko)}</span>`).join("");
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function scorebar(label, value) {
|
||||
const pct = Math.min(100, Math.max(0, value * 100));
|
||||
return `
|
||||
<div class="scorebar-row">
|
||||
<span class="label">${label}</span>
|
||||
<div class="scorebar"><div style="width: ${pct}%;"></div></div>
|
||||
<span class="value">${value.toFixed(3)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
// ========== 임계값 슬라이더 ==========
|
||||
document.getElementById("threshold-slider").addEventListener("input", (e) => {
|
||||
document.getElementById("threshold-value").textContent = parseFloat(e.target.value).toFixed(2);
|
||||
});
|
||||
|
||||
// ========== 탭 네비게이션 ==========
|
||||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const tab = btn.dataset.tab;
|
||||
document.getElementById(`tab-${tab}`).classList.add("active");
|
||||
if (tab === "corpus") loadCorpus();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 코퍼스 관리 ==========
|
||||
async function loadCorpus() {
|
||||
const apiKey = document.getElementById("corpus-api-key").value.trim();
|
||||
const tbody = document.getElementById("corpus-tbody");
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>';
|
||||
try {
|
||||
const resp = await fetch("/v1/corpus", { headers: { "X-API-Key": apiKey } });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
document.getElementById("corpus-count").textContent = data.total;
|
||||
if (data.docs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">아직 등록된 자서전이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.docs.map(d => `
|
||||
<tr>
|
||||
<td class="doc-id">${escapeHtml(d.doc_id)}</td>
|
||||
<td>${escapeHtml(d.title)}</td>
|
||||
<td>${formatBytes(d.size_bytes)}</td>
|
||||
<td><button class="btn-danger" onclick="deleteDoc('${escapeJs(d.doc_id)}')">삭제</button></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" style="color: var(--danger); padding: 20px;">로딩 실패: ${escapeHtml(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("corpus-refresh-btn").addEventListener("click", loadCorpus);
|
||||
document.getElementById("corpus-upload-btn").addEventListener("click", uploadCorpus);
|
||||
|
||||
async function uploadCorpus() {
|
||||
const docId = document.getElementById("corpus-doc-id").value.trim();
|
||||
const title = document.getElementById("corpus-title").value.trim();
|
||||
const text = document.getElementById("corpus-text").value.trim();
|
||||
const file = document.getElementById("corpus-file").files[0];
|
||||
const apiKey = document.getElementById("corpus-api-key").value.trim();
|
||||
const statusEl = document.getElementById("corpus-status");
|
||||
|
||||
if (!title) {
|
||||
showCorpusStatus("err", "제목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!text && !file) {
|
||||
showCorpusStatus("err", "본문 텍스트 또는 .txt 파일을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("corpus-upload-btn");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loader"></span> 업로드 중…';
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (file) {
|
||||
const fd = new FormData();
|
||||
fd.append("title", title);
|
||||
if (docId) fd.append("doc_id", docId);
|
||||
fd.append("file", file);
|
||||
resp = await fetch("/v1/corpus/file", { method: "POST", headers: { "X-API-Key": apiKey }, body: fd });
|
||||
} else {
|
||||
resp = await fetch("/v1/corpus", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
|
||||
body: JSON.stringify({ doc_id: docId || null, title, text }),
|
||||
});
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status} — ${errBody}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
showCorpusStatus("ok", `✓ 업로드 완료: ${data.doc_id} (${data.title}) · 코퍼스 ${data.corpus_size_after}건. 인덱스 재빌드 완료.`);
|
||||
document.getElementById("corpus-doc-id").value = "";
|
||||
document.getElementById("corpus-title").value = "";
|
||||
document.getElementById("corpus-text").value = "";
|
||||
document.getElementById("corpus-file").value = "";
|
||||
await loadCorpus();
|
||||
await checkHealth();
|
||||
} catch (err) {
|
||||
showCorpusStatus("err", `업로드 실패: ${err.message}`);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "업로드";
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
if (!confirm(`'${docId}' 문서를 삭제하시겠습니까? 인덱스가 재빌드됩니다.`)) return;
|
||||
const apiKey = document.getElementById("corpus-api-key").value.trim();
|
||||
try {
|
||||
const resp = await fetch(`/v1/corpus/${encodeURIComponent(docId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-API-Key": apiKey },
|
||||
});
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
throw new Error(`HTTP ${resp.status}`);
|
||||
}
|
||||
showCorpusStatus("ok", `✓ '${docId}' 삭제 완료. 인덱스 재빌드 완료.`);
|
||||
await loadCorpus();
|
||||
await checkHealth();
|
||||
} catch (err) {
|
||||
showCorpusStatus("err", `삭제 실패: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showCorpusStatus(level, msg) {
|
||||
const el = document.getElementById("corpus-status");
|
||||
el.className = `upload-status ${level}`;
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function escapeJs(s) {
|
||||
return String(s).replace(/['\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// 헬스 체크 + 코퍼스 정보 표시
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch("/v1/health");
|
||||
if (!resp.ok) throw new Error("not ok");
|
||||
const data = await resp.json();
|
||||
const autobio = data.autobiography_mode ? ' · 자서전모드' : '';
|
||||
document.getElementById("health-badge").textContent = `● 정상 · 코퍼스 ${data.corpus_size}건${autobio}`;
|
||||
document.getElementById("health-badge").className = "badge ok";
|
||||
document.getElementById("engine-version").textContent = data.engine_version;
|
||||
document.getElementById("corpus-size").textContent = data.corpus_size;
|
||||
if (data.taxonomy_version) {
|
||||
const footer = document.querySelector("footer");
|
||||
footer.innerHTML += ` · 분류체계: ${escapeHtml(data.taxonomy_version)}`;
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById("health-badge").textContent = "● 연결 실패";
|
||||
document.getElementById("health-badge").className = "badge err";
|
||||
}
|
||||
}
|
||||
checkHealth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# 자서전 빈출 공통 표현 사전 (PDF VII-4 오탐 방지)
|
||||
# 이 패턴들은 자서전 어디서나 나타나 표면 유사도를 자연스럽게 높이므로
|
||||
# 정밀 비교 전에 본문에서 제거 (또는 가중치 0).
|
||||
# 한 줄 1패턴, # 으로 시작하는 줄은 주석.
|
||||
|
||||
# 학교·교육 단계
|
||||
초등학교에 입학하였다
|
||||
초등학교에 입학했다
|
||||
중학교에 입학하였다
|
||||
중학교에 입학했다
|
||||
고등학교에 입학하였다
|
||||
고등학교에 입학했다
|
||||
대학교에 입학하였다
|
||||
대학교에 입학했다
|
||||
학교에 다녔다
|
||||
초등학교를 다녔다
|
||||
고등학교를 졸업하였다
|
||||
고등학교를 졸업했다
|
||||
대학교를 졸업하였다
|
||||
대학교를 졸업했다
|
||||
|
||||
# 가족·출생
|
||||
태어났다
|
||||
태어났습니다
|
||||
태어났던 것이다
|
||||
남동생이 태어났다
|
||||
여동생이 태어났다
|
||||
아들이 태어났다
|
||||
딸이 태어났다
|
||||
형이 있었다
|
||||
누나가 있었다
|
||||
부모님은 농사를 지으셨다
|
||||
어머니는 살림을 하셨다
|
||||
아버지는 회사에 다니셨다
|
||||
|
||||
# 결혼·가정
|
||||
결혼을 하였다
|
||||
결혼을 했다
|
||||
결혼식을 올렸다
|
||||
신혼생활을 시작했다
|
||||
첫아이를 낳았다
|
||||
|
||||
# 군대·직장
|
||||
군대에 입대하였다
|
||||
군대에 입대했다
|
||||
군에 입대했다
|
||||
군대를 제대했다
|
||||
회사에 취직하였다
|
||||
회사에 취직했다
|
||||
직장에 들어갔다
|
||||
직장 생활을 시작했다
|
||||
퇴직을 하였다
|
||||
퇴직했다
|
||||
은퇴를 하였다
|
||||
은퇴했다
|
||||
|
||||
# 이사·거주
|
||||
이사를 갔다
|
||||
이사를 했다
|
||||
이사를 다녔다
|
||||
서울로 올라왔다
|
||||
시골에서 자랐다
|
||||
|
||||
# 시간·계절 흔한 시작
|
||||
어느 날이었다
|
||||
그때 그 시절
|
||||
그 시절에는
|
||||
지금도 생각이 난다
|
||||
지금도 잊을 수 없다
|
||||
세월이 흘렀다
|
||||
시간이 지났다
|
||||
오랜 시간이 흘렀다
|
||||
|
||||
# 감정·회고 정형 표현
|
||||
지금 생각해도
|
||||
돌이켜보면
|
||||
돌아보면
|
||||
지나고 보니
|
||||
그땐 몰랐다
|
||||
그땐 어렸다
|
||||
|
||||
# 죽음·이별 정형
|
||||
세상을 떠나셨다
|
||||
돌아가셨다
|
||||
별세하셨다
|
||||
하늘나라로 가셨다
|
||||
영면에 드셨다
|
||||
|
||||
# 친목·만남
|
||||
친구를 만났다
|
||||
오랜 친구를 만났다
|
||||
동창들을 만났다
|
||||
가족과 함께 식사를 했다
|
||||
|
|
@ -0,0 +1 @@
|
|||
어린왕자는 작은 별 B-612에서 온 호기심 많은 소년이다. 그는 자신의 별에서 장미 한 송이를 사랑하며 살았다. 어느 날 그는 별을 떠나 여러 행성을 여행하기 시작한다. 첫 번째 행성에서는 명령만 내리는 왕을 만나고, 두 번째 행성에서는 허영심에 가득 찬 사람을 만난다. 세 번째 행성에서는 술을 마시는 사람, 네 번째 행성에서는 별을 세는 사업가를 만난다. 다섯 번째 행성에서는 가로등을 켜는 사람, 여섯 번째 행성에서는 지리학자를 만난다. 마침내 일곱 번째 행성인 지구에 도착한 어린왕자는 사막에 불시착한 비행기 조종사를 만난다. 그곳에서 여우와 만나 길들임의 의미를 배우고, 자신이 사랑한 장미가 세상에서 유일한 존재임을 깨닫는다. 어린왕자는 자신의 별로 돌아가기 위해 뱀에게 도움을 청한다. 조종사는 어린왕자가 떠난 자리에서 별을 바라보며 그를 그리워한다.
|
||||
|
|
@ -0,0 +1 @@
|
|||
앤 셜리는 상상력이 풍부하고 말이 많은 고아 소녀다. 그녀는 매슈와 마릴라 남매가 사는 초록 지붕 집으로 입양된다. 매슈와 마릴라는 원래 농장 일을 도울 남자아이를 입양하려 했지만, 착오로 앤이 오게 된다. 처음에는 앤을 돌려보내려 했지만, 그녀의 따뜻한 마음과 풍부한 상상력에 매료되어 함께 살기로 결정한다. 앤은 학교에서 다이애나 배리와 평생 친구가 되고, 길버트 블라이스와는 처음에는 다투지만 점차 라이벌이자 친구가 된다. 그녀는 자신의 빨간 머리를 싫어하지만 점차 자신을 사랑하는 법을 배운다. 앤은 공부에 매진하여 퀸스 학원에 진학하고 장학금을 받으며 성장한다. 매슈가 세상을 떠나자 앤은 마릴라와 농장을 지키기 위해 대학 진학을 포기하고 마을의 교사가 되기로 결심한다.
|
||||
|
|
@ -0,0 +1 @@
|
|||
홍길동은 조선시대 한 재상의 서자로 태어났다. 그는 어려서부터 비범한 재주를 보였으나, 서자라는 신분 때문에 아버지를 아버지라 부르지 못하고 형을 형이라 부르지 못하는 한을 품고 자란다. 자객의 위협을 피해 집을 떠난 길동은 도적의 무리에 들어가 우두머리가 된다. 그는 무리의 이름을 활빈당이라 짓고, 부패한 관리와 탐관오리의 재물을 빼앗아 가난한 백성에게 나누어 준다. 조정에서는 길동을 잡으려 하나, 그는 도술과 기지로 번번이 빠져나간다. 결국 조정은 그를 병조판서에 임명하여 회유하지만, 길동은 조선을 떠나 율도국으로 향한다. 그곳에서 그는 왕이 되어 이상적인 나라를 세운다.
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"version": "1.2",
|
||||
"source": "나누구_저작권침해_아카이빙_실무방안_v1.2.pdf (IX장)",
|
||||
"total_cases": 38,
|
||||
"groups": {
|
||||
"A": {
|
||||
"label": "저자(가해)",
|
||||
"count": 27,
|
||||
"share": 0.711,
|
||||
"role": "본문 텍스트 표절 검출의 핵심 대상"
|
||||
},
|
||||
"B": {
|
||||
"label": "저자(피해)",
|
||||
"count": 4,
|
||||
"share": 0.105,
|
||||
"role": "신고·삭제 절차(notice & takedown) 대상"
|
||||
},
|
||||
"C": {
|
||||
"label": "플랫폼",
|
||||
"count": 3,
|
||||
"share": 0.079,
|
||||
"role": "약관·라이선스·동의 절차 대상"
|
||||
},
|
||||
"D": {
|
||||
"label": "다른 사용자",
|
||||
"count": 1,
|
||||
"share": 0.026,
|
||||
"role": "2차 창작 기능 운영 정책 대상"
|
||||
},
|
||||
"E": {
|
||||
"label": "유족",
|
||||
"count": 2,
|
||||
"share": 0.053,
|
||||
"role": "사후 권리 가이드라인 대상"
|
||||
},
|
||||
"X": {
|
||||
"label": "분류체계 외",
|
||||
"count": 2,
|
||||
"share": 0.053,
|
||||
"role": "별도 약관·운영 절차"
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{"case_id": "A1", "old_no": 1, "subgroup": "A-1 외부 텍스트 인용·수록", "title": "시·노래 가사 본문 무단 인용", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "distribution"], "detectable_internal": true},
|
||||
{"case_id": "A2", "old_no": 2, "subgroup": "A-1 외부 텍스트 인용·수록", "title": "소설·수필 본문 발췌 무단 수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "distribution"], "detectable_internal": true},
|
||||
{"case_id": "A3", "old_no": 3, "subgroup": "A-1 외부 텍스트 인용·수록", "title": "신문·잡지 기사 전문 옮겨 적기", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "distribution"], "detectable_internal": true},
|
||||
{"case_id": "A4", "old_no": 4, "subgroup": "A-1 외부 텍스트 인용·수록", "title": "인터넷 블로그·SNS 글 무단 수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": true},
|
||||
{"case_id": "A5", "old_no": 5, "subgroup": "A-1 외부 텍스트 인용·수록", "title": "위키백과·백과사전 본문 그대로 사용", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": true},
|
||||
|
||||
{"case_id": "A6", "old_no": 6, "subgroup": "A-2 타인 자서전·회고", "title": "유명인 자서전 일부 베끼기", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "distribution"], "detectable_internal": false},
|
||||
{"case_id": "A7", "old_no": 7, "subgroup": "A-2 타인 자서전·회고", "title": "다른 자서전 줄거리·서사 변형 차용", "actor": "저자(가해)", "primary_tags": ["derivative_work", "substandard_derivative"], "secondary_tags": ["reproduction", "publication", "attribution"], "detectable_internal": false},
|
||||
{"case_id": "A8", "old_no": 8, "subgroup": "A-2 타인 자서전·회고", "title": "친구·가족 회고를 본인 글로 옮김", "actor": "저자(가해)", "primary_tags": ["reproduction", "publication"], "secondary_tags": ["attribution", "false_authorship"], "detectable_internal": false},
|
||||
{"case_id": "A9", "old_no": 9, "subgroup": "A-2 타인 자서전·회고", "title": "가족 일기·편지 무단 수록 (미공표)", "actor": "저자(가해)", "primary_tags": ["reproduction", "publication"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": false},
|
||||
{"case_id": "A10", "old_no": 10, "subgroup": "A-2 타인 자서전·회고", "title": "부모·조부모 자서전 통째 재수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "derivative_work"], "secondary_tags": ["distribution", "publication", "attribution"], "detectable_internal": false, "high_risk": true},
|
||||
{"case_id": "A11", "old_no": 11, "subgroup": "A-2 타인 자서전·회고", "title": "학창시절 친구의 시·편지 수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "attribution"], "secondary_tags": ["publication"], "detectable_internal": false},
|
||||
{"case_id": "A12", "old_no": 12, "subgroup": "A-2 타인 자서전·회고", "title": "동료 이메일·업무문서 인용", "actor": "저자(가해)", "primary_tags": ["reproduction", "publication"], "secondary_tags": ["attribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "A13", "old_no": 36, "subgroup": "A-3 구술·강연·녹음", "title": "부모님·스승의 강연·설교 녹음해 본문에 옮김", "actor": "저자(가해)", "primary_tags": ["reproduction", "publication"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": false, "high_risk": true},
|
||||
|
||||
{"case_id": "A14", "old_no": 38, "subgroup": "A-4 학술·교육 자료", "title": "본인 과거 논문·학위논문 발췌 무단 수록", "actor": "저자(가해)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission", "distribution", "attribution"], "detectable_internal": false},
|
||||
{"case_id": "A15", "old_no": 39, "subgroup": "A-4 학술·교육 자료", "title": "교과서·교재 본문 그대로 수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "citation_missing"], "secondary_tags": ["public_transmission", "distribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "A16", "old_no": 31, "subgroup": "A-5 번역물", "title": "외국 자서전·도서 직접 번역해 본인 글로 수록", "actor": "저자(가해)", "primary_tags": ["derivative_work", "citation_missing"], "secondary_tags": ["reproduction", "public_transmission", "distribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "A17", "old_no": 13, "subgroup": "A-6 이미지·시각 자산", "title": "인터넷 옛 사진·포스터 본문 삽입", "actor": "저자(가해)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": false},
|
||||
{"case_id": "A18", "old_no": 14, "subgroup": "A-6 이미지·시각 자산", "title": "졸업앨범 단체사진 무단 사용", "actor": "저자(가해)", "primary_tags": ["reproduction"], "secondary_tags": [], "detectable_internal": false},
|
||||
{"case_id": "A19", "old_no": 15, "subgroup": "A-6 이미지·시각 자산", "title": "신문 스크랩·잡지 표지 사진 사용", "actor": "저자(가해)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": false},
|
||||
{"case_id": "A20", "old_no": 44, "subgroup": "A-6 이미지·시각 자산", "title": "만화 캐릭터·브랜드 로고 본문 삽입", "actor": "저자(가해)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "A21", "old_no": 16, "subgroup": "A-7 음원·영상", "title": "오디오북 BGM에 상업 음원 사용", "actor": "저자(가해)", "primary_tags": ["reproduction", "public_transmission"], "secondary_tags": ["attribution"], "detectable_internal": false, "high_risk": true},
|
||||
|
||||
{"case_id": "A22", "old_no": 49, "subgroup": "A-8 디지털 사적 통신", "title": "동창회 카톡방·단톡방 대화 캡처 수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "publication"], "secondary_tags": ["public_transmission", "attribution"], "detectable_internal": false, "high_risk": true},
|
||||
|
||||
{"case_id": "A23", "old_no": 51, "subgroup": "A-9 사후·고인 자료", "title": "사망한 친구·동료의 유작·일기 본문 수록", "actor": "저자(가해)", "primary_tags": ["reproduction", "publication", "attribution"], "secondary_tags": [], "detectable_internal": false, "high_risk": true},
|
||||
|
||||
{"case_id": "A24", "old_no": 17, "subgroup": "A-10 AI 도구 사용", "title": "AI memorization 반환 결과 사용", "actor": "저자(가해, 비의도)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission", "citation_missing"], "detectable_internal": true},
|
||||
{"case_id": "A25", "old_no": 18, "subgroup": "A-10 AI 도구 사용", "title": "AI 생성물 우연 유사 (기존 저작물)", "actor": "저자(가해, 비의도)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission"], "detectable_internal": true},
|
||||
{"case_id": "A26", "old_no": 19, "subgroup": "A-10 AI 도구 사용", "title": "AI 결과물을 본인 저작인 양 표시", "actor": "저자(가해)", "primary_tags": ["false_authorship"], "secondary_tags": ["attribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "A27", "old_no": 20, "subgroup": "A-11 대필", "title": "대필 작가 작성분을 저자 단독 명의 출간", "actor": "저자·플랫폼", "primary_tags": ["false_authorship"], "secondary_tags": ["attribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "B1", "old_no": 22, "subgroup": "B 저자(피해)", "title": "다른 사용자가 내 자서전 베낌", "actor": "저자(피해)", "primary_tags": ["reproduction"], "secondary_tags": ["public_transmission", "distribution", "attribution", "citation_missing", "false_authorship"], "detectable_internal": true},
|
||||
{"case_id": "B2", "old_no": 23, "subgroup": "B 저자(피해)", "title": "다른 사용자가 내 서사·구조 차용", "actor": "저자(피해)", "primary_tags": ["derivative_work", "substandard_derivative"], "secondary_tags": ["attribution", "citation_missing"], "detectable_internal": true},
|
||||
{"case_id": "B3", "old_no": 24, "subgroup": "B 저자(피해)", "title": "외부 사이트·SNS의 내 자서전 무단 게재", "actor": "저자(피해)", "primary_tags": ["reproduction", "public_transmission"], "secondary_tags": ["attribution"], "detectable_internal": false, "high_risk": true},
|
||||
{"case_id": "B4", "old_no": 25, "subgroup": "B 저자(피해)", "title": "외부의 AI 학습 데이터로 무단 수집", "actor": "저자(피해)", "primary_tags": ["reproduction", "public_transmission"], "secondary_tags": [], "detectable_internal": false, "high_risk": true},
|
||||
|
||||
{"case_id": "C1", "old_no": 21, "subgroup": "C 플랫폼", "title": "편집자·교정자 손이 많이 들어간 경우", "actor": "플랫폼", "primary_tags": ["integrity"], "secondary_tags": ["derivative_work", "attribution"], "detectable_internal": false},
|
||||
{"case_id": "C2", "old_no": 42, "subgroup": "C 플랫폼", "title": "유료 폰트 무단 사용 (본문·표지)", "actor": "저자·플랫폼", "primary_tags": ["reproduction"], "secondary_tags": ["distribution"], "detectable_internal": false},
|
||||
{"case_id": "C3", "old_no": 54, "subgroup": "C 플랫폼", "title": "플랫폼이 사용자 자서전을 AI 학습 데이터로 사용", "actor": "플랫폼", "primary_tags": ["reproduction", "public_transmission"], "secondary_tags": ["derivative_work"], "detectable_internal": false, "high_risk": true},
|
||||
|
||||
{"case_id": "D1", "old_no": 26, "subgroup": "D 다른 사용자", "title": "2차 창작 기능에서 원저자 동의 없는 변형", "actor": "다른 사용자", "primary_tags": ["derivative_work", "attribution", "integrity"], "secondary_tags": ["publication"], "detectable_internal": true, "high_risk": true},
|
||||
|
||||
{"case_id": "E1", "old_no": 27, "subgroup": "E 유족", "title": "저자 사후 유족이 본문 임의 수정", "actor": "유족", "primary_tags": ["integrity"], "secondary_tags": ["reproduction", "distribution"], "detectable_internal": false},
|
||||
{"case_id": "E2", "old_no": 28, "subgroup": "E 유족", "title": "사후 유고 자서전에 미공표 일기 포함", "actor": "유족", "primary_tags": ["reproduction", "publication"], "secondary_tags": ["attribution"], "detectable_internal": false},
|
||||
|
||||
{"case_id": "X1", "old_no": 29, "subgroup": "X 분류체계 외", "title": "자서전 등장 제3자의 사적 정보 노출", "actor": "저자(가해)", "primary_tags": [], "secondary_tags": [], "detectable_internal": false, "note": "사생활 침해 영역, 약관·운영 절차로 처리"},
|
||||
{"case_id": "X2", "old_no": 30, "subgroup": "X 분류체계 외", "title": "자서전 등장 제3자의 명예 훼손 묘사", "actor": "저자(가해)", "primary_tags": [], "secondary_tags": [], "detectable_internal": false, "note": "명예훼손 영역, 약관·운영 절차로 처리"}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"version": "1.0",
|
||||
"source": "나누구_저작권침해_아카이빙_실무방안_v1.2.pdf (IV장)",
|
||||
"principle": "어문저작물(텍스트·전자책·종이책) 중심. 공연·전시·대여권은 분류 외 처리.",
|
||||
"tags": [
|
||||
{
|
||||
"id": "reproduction",
|
||||
"label_ko": "복제권",
|
||||
"category": "저작재산권",
|
||||
"law_ref": "저작권법 제16조",
|
||||
"scope": "핵심",
|
||||
"description": "저작물을 인쇄·복사·녹음·녹화 등 유형물로 다시 제작할 권리"
|
||||
},
|
||||
{
|
||||
"id": "public_transmission",
|
||||
"label_ko": "공중송신권",
|
||||
"category": "저작재산권",
|
||||
"law_ref": "저작권법 제18조 (전송 포함)",
|
||||
"scope": "핵심",
|
||||
"description": "공중이 수신·접근하게 송신할 권리. 전자책·웹 게재 시 적용"
|
||||
},
|
||||
{
|
||||
"id": "distribution",
|
||||
"label_ko": "배포권",
|
||||
"category": "저작재산권",
|
||||
"law_ref": "저작권법 제20조",
|
||||
"scope": "유형물 한정",
|
||||
"description": "종이책·큰글자책 등 유형물 양도·대여 권한"
|
||||
},
|
||||
{
|
||||
"id": "derivative_work",
|
||||
"label_ko": "2차적저작물작성권",
|
||||
"category": "저작재산권",
|
||||
"law_ref": "저작권법 제5조, 제22조",
|
||||
"scope": "핵심",
|
||||
"description": "원저작물을 번역·편곡·각색·변형 등 2차 창작할 권리"
|
||||
},
|
||||
{
|
||||
"id": "publication",
|
||||
"label_ko": "공표권",
|
||||
"category": "저작인격권",
|
||||
"law_ref": "저작권법 제11조",
|
||||
"scope": "핵심",
|
||||
"description": "저작물을 공중에 공개할지 결정할 권리. 미공표 사적기록(일기·강연녹음 등) 무단 사용 시 침해"
|
||||
},
|
||||
{
|
||||
"id": "attribution",
|
||||
"label_ko": "성명표시권",
|
||||
"category": "저작인격권",
|
||||
"law_ref": "저작권법 제12조",
|
||||
"scope": "핵심",
|
||||
"description": "저작물에 실명·이명을 표시할 권리. AI 집필 결과물 작성자 표시 누락도 포함"
|
||||
},
|
||||
{
|
||||
"id": "integrity",
|
||||
"label_ko": "동일성유지권",
|
||||
"category": "저작인격권",
|
||||
"law_ref": "저작권법 제13조",
|
||||
"scope": "핵심",
|
||||
"description": "저작물의 내용·형식·제호 동일성을 유지할 권리. 교정·교열에 따른 변형 시 적용"
|
||||
},
|
||||
{
|
||||
"id": "citation_missing",
|
||||
"label_ko": "인용 표시 누락",
|
||||
"category": "표절 실무",
|
||||
"law_ref": "한국저작권위원회 상담사례집",
|
||||
"scope": "핵심",
|
||||
"description": "출처를 명시하지 않고 타인 저작물을 본문에 포함"
|
||||
},
|
||||
{
|
||||
"id": "false_authorship",
|
||||
"label_ko": "자기 창작인 양 표시",
|
||||
"category": "표절 실무",
|
||||
"law_ref": "한국저작권위원회 상담사례집",
|
||||
"scope": "핵심",
|
||||
"description": "타인의 글·아이디어를 본인이 창작한 것처럼 표시"
|
||||
},
|
||||
{
|
||||
"id": "substandard_derivative",
|
||||
"label_ko": "2차적저작물 미달 가공",
|
||||
"category": "표절 실무",
|
||||
"law_ref": "한국저작권위원회 상담사례집",
|
||||
"scope": "핵심",
|
||||
"description": "원저작물에 충분한 새 창작성을 더하지 않은 가공을 새 창작인 양 표시"
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
{
|
||||
"id": "performance",
|
||||
"label_ko": "공연권",
|
||||
"reason": "텍스트·전자책 송신 중심 서비스로 공연 행위 부재"
|
||||
},
|
||||
{
|
||||
"id": "exhibition",
|
||||
"label_ko": "전시권",
|
||||
"reason": "대법원 2010도4468 판결 - 어문저작물 미적용 (표지·삽입 이미지는 별도 관리)"
|
||||
},
|
||||
{
|
||||
"id": "rental",
|
||||
"label_ko": "대여권",
|
||||
"reason": "한국 저작권법상 상업용 음반·일부 프로그램 한정, 어문저작물 미적용"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
services:
|
||||
plagiarism-api:
|
||||
build: .
|
||||
container_name: o2o-plagiarism-api
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
API_KEYS: ${API_KEYS:-combooks-key-change-me,baikal-key-change-me}
|
||||
ENGINE_VERSION: ${ENGINE_VERSION:-o2o-plagiarism-1.0.0-baseline}
|
||||
REFERENCE_CORPUS_DIR: /app/data/reference
|
||||
SIMILARITY_THRESHOLD: ${SIMILARITY_THRESHOLD:-0.30}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -0,0 +1,41 @@
|
|||
# 사내 plagia_result 데이터셋 평가 리포트
|
||||
|
||||
- **데이터셋**: 표절 페어 499건 + 비표절 페어 499건 (총 999쌍)
|
||||
- **엔진 버전**: o2o-plagiarism-1.2.0-hybrid-openai
|
||||
- **하이브리드 결합**: `score = α·meta_emb + (1-α)·lemma_overlap`
|
||||
|
||||
## 1. 점수 분포 (POS vs NEG 분리도)
|
||||
|
||||
| 점수 | POS 평균 | NEG 평균 | **분리도** | std(POS / NEG) |
|
||||
|---|---|---|---|---|
|
||||
| 메타 임베딩 코사인 | 0.8632 | 0.6665 | **+0.1967** | 0.085 / 0.128 |
|
||||
| **Lemma 교집합 비율** | **0.7807** | **0.2844** | **+0.4964** | 0.100 / 0.179 |
|
||||
|
||||
→ Lemma의 분리도가 메타보다 약 2.5배 넓음. 표절-비표절을 점수만으로 더 깨끗하게 구분 가능.
|
||||
|
||||
→ 그래프: `reports/01_score_distributions.png`
|
||||
|
||||
## 2. 모델별 최적 성능 (F1 최대화 threshold)
|
||||
|
||||
| 모델 | Precision | Recall | **F1** | Threshold |
|
||||
|---|---|---|---|---|
|
||||
| 기존 result.json (전임자 1단계 산출물) | 0.9520 | 0.9560 | **0.9540** | 0.78 |
|
||||
| 메타 임베딩 단독 | 0.7842 | 0.8720 | 0.8258 | 0.76 |
|
||||
| **Lemma 단독** (구조 분석) | **0.9391** | **0.9560** | **0.9475** | 0.59 |
|
||||
| **하이브리드 α=0.30** (Recommended) | **0.9278** | **0.9760** | **0.9513** | 0.63 |
|
||||
|
||||
→ 그래프: `reports/02_threshold_curves.png`, `reports/03_model_comparison.png`
|
||||
|
||||
## 3. Confusion Matrix (하이브리드 α=0.30, threshold=0.63)
|
||||
|
||||
| | 예측: 표절 | 예측: 비표절 |
|
||||
|---|---|---|
|
||||
| **실제: 표절** | TP = 488 | FN = 12 |
|
||||
| **실제: 비표절** | FP = 38 | TN = 461 |
|
||||
|
||||
## 4. 결론
|
||||
|
||||
1. **전임자 가이드 검증** — "의미 스코어(메타 임베딩) + 구조 스코어(lemma 교집합) → 하이브리드" 구조가 실제 데이터로 입증됨
|
||||
2. **Lemma가 핵심 신호** — augmented 케이스가 "어미·조사만 변경" 패턴이 많아 lemma 단독으로도 F1 0.9475 달성
|
||||
3. **하이브리드가 가장 안정** — 하이브리드 α=0.30에서 recall 0.9760 (표절을 거의 다 잡음)
|
||||
4. **권장 운영 임계치** — `SIMILARITY_THRESHOLD=0.63`, `WEIGHT_TEXT_SIM=0.30`, `WEIGHT_LEMMA_SIM=0.45`
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2.11
|
||||
pydantic-settings>=2.6
|
||||
scikit-learn>=1.5
|
||||
numpy>=1.26
|
||||
python-multipart>=0.0.12
|
||||
openai>=1.55
|
||||
httpx>=0.27
|
||||
kiwipiepy>=0.18
|
||||
datasketch>=1.6
|
||||
sentence-transformers>=3.0
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
"""사내 plagia_result 데이터셋(pos 500 + neg 500)으로 하이브리드 성능 평가.
|
||||
|
||||
전임자 가이드 검증:
|
||||
- 의미기반 스코어: 기존 1단계 모델의 메타데이터 임베딩 코사인 (이미 계산되어 있음)
|
||||
- 구조기반 스코어: 우리 엔진의 lemma 교집합 비율 (본문 텍스트 기반)
|
||||
- 조합: hybrid = α * meta_sim + (1-α) * lemma_sim
|
||||
→ α와 threshold 그리드 서치로 최적 F1 도출
|
||||
|
||||
사용:
|
||||
python scripts/evaluate_o2o_dataset.py \
|
||||
--data-dir /Users/marineyang/Desktop/work/code/AI_publish_3rdtest/25/plagia_result
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.engine.structural import extract_lemmas, lemma_overlap_ratio # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logger = logging.getLogger("eval-o2o")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sample:
|
||||
sample_id: str
|
||||
is_plagiarism: bool
|
||||
original_text: str
|
||||
augmented_text: str
|
||||
meta_sim: float | None # 기존 모델 점수
|
||||
lemma_sim: float | None = None # 평가 시 채워짐
|
||||
|
||||
|
||||
def _load_meta_sims(data_dir: Path) -> dict[str, float]:
|
||||
candidates = sorted(data_dir.glob("all_similarities_*.json"))
|
||||
if not candidates:
|
||||
return {}
|
||||
with candidates[-1].open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
out: dict[str, float] = {}
|
||||
for row in data.get("pos_results", []):
|
||||
if row.get("cosine_similarity") is not None:
|
||||
out[row["id"]] = float(row["cosine_similarity"])
|
||||
for row in data.get("neg_results", []):
|
||||
if row.get("cosine_similarity") is not None:
|
||||
out[row["id"]] = float(row["cosine_similarity"])
|
||||
return out
|
||||
|
||||
|
||||
def _load_samples(data_dir: Path) -> list[Sample]:
|
||||
pos = json.load((data_dir / "plagiarism_pos_metadata.json").open("r", encoding="utf-8"))
|
||||
neg = json.load((data_dir / "plagiarism_neg_metadata.json").open("r", encoding="utf-8"))
|
||||
meta_sims = _load_meta_sims(data_dir)
|
||||
|
||||
samples: list[Sample] = []
|
||||
for i, item in enumerate(pos, start=1):
|
||||
sid = f"POS{i:03d}"
|
||||
samples.append(Sample(
|
||||
sample_id=sid,
|
||||
is_plagiarism=True,
|
||||
original_text=item["original_text"],
|
||||
augmented_text=item["augmented_text"],
|
||||
meta_sim=meta_sims.get(sid),
|
||||
))
|
||||
for i, item in enumerate(neg, start=1):
|
||||
sid = f"NEG{i:03d}"
|
||||
samples.append(Sample(
|
||||
sample_id=sid,
|
||||
is_plagiarism=False,
|
||||
original_text=item["original_text"],
|
||||
augmented_text=item["augmented_text"],
|
||||
meta_sim=meta_sims.get(sid),
|
||||
))
|
||||
return samples
|
||||
|
||||
|
||||
def _compute_lemma_sims(samples: list[Sample]) -> None:
|
||||
"""augmented (의심 표절본) 기준 lemma 교집합 비율."""
|
||||
for i, s in enumerate(samples, 1):
|
||||
q = extract_lemmas(s.augmented_text)
|
||||
r = extract_lemmas(s.original_text)
|
||||
s.lemma_sim = lemma_overlap_ratio(q, r)
|
||||
if i % 200 == 0:
|
||||
logger.info("Lemma extraction %d/%d", i, len(samples))
|
||||
|
||||
|
||||
def _metrics(scores: np.ndarray, labels: np.ndarray, threshold: float) -> dict[str, float]:
|
||||
pred = scores >= threshold
|
||||
tp = int(((pred == 1) & (labels == 1)).sum())
|
||||
fp = int(((pred == 1) & (labels == 0)).sum())
|
||||
tn = int(((pred == 0) & (labels == 0)).sum())
|
||||
fn = int(((pred == 0) & (labels == 1)).sum())
|
||||
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
|
||||
acc = (tp + tn) / max(1, tp + fp + tn + fn)
|
||||
return {
|
||||
"threshold": threshold, "precision": precision, "recall": recall, "f1": f1,
|
||||
"accuracy": acc, "tp": tp, "fp": fp, "tn": tn, "fn": fn,
|
||||
}
|
||||
|
||||
|
||||
def _best_threshold(scores: np.ndarray, labels: np.ndarray,
|
||||
grid: Iterable[float] = None) -> dict[str, float]:
|
||||
if grid is None:
|
||||
grid = np.arange(0.05, 0.99, 0.01)
|
||||
best = None
|
||||
for t in grid:
|
||||
m = _metrics(scores, labels, float(t))
|
||||
if best is None or m["f1"] > best["f1"]:
|
||||
best = m
|
||||
return best
|
||||
|
||||
|
||||
def _distribution_summary(scores: np.ndarray, labels: np.ndarray) -> str:
|
||||
pos_scores = scores[labels == 1]
|
||||
neg_scores = scores[labels == 0]
|
||||
return (
|
||||
f"POS n={len(pos_scores)} avg={pos_scores.mean():.4f} std={pos_scores.std():.4f} "
|
||||
f"min={pos_scores.min():.4f} max={pos_scores.max():.4f}\n"
|
||||
f"NEG n={len(neg_scores)} avg={neg_scores.mean():.4f} std={neg_scores.std():.4f} "
|
||||
f"min={neg_scores.min():.4f} max={neg_scores.max():.4f}\n"
|
||||
f"분리도(POS평균 - NEG평균) = {pos_scores.mean() - neg_scores.mean():+.4f}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", required=True, help="plagia_result 디렉토리")
|
||||
parser.add_argument("--out-json", default="data/training/o2o_eval_result.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir = Path(args.data_dir).expanduser().resolve()
|
||||
samples = _load_samples(data_dir)
|
||||
logger.info("Loaded %d samples (%d POS, %d NEG)",
|
||||
len(samples), sum(s.is_plagiarism for s in samples),
|
||||
sum(not s.is_plagiarism for s in samples))
|
||||
|
||||
logger.info("Computing lemma overlap for all pairs...")
|
||||
_compute_lemma_sims(samples)
|
||||
|
||||
# 메타 점수가 누락된 샘플 제외 (기존 결과 누락분)
|
||||
valid = [s for s in samples if s.meta_sim is not None and s.lemma_sim is not None]
|
||||
logger.info("Valid samples for evaluation: %d", len(valid))
|
||||
|
||||
labels = np.array([1 if s.is_plagiarism else 0 for s in valid])
|
||||
meta_scores = np.array([s.meta_sim for s in valid])
|
||||
lemma_scores = np.array([s.lemma_sim for s in valid])
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("[1] 메타 임베딩 점수 단독 (기존 1단계 모델 재현)")
|
||||
print("=" * 72)
|
||||
print(_distribution_summary(meta_scores, labels))
|
||||
best_meta = _best_threshold(meta_scores, labels)
|
||||
print(f"\n최적 F1: threshold={best_meta['threshold']:.2f} "
|
||||
f"P={best_meta['precision']:.4f} R={best_meta['recall']:.4f} "
|
||||
f"F1={best_meta['f1']:.4f} Acc={best_meta['accuracy']:.4f}")
|
||||
print(f" TP={best_meta['tp']} FP={best_meta['fp']} "
|
||||
f"TN={best_meta['tn']} FN={best_meta['fn']}")
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("[2] Lemma 교집합 점수 단독 (우리가 추가한 구조 분석)")
|
||||
print("=" * 72)
|
||||
print(_distribution_summary(lemma_scores, labels))
|
||||
best_lemma = _best_threshold(lemma_scores, labels)
|
||||
print(f"\n최적 F1: threshold={best_lemma['threshold']:.2f} "
|
||||
f"P={best_lemma['precision']:.4f} R={best_lemma['recall']:.4f} "
|
||||
f"F1={best_lemma['f1']:.4f} Acc={best_lemma['accuracy']:.4f}")
|
||||
print(f" TP={best_lemma['tp']} FP={best_lemma['fp']} "
|
||||
f"TN={best_lemma['tn']} FN={best_lemma['fn']}")
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("[3] 하이브리드 = α·meta + (1-α)·lemma ── α 그리드 서치")
|
||||
print("=" * 72)
|
||||
print(f"{'α(meta)':>9} {'threshold':>10} {'precision':>10} {'recall':>10} {'F1':>8} {'acc':>8}")
|
||||
best_hybrid = None
|
||||
best_alpha = None
|
||||
rows = []
|
||||
for alpha in np.arange(0.0, 1.01, 0.05):
|
||||
combined = alpha * meta_scores + (1 - alpha) * lemma_scores
|
||||
m = _best_threshold(combined, labels)
|
||||
rows.append((float(alpha), m))
|
||||
print(f"{alpha:>9.2f} {m['threshold']:>10.2f} {m['precision']:>10.4f} "
|
||||
f"{m['recall']:>10.4f} {m['f1']:>8.4f} {m['accuracy']:>8.4f}")
|
||||
if best_hybrid is None or m["f1"] > best_hybrid["f1"]:
|
||||
best_hybrid = m
|
||||
best_alpha = float(alpha)
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("[4] 요약 비교")
|
||||
print("=" * 72)
|
||||
print(f"{'모델':25s} {'precision':>10} {'recall':>10} {'F1':>8} {'threshold':>10}")
|
||||
print(f"{'기존 모델 (result.json)':25s} {0.9520:>10.4f} {0.9560:>10.4f} {0.9540:>8.4f} {0.78:>10.2f}")
|
||||
print(f"{'메타 단독 (재현)':25s} {best_meta['precision']:>10.4f} "
|
||||
f"{best_meta['recall']:>10.4f} {best_meta['f1']:>8.4f} {best_meta['threshold']:>10.2f}")
|
||||
print(f"{'Lemma 단독':25s} {best_lemma['precision']:>10.4f} "
|
||||
f"{best_lemma['recall']:>10.4f} {best_lemma['f1']:>8.4f} {best_lemma['threshold']:>10.2f}")
|
||||
print(f"{f'하이브리드 (α={best_alpha:.2f})':25s} {best_hybrid['precision']:>10.4f} "
|
||||
f"{best_hybrid['recall']:>10.4f} {best_hybrid['f1']:>8.4f} {best_hybrid['threshold']:>10.2f}")
|
||||
|
||||
out_path = ROOT / args.out_json
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"n_valid": len(valid),
|
||||
"meta_only": best_meta,
|
||||
"lemma_only": best_lemma,
|
||||
"hybrid_best": {"alpha": best_alpha, **best_hybrid},
|
||||
"hybrid_grid": [{"alpha": a, **m} for a, m in rows],
|
||||
"distributions": {
|
||||
"meta_pos_avg": float(meta_scores[labels == 1].mean()),
|
||||
"meta_neg_avg": float(meta_scores[labels == 0].mean()),
|
||||
"lemma_pos_avg": float(lemma_scores[labels == 1].mean()),
|
||||
"lemma_neg_avg": float(lemma_scores[labels == 0].mean()),
|
||||
},
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n결과 저장: {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""생성된 페어 데이터셋으로 탐지 엔진 정밀도/재현율 평가.
|
||||
|
||||
성능지표 #4 (계획서 p.23): "표절 여부 판별 정밀도(precision)" 자체 평가.
|
||||
|
||||
사용:
|
||||
python scripts/evaluate_pairs.py --pairs data/training/pairs.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logger = logging.getLogger("eval-pairs")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.engine.detector import PlagiarismDetector # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--pairs", default=str(ROOT / "data/training/pairs.jsonl"))
|
||||
parser.add_argument("--threshold", type=float, default=0.30)
|
||||
args = parser.parse_args()
|
||||
|
||||
pairs_path = Path(args.pairs)
|
||||
if not pairs_path.exists():
|
||||
logger.error("Pairs file not found: %s (먼저 generate_plagiarism_pairs.py 실행)", pairs_path)
|
||||
return 1
|
||||
|
||||
detector = PlagiarismDetector()
|
||||
logger.info("Engine ready (corpus_size=%d)", detector.corpus_size)
|
||||
|
||||
tp = fp = tn = fn = 0
|
||||
by_transformation: dict[str, dict[str, int]] = {}
|
||||
|
||||
with pairs_path.open("r", encoding="utf-8") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
row = json.loads(line)
|
||||
transformation = row.get("transformation", "unknown")
|
||||
expected = row["is_plagiarism"]
|
||||
result = detector.detect(
|
||||
doc_id=row["pair_id"],
|
||||
text=row["derived_text"],
|
||||
)
|
||||
predicted = result.is_infringement and result.confidence >= args.threshold
|
||||
|
||||
if expected and predicted:
|
||||
tp += 1
|
||||
elif expected and not predicted:
|
||||
fn += 1
|
||||
elif not expected and predicted:
|
||||
fp += 1
|
||||
else:
|
||||
tn += 1
|
||||
|
||||
bucket = by_transformation.setdefault(
|
||||
transformation, {"tp": 0, "fp": 0, "tn": 0, "fn": 0}
|
||||
)
|
||||
if expected and predicted:
|
||||
bucket["tp"] += 1
|
||||
elif expected and not predicted:
|
||||
bucket["fn"] += 1
|
||||
elif not expected and predicted:
|
||||
bucket["fp"] += 1
|
||||
else:
|
||||
bucket["tn"] += 1
|
||||
|
||||
if i % 10 == 0:
|
||||
logger.info("[%d] processed...", i)
|
||||
|
||||
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
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"전체 정밀도 (precision): {precision:.4f} (목표 0.95)")
|
||||
print(f"재현율 (recall): {recall:.4f}")
|
||||
print(f"F1: {f1:.4f}")
|
||||
print(f"TP={tp} FP={fp} TN={tn} FN={fn}")
|
||||
print()
|
||||
print("[변형 유형별]")
|
||||
for t, b in by_transformation.items():
|
||||
total = sum(b.values())
|
||||
prec_t = b["tp"] / (b["tp"] + b["fp"]) if (b["tp"] + b["fp"]) else 0.0
|
||||
rec_t = b["tp"] / (b["tp"] + b["fn"]) if (b["tp"] + b["fn"]) else 0.0
|
||||
print(f" {t:18s} n={total:3d} P={prec_t:.3f} R={rec_t:.3f} "
|
||||
f"TP={b['tp']} FP={b['fp']} TN={b['tn']} FN={b['fn']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
"""표절 페어 학습/평가 데이터 생성 스크립트.
|
||||
|
||||
레퍼런스 코퍼스의 각 원본을 5가지 변형으로 자동 생성:
|
||||
1. paraphrase - 어휘만 바꾼 패러프레이즈 (표면적 표절)
|
||||
2. character_swap - 인물 이름만 치환 (인물 차용)
|
||||
3. plot_only - 인물/배경 다른 작품 스타일, 플롯만 동일 (플롯 차용)
|
||||
4. summary - 요약/축약 (요약형 표절)
|
||||
5. legitimate - 동일 주제/장르의 다른 이야기 (정상, 비표절 음성 샘플)
|
||||
|
||||
출력: data/training/pairs.jsonl
|
||||
{"pair_id": "...", "source_doc": "ref-0001", "transformation": "paraphrase",
|
||||
"is_plagiarism": true, "original_excerpt": "...", "derived_text": "..."}
|
||||
|
||||
자체 평가 데이터셋(계획서 성능지표 #4) 용도. 운영 모델 학습 시
|
||||
이 데이터와 컴북스 보유 30,000 건 원천 자료를 결합하여 정밀도 95% 달성.
|
||||
|
||||
사용:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
python scripts/generate_plagiarism_pairs.py --pairs-per-doc 3 --out data/training/pairs.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logger = logging.getLogger("gen-pairs")
|
||||
|
||||
# 프로젝트 루트에서 실행되도록 path 설정
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.engine.corpus import ReferenceDoc, load_corpus # noqa: E402
|
||||
|
||||
|
||||
TRANSFORMATIONS = {
|
||||
"paraphrase": {
|
||||
"is_plagiarism": True,
|
||||
"instruction": (
|
||||
"원문의 의미와 사건 전개를 유지하면서, 모든 문장을 다른 표현으로 다시 써라. "
|
||||
"어휘를 60% 이상 교체하고, 문장 구조를 바꾸어 패러프레이즈를 생성하라. "
|
||||
"원문의 인물 이름과 핵심 모티프는 유지하라."
|
||||
),
|
||||
},
|
||||
"character_swap": {
|
||||
"is_plagiarism": True,
|
||||
"instruction": (
|
||||
"원문의 모든 등장인물 고유명사를 다른 이름으로 치환하라. "
|
||||
"스토리, 사건, 배경은 그대로 유지하라. 인물 성격 묘사는 동일하게 둔다."
|
||||
),
|
||||
},
|
||||
"plot_only": {
|
||||
"is_plagiarism": True,
|
||||
"instruction": (
|
||||
"원문의 플롯 구조와 사건 순서는 동일하게 유지하되, "
|
||||
"인물 이름·시대 배경·지명·소재를 완전히 다른 세계관으로 바꿔 다시 써라."
|
||||
),
|
||||
},
|
||||
"summary": {
|
||||
"is_plagiarism": True,
|
||||
"instruction": (
|
||||
"원문을 1/3 분량으로 요약하라. 핵심 사건과 인물 이름은 유지하되, "
|
||||
"문장 표현은 새로 작성하라."
|
||||
),
|
||||
},
|
||||
"legitimate": {
|
||||
"is_plagiarism": False,
|
||||
"instruction": (
|
||||
"원문과 동일한 장르·주제(모티프)이지만, "
|
||||
"완전히 다른 인물·배경·플롯의 새로운 짧은 이야기를 창작하라. "
|
||||
"원문의 인물 이름이나 사건은 절대 사용하지 마라."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pair:
|
||||
pair_id: str
|
||||
source_doc: str
|
||||
source_title: str
|
||||
transformation: str
|
||||
is_plagiarism: bool
|
||||
original_excerpt: str
|
||||
derived_text: str
|
||||
|
||||
|
||||
def _generate_one(client, model: str, doc: ReferenceDoc, transformation: str) -> Pair | None:
|
||||
cfg = TRANSFORMATIONS[transformation]
|
||||
prompt = (
|
||||
f"[작업 지시]\n{cfg['instruction']}\n\n"
|
||||
f"[원문]\n{doc.text}\n\n"
|
||||
f"[출력 형식]\n결과 텍스트만 출력. 추가 설명 금지."
|
||||
)
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a Korean creative writer producing controlled text variations."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
)
|
||||
derived = (resp.choices[0].message.content or "").strip()
|
||||
if not derived:
|
||||
return None
|
||||
return Pair(
|
||||
pair_id=str(uuid.uuid4()),
|
||||
source_doc=doc.doc_id,
|
||||
source_title=doc.title,
|
||||
transformation=transformation,
|
||||
is_plagiarism=cfg["is_plagiarism"],
|
||||
original_excerpt=doc.text,
|
||||
derived_text=derived,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Generation failed for %s/%s: %s", doc.doc_id, transformation, exc)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--corpus-dir", default=str(ROOT / "data/reference"))
|
||||
parser.add_argument("--out", default=str(ROOT / "data/training/pairs.jsonl"))
|
||||
parser.add_argument("--model", default=os.environ.get("OPENAI_PAIR_MODEL", "gpt-4o-mini"))
|
||||
parser.add_argument("--pairs-per-doc", type=int, default=1,
|
||||
help="문서당 각 변형 유형 몇 번 생성할지 (총=유형5 × pairs-per-doc)")
|
||||
parser.add_argument("--limit-docs", type=int, default=0,
|
||||
help="처음 N개 문서만 처리 (0 = 전체)")
|
||||
parser.add_argument("--transformations", nargs="+", default=list(TRANSFORMATIONS.keys()),
|
||||
choices=list(TRANSFORMATIONS.keys()))
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
logger.error("OPENAI_API_KEY 환경변수가 필요합니다.")
|
||||
return 1
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
logger.error("openai 패키지가 필요합니다. pip install openai")
|
||||
return 1
|
||||
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
docs = load_corpus(Path(args.corpus_dir).resolve())
|
||||
if not docs:
|
||||
logger.error("코퍼스가 비어있음: %s", args.corpus_dir)
|
||||
return 1
|
||||
if args.limit_docs > 0:
|
||||
docs = docs[: args.limit_docs]
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total = 0
|
||||
plagiarism = 0
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
for doc in docs:
|
||||
for transformation in args.transformations:
|
||||
for _ in range(args.pairs_per_doc):
|
||||
pair = _generate_one(client, args.model, doc, transformation)
|
||||
if pair is None:
|
||||
continue
|
||||
f.write(json.dumps(pair.__dict__, ensure_ascii=False) + "\n")
|
||||
total += 1
|
||||
if pair.is_plagiarism:
|
||||
plagiarism += 1
|
||||
logger.info(
|
||||
"[%d] %s / %s (plagiarism=%s, %d chars)",
|
||||
total, doc.doc_id, transformation, pair.is_plagiarism, len(pair.derived_text),
|
||||
)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("DONE: %d pairs (plagiarism=%d, legitimate=%d)", total, plagiarism, total - plagiarism)
|
||||
logger.info("Output: %s", out_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# 샘플 호출 스크립트. 서비스가 http://localhost:8000 에서 동작 중이어야 한다.
|
||||
set -euo pipefail
|
||||
|
||||
API_HOST="${API_HOST:-http://localhost:8000}"
|
||||
API_KEY="${API_KEY:-combooks-key-change-me}"
|
||||
|
||||
echo "--- 1) Health ---"
|
||||
curl -sS "${API_HOST}/v1/health" | python3 -m json.tool
|
||||
|
||||
echo
|
||||
echo "--- 2) 단건 탐지: 어린왕자 유사 텍스트 ---"
|
||||
curl -sS -X POST "${API_HOST}/v1/plagiarism/detect" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${API_KEY}" \
|
||||
-d '{
|
||||
"doc_id": "test-001",
|
||||
"text": "어린왕자는 작은 별에서 온 소년이다. 그는 별을 떠나 여러 행성을 여행하며 다양한 어른들을 만난다. 마침내 지구에 도착해 여우를 만나고 길들임의 의미를 배운다.",
|
||||
"metadata": {"title": "테스트 작품", "author": "익명"}
|
||||
}' | python3 -m json.tool
|
||||
|
||||
echo
|
||||
echo "--- 3) 배치 등록 ---"
|
||||
JOB_RESPONSE=$(curl -sS -X POST "${API_HOST}/v1/plagiarism/batch" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${API_KEY}" \
|
||||
-d '{
|
||||
"items": [
|
||||
{"doc_id": "b-001", "text": "앤 셜리는 상상력이 풍부한 고아 소녀로 초록 지붕 집에 입양된다."},
|
||||
{"doc_id": "b-002", "text": "홍길동은 서자로 태어나 활빈당을 조직하여 탐관오리의 재물을 빼앗는다."}
|
||||
]
|
||||
}')
|
||||
echo "${JOB_RESPONSE}" | python3 -m json.tool
|
||||
JOB_ID=$(echo "${JOB_RESPONSE}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job_id"])')
|
||||
|
||||
echo
|
||||
echo "--- 4) 배치 결과 조회 ---"
|
||||
sleep 1
|
||||
curl -sS "${API_HOST}/v1/plagiarism/batch/${JOB_ID}" \
|
||||
-H "X-API-Key: ${API_KEY}" | python3 -m json.tool
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"""커뮤니케이션북스/바이칼 측 통합 참조용 샘플.
|
||||
|
||||
표준 라이브러리만 사용하므로 별도 의존성 설치 불필요.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
API_HOST = os.environ.get("API_HOST", "http://localhost:8000")
|
||||
API_KEY = os.environ.get("API_KEY", "combooks-key-change-me")
|
||||
|
||||
|
||||
def _post(path: str, body: dict) -> dict:
|
||||
req = urllib.request.Request(
|
||||
f"{API_HOST}{path}",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _get(path: str) -> dict:
|
||||
req = urllib.request.Request(f"{API_HOST}{path}", headers={"X-API-Key": API_KEY})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def detect_one() -> None:
|
||||
result = _post(
|
||||
"/v1/plagiarism/detect",
|
||||
{
|
||||
"doc_id": "sample-py-001",
|
||||
"text": (
|
||||
"어린왕자는 작은 별 B-612에서 온 소년이다. 그는 별을 떠나 여러 행성을 여행한다. "
|
||||
"마침내 지구에 도착해 사막에서 비행기 조종사를 만나고 여우와 친구가 된다."
|
||||
),
|
||||
"metadata": {"title": "샘플 작품", "author": "tester"},
|
||||
},
|
||||
)
|
||||
print("[detect] is_infringement:", result["is_infringement"])
|
||||
print("[detect] confidence:", result["confidence"])
|
||||
for m in result["matches"]:
|
||||
print(f" - {m['source_title']} ({m['similarity']}) :: {m['infringement_type']}")
|
||||
|
||||
|
||||
def detect_batch() -> None:
|
||||
created = _post(
|
||||
"/v1/plagiarism/batch",
|
||||
{
|
||||
"items": [
|
||||
{"doc_id": "b-001", "text": "앤 셜리는 초록 지붕 집에 입양된 상상력 풍부한 소녀다."},
|
||||
{"doc_id": "b-002", "text": "홍길동은 활빈당을 만들어 부패한 관리의 재물을 빼앗는다."},
|
||||
]
|
||||
},
|
||||
)
|
||||
job_id = created["job_id"]
|
||||
print(f"[batch] job_id={job_id}, total={created['total']}")
|
||||
for _ in range(20):
|
||||
status = _get(f"/v1/plagiarism/batch/{job_id}")
|
||||
print(f"[batch] status={status['status']} processed={status['processed']}/{status['total']}")
|
||||
if status["status"] in ("completed", "failed"):
|
||||
print("[batch] results:")
|
||||
print(json.dumps(status.get("results"), ensure_ascii=False, indent=2))
|
||||
return
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== health ===")
|
||||
print(json.dumps(_get("/v1/health"), ensure_ascii=False, indent=2))
|
||||
print()
|
||||
print("=== detect (단건) ===")
|
||||
detect_one()
|
||||
print()
|
||||
print("=== batch (비동기) ===")
|
||||
detect_batch()
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
"""평가 결과 시각화 리포트 생성.
|
||||
|
||||
사내 plagia_result 데이터셋 1000쌍에 대해:
|
||||
- 점수 분포 히스토그램 (메타 임베딩 vs Lemma 교집합)
|
||||
- threshold-F1 곡선 (모델별)
|
||||
- 모델 비교 막대차트
|
||||
- Markdown 한 페이지 리포트
|
||||
|
||||
사용:
|
||||
python scripts/visualize_eval.py \
|
||||
--data-dir /Users/marineyang/Desktop/work/code/AI_publish_3rdtest/25/plagia_result
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.font_manager as fm
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.engine.structural import extract_lemmas, lemma_overlap_ratio # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logger = logging.getLogger("visualize")
|
||||
|
||||
|
||||
def _setup_korean_font() -> None:
|
||||
"""macOS 한글 표시용 폰트 설정."""
|
||||
candidates = [
|
||||
"AppleSDGothicNeo-Regular", "Apple SD Gothic Neo",
|
||||
"NanumGothic", "Nanum Gothic",
|
||||
"Malgun Gothic",
|
||||
]
|
||||
available = {f.name for f in fm.fontManager.ttflist}
|
||||
for name in candidates:
|
||||
if name in available:
|
||||
plt.rcParams["font.family"] = name
|
||||
break
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
|
||||
def _load_data(data_dir: Path):
|
||||
pos = json.load((data_dir / "plagiarism_pos_metadata.json").open(encoding="utf-8"))
|
||||
neg = json.load((data_dir / "plagiarism_neg_metadata.json").open(encoding="utf-8"))
|
||||
sims_path = sorted(data_dir.glob("all_similarities_*.json"))[-1]
|
||||
sims = json.load(sims_path.open(encoding="utf-8"))
|
||||
meta_map: dict[str, float] = {}
|
||||
for r in sims.get("pos_results", []):
|
||||
if r.get("cosine_similarity") is not None:
|
||||
meta_map[r["id"]] = float(r["cosine_similarity"])
|
||||
for r in sims.get("neg_results", []):
|
||||
if r.get("cosine_similarity") is not None:
|
||||
meta_map[r["id"]] = float(r["cosine_similarity"])
|
||||
|
||||
rows = []
|
||||
for i, item in enumerate(pos, 1):
|
||||
sid = f"POS{i:03d}"
|
||||
if sid not in meta_map:
|
||||
continue
|
||||
rows.append((sid, True, item["original_text"], item["augmented_text"], meta_map[sid]))
|
||||
for i, item in enumerate(neg, 1):
|
||||
sid = f"NEG{i:03d}"
|
||||
if sid not in meta_map:
|
||||
continue
|
||||
rows.append((sid, False, item["original_text"], item["augmented_text"], meta_map[sid]))
|
||||
return rows
|
||||
|
||||
|
||||
def _compute_lemma(rows):
|
||||
labels, meta, lemma = [], [], []
|
||||
for i, (sid, is_p, orig, aug, m) in enumerate(rows, 1):
|
||||
q_lemmas = extract_lemmas(aug)
|
||||
r_lemmas = extract_lemmas(orig)
|
||||
labels.append(1 if is_p else 0)
|
||||
meta.append(m)
|
||||
lemma.append(lemma_overlap_ratio(q_lemmas, r_lemmas))
|
||||
if i % 200 == 0:
|
||||
logger.info("lemma %d/%d", i, len(rows))
|
||||
return np.array(labels), np.array(meta), np.array(lemma)
|
||||
|
||||
|
||||
def _metrics_at(scores: np.ndarray, labels: np.ndarray, t: float) -> dict:
|
||||
pred = scores >= t
|
||||
tp = int(((pred == 1) & (labels == 1)).sum())
|
||||
fp = int(((pred == 1) & (labels == 0)).sum())
|
||||
tn = int(((pred == 0) & (labels == 0)).sum())
|
||||
fn = int(((pred == 0) & (labels == 1)).sum())
|
||||
p = tp / (tp + fp) if (tp + fp) else 0.0
|
||||
r = tp / (tp + fn) if (tp + fn) else 0.0
|
||||
f1 = 2 * p * r / (p + r) if (p + r) else 0.0
|
||||
return {"threshold": t, "precision": p, "recall": r, "f1": f1,
|
||||
"tp": tp, "fp": fp, "tn": tn, "fn": fn}
|
||||
|
||||
|
||||
def _curve(scores, labels, grid=None):
|
||||
if grid is None:
|
||||
grid = np.arange(0.05, 0.99, 0.01)
|
||||
return [_metrics_at(scores, labels, float(t)) for t in grid]
|
||||
|
||||
|
||||
def plot_distributions(labels, meta, lemma, out_path: Path):
|
||||
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
|
||||
bins = np.linspace(0, 1, 41)
|
||||
|
||||
axes[0].hist(meta[labels == 1], bins=bins, alpha=0.6, label="POS (표절)", color="#d62728")
|
||||
axes[0].hist(meta[labels == 0], bins=bins, alpha=0.6, label="NEG (비표절)", color="#1f77b4")
|
||||
axes[0].set_title("메타 임베딩 코사인 점수 분포")
|
||||
axes[0].set_xlabel("score"); axes[0].set_ylabel("count")
|
||||
axes[0].legend(); axes[0].grid(alpha=0.3)
|
||||
axes[0].axvline(0.76, color="black", linestyle="--", alpha=0.5, label="best threshold")
|
||||
|
||||
axes[1].hist(lemma[labels == 1], bins=bins, alpha=0.6, label="POS (표절)", color="#d62728")
|
||||
axes[1].hist(lemma[labels == 0], bins=bins, alpha=0.6, label="NEG (비표절)", color="#1f77b4")
|
||||
axes[1].set_title("Lemma 교집합 비율 분포 (구조 분석)")
|
||||
axes[1].set_xlabel("score"); axes[1].set_ylabel("count")
|
||||
axes[1].legend(); axes[1].grid(alpha=0.3)
|
||||
axes[1].axvline(0.59, color="black", linestyle="--", alpha=0.5, label="best threshold")
|
||||
|
||||
fig.suptitle("점수 분포 — POS(표절) vs NEG(비표절) 분리도", fontsize=14)
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_path, dpi=120, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_threshold_curves(labels, meta, lemma, out_path: Path):
|
||||
grid = np.arange(0.05, 0.99, 0.01)
|
||||
meta_curve = _curve(meta, labels, grid)
|
||||
lemma_curve = _curve(lemma, labels, grid)
|
||||
hybrid = 0.30 * meta + 0.70 * lemma
|
||||
hybrid_curve = _curve(hybrid, labels, grid)
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
|
||||
# F1 curve
|
||||
axes[0].plot(grid, [m["f1"] for m in meta_curve], label="메타 임베딩 단독", linewidth=2)
|
||||
axes[0].plot(grid, [m["f1"] for m in lemma_curve], label="Lemma 단독", linewidth=2)
|
||||
axes[0].plot(grid, [m["f1"] for m in hybrid_curve], label="하이브리드 (α=0.30)", linewidth=2.5, color="green")
|
||||
axes[0].set_title("Threshold별 F1 점수")
|
||||
axes[0].set_xlabel("threshold"); axes[0].set_ylabel("F1")
|
||||
axes[0].set_ylim(0.0, 1.0); axes[0].grid(alpha=0.3); axes[0].legend()
|
||||
|
||||
# Precision-Recall curve
|
||||
axes[1].plot([m["recall"] for m in meta_curve], [m["precision"] for m in meta_curve],
|
||||
label="메타 임베딩 단독", linewidth=2)
|
||||
axes[1].plot([m["recall"] for m in lemma_curve], [m["precision"] for m in lemma_curve],
|
||||
label="Lemma 단독", linewidth=2)
|
||||
axes[1].plot([m["recall"] for m in hybrid_curve], [m["precision"] for m in hybrid_curve],
|
||||
label="하이브리드 (α=0.30)", linewidth=2.5, color="green")
|
||||
axes[1].set_title("Precision-Recall Curve")
|
||||
axes[1].set_xlabel("recall"); axes[1].set_ylabel("precision")
|
||||
axes[1].set_xlim(0.5, 1.0); axes[1].set_ylim(0.5, 1.0)
|
||||
axes[1].grid(alpha=0.3); axes[1].legend()
|
||||
|
||||
fig.suptitle("모델 성능 곡선", fontsize=14)
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_path, dpi=120, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_model_comparison(labels, meta, lemma, out_path: Path):
|
||||
grid = np.arange(0.05, 0.99, 0.01)
|
||||
|
||||
def best(scores):
|
||||
rows = _curve(scores, labels, grid)
|
||||
return max(rows, key=lambda r: r["f1"])
|
||||
|
||||
best_meta = best(meta)
|
||||
best_lemma = best(lemma)
|
||||
best_hybrid = best(0.30 * meta + 0.70 * lemma)
|
||||
result_json = {"precision": 0.952, "recall": 0.956, "f1": 0.954}
|
||||
|
||||
models = ["기존 result.json", "메타 단독", "Lemma 단독", "하이브리드 α=0.30"]
|
||||
precisions = [result_json["precision"], best_meta["precision"], best_lemma["precision"], best_hybrid["precision"]]
|
||||
recalls = [result_json["recall"], best_meta["recall"], best_lemma["recall"], best_hybrid["recall"]]
|
||||
f1s = [result_json["f1"], best_meta["f1"], best_lemma["f1"], best_hybrid["f1"]]
|
||||
|
||||
x = np.arange(len(models))
|
||||
w = 0.27
|
||||
fig, ax = plt.subplots(figsize=(11, 5.5))
|
||||
ax.bar(x - w, precisions, w, label="Precision", color="#1f77b4")
|
||||
ax.bar(x, recalls, w, label="Recall", color="#ff7f0e")
|
||||
ax.bar(x + w, f1s, w, label="F1", color="#2ca02c")
|
||||
|
||||
for i, (p, r, f1) in enumerate(zip(precisions, recalls, f1s)):
|
||||
ax.text(i - w, p + 0.005, f"{p:.3f}", ha="center", fontsize=8)
|
||||
ax.text(i, r + 0.005, f"{r:.3f}", ha="center", fontsize=8)
|
||||
ax.text(i + w, f1 + 0.005, f"{f1:.3f}", ha="center", fontsize=8)
|
||||
|
||||
ax.set_xticks(x); ax.set_xticklabels(models)
|
||||
ax.set_ylim(0.7, 1.0); ax.set_ylabel("점수")
|
||||
ax.set_title("모델 성능 비교 (사내 1000쌍 데이터, F1 최적 threshold 기준)")
|
||||
ax.grid(alpha=0.3, axis="y"); ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_path, dpi=120, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
return best_meta, best_lemma, best_hybrid, result_json
|
||||
|
||||
|
||||
def write_markdown_report(out_path: Path, best_meta, best_lemma, best_hybrid, result_json,
|
||||
n_total, meta_stats, lemma_stats):
|
||||
md = f"""# 사내 plagia_result 데이터셋 평가 리포트
|
||||
|
||||
- **데이터셋**: 표절 페어 {n_total // 2}건 + 비표절 페어 {n_total // 2}건 (총 {n_total}쌍)
|
||||
- **엔진 버전**: o2o-plagiarism-1.2.0-hybrid-openai
|
||||
- **하이브리드 결합**: `score = α·meta_emb + (1-α)·lemma_overlap`
|
||||
|
||||
## 1. 점수 분포 (POS vs NEG 분리도)
|
||||
|
||||
| 점수 | POS 평균 | NEG 평균 | **분리도** | std(POS / NEG) |
|
||||
|---|---|---|---|---|
|
||||
| 메타 임베딩 코사인 | {meta_stats['pos_avg']:.4f} | {meta_stats['neg_avg']:.4f} | **+{meta_stats['pos_avg'] - meta_stats['neg_avg']:.4f}** | {meta_stats['pos_std']:.3f} / {meta_stats['neg_std']:.3f} |
|
||||
| **Lemma 교집합 비율** | **{lemma_stats['pos_avg']:.4f}** | **{lemma_stats['neg_avg']:.4f}** | **+{lemma_stats['pos_avg'] - lemma_stats['neg_avg']:.4f}** | {lemma_stats['pos_std']:.3f} / {lemma_stats['neg_std']:.3f} |
|
||||
|
||||
→ Lemma의 분리도가 메타보다 약 2.5배 넓음. 표절-비표절을 점수만으로 더 깨끗하게 구분 가능.
|
||||
|
||||
→ 그래프: `reports/01_score_distributions.png`
|
||||
|
||||
## 2. 모델별 최적 성능 (F1 최대화 threshold)
|
||||
|
||||
| 모델 | Precision | Recall | **F1** | Threshold |
|
||||
|---|---|---|---|---|
|
||||
| 기존 result.json (전임자 1단계 산출물) | {result_json['precision']:.4f} | {result_json['recall']:.4f} | **{result_json['f1']:.4f}** | 0.78 |
|
||||
| 메타 임베딩 단독 | {best_meta['precision']:.4f} | {best_meta['recall']:.4f} | {best_meta['f1']:.4f} | {best_meta['threshold']:.2f} |
|
||||
| **Lemma 단독** (구조 분석) | **{best_lemma['precision']:.4f}** | **{best_lemma['recall']:.4f}** | **{best_lemma['f1']:.4f}** | {best_lemma['threshold']:.2f} |
|
||||
| **하이브리드 α=0.30** (Recommended) | **{best_hybrid['precision']:.4f}** | **{best_hybrid['recall']:.4f}** | **{best_hybrid['f1']:.4f}** | {best_hybrid['threshold']:.2f} |
|
||||
|
||||
→ 그래프: `reports/02_threshold_curves.png`, `reports/03_model_comparison.png`
|
||||
|
||||
## 3. Confusion Matrix (하이브리드 α=0.30, threshold={best_hybrid['threshold']:.2f})
|
||||
|
||||
| | 예측: 표절 | 예측: 비표절 |
|
||||
|---|---|---|
|
||||
| **실제: 표절** | TP = {best_hybrid['tp']} | FN = {best_hybrid['fn']} |
|
||||
| **실제: 비표절** | FP = {best_hybrid['fp']} | TN = {best_hybrid['tn']} |
|
||||
|
||||
## 4. 결론
|
||||
|
||||
1. **전임자 가이드 검증** — "의미 스코어(메타 임베딩) + 구조 스코어(lemma 교집합) → 하이브리드" 구조가 실제 데이터로 입증됨
|
||||
2. **Lemma가 핵심 신호** — augmented 케이스가 "어미·조사만 변경" 패턴이 많아 lemma 단독으로도 F1 {best_lemma['f1']:.4f} 달성
|
||||
3. **하이브리드가 가장 안정** — 하이브리드 α=0.30에서 recall {best_hybrid['recall']:.4f} (표절을 거의 다 잡음)
|
||||
4. **권장 운영 임계치** — `SIMILARITY_THRESHOLD={best_hybrid['threshold']:.2f}`, `WEIGHT_TEXT_SIM=0.30`, `WEIGHT_LEMMA_SIM=0.45`
|
||||
"""
|
||||
out_path.write_text(md, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", required=True)
|
||||
parser.add_argument("--out-dir", default=str(ROOT / "reports"))
|
||||
args = parser.parse_args()
|
||||
|
||||
_setup_korean_font()
|
||||
data_dir = Path(args.data_dir).expanduser().resolve()
|
||||
out_dir = Path(args.out_dir).resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = _load_data(data_dir)
|
||||
logger.info("Loaded %d valid samples", len(rows))
|
||||
labels, meta, lemma = _compute_lemma(rows)
|
||||
|
||||
logger.info("Plotting distributions...")
|
||||
plot_distributions(labels, meta, lemma, out_dir / "01_score_distributions.png")
|
||||
logger.info("Plotting threshold curves...")
|
||||
plot_threshold_curves(labels, meta, lemma, out_dir / "02_threshold_curves.png")
|
||||
logger.info("Plotting model comparison...")
|
||||
best_meta, best_lemma, best_hybrid, result_json = plot_model_comparison(
|
||||
labels, meta, lemma, out_dir / "03_model_comparison.png"
|
||||
)
|
||||
|
||||
meta_stats = {
|
||||
"pos_avg": float(meta[labels == 1].mean()), "pos_std": float(meta[labels == 1].std()),
|
||||
"neg_avg": float(meta[labels == 0].mean()), "neg_std": float(meta[labels == 0].std()),
|
||||
}
|
||||
lemma_stats = {
|
||||
"pos_avg": float(lemma[labels == 1].mean()), "pos_std": float(lemma[labels == 1].std()),
|
||||
"neg_avg": float(lemma[labels == 0].mean()), "neg_std": float(lemma[labels == 0].std()),
|
||||
}
|
||||
|
||||
write_markdown_report(
|
||||
out_dir / "REPORT.md",
|
||||
best_meta, best_lemma, best_hybrid, result_json,
|
||||
len(rows), meta_stats, lemma_stats,
|
||||
)
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("리포트 생성 완료")
|
||||
print("=" * 60)
|
||||
print(f" 📊 {out_dir / '01_score_distributions.png'}")
|
||||
print(f" 📊 {out_dir / '02_threshold_curves.png'}")
|
||||
print(f" 📊 {out_dir / '03_model_comparison.png'}")
|
||||
print(f" 📄 {out_dir / 'REPORT.md'}")
|
||||
print()
|
||||
print(" 열어보기:")
|
||||
print(f" open {out_dir} # Finder")
|
||||
print(f" open {out_dir / 'REPORT.md'} # 기본 마크다운 뷰어")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""스모크 테스트. `pip install fastapi httpx pytest` 후 실행."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
API_KEY = "combooks-key-change-me"
|
||||
|
||||
|
||||
def test_health():
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/v1/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["corpus_size"] >= 0
|
||||
|
||||
|
||||
def test_detect_requires_api_key():
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={"doc_id": "x", "text": "테스트 본문"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_detect_returns_schema():
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/v1/plagiarism/detect",
|
||||
json={
|
||||
"doc_id": "t-1",
|
||||
"text": "어린왕자는 작은 별에서 온 소년이다. 그는 여우를 만나 길들임을 배운다.",
|
||||
},
|
||||
headers={"X-API-Key": API_KEY},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["doc_id"] == "t-1"
|
||||
assert "matches" in body
|
||||
assert "extracted_elements" in body
|
||||
assert "engine_version" in body
|
||||
|
||||
|
||||
def test_batch_flow():
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/v1/plagiarism/batch",
|
||||
json={
|
||||
"items": [
|
||||
{"doc_id": "b-1", "text": "앤 셜리는 초록 지붕 집에 입양된 소녀다."},
|
||||
]
|
||||
},
|
||||
headers={"X-API-Key": API_KEY},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
job_id = resp.json()["job_id"]
|
||||
status = client.get(
|
||||
f"/v1/plagiarism/batch/{job_id}",
|
||||
headers={"X-API-Key": API_KEY},
|
||||
)
|
||||
assert status.status_code == 200
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
"""삼중 유사도(text + lemma + element) 알고리즘 단위테스트."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.schemas import ExtractedElements
|
||||
from app.core.config import Settings
|
||||
from app.engine.corpus import ReferenceDoc
|
||||
from app.engine.detector import _classify
|
||||
from app.engine.extractor import RuleExtractor
|
||||
from app.engine.similarity import (
|
||||
DualSimilarityIndex,
|
||||
SimilarityHit,
|
||||
TfidfBackend,
|
||||
_element_similarities,
|
||||
_jaccard,
|
||||
)
|
||||
from app.engine.structural import extract_lemmas
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
weight_text_sim=0.35,
|
||||
weight_lemma_sim=0.30,
|
||||
weight_char_sim=0.20,
|
||||
weight_motif_sim=0.15,
|
||||
)
|
||||
|
||||
|
||||
def _build_index(docs):
|
||||
ex = RuleExtractor()
|
||||
elements = [ex.extract(d.text) for d in docs]
|
||||
lemmas = [extract_lemmas(d.text) for d in docs]
|
||||
return DualSimilarityIndex(docs, elements, lemmas, _settings(), TfidfBackend(docs)), ex
|
||||
|
||||
|
||||
def test_jaccard_basic():
|
||||
assert _jaccard([], []) == 0.0
|
||||
assert _jaccard(["a", "b"], ["a", "b"]) == 1.0
|
||||
assert _jaccard(["a", "b"], ["b", "c"]) == 1 / 3
|
||||
assert _jaccard(["A"], ["a"]) == 1.0
|
||||
|
||||
|
||||
def test_element_similarities():
|
||||
q = ExtractedElements(characters=["앤", "마릴라"], motifs=["성장"], keywords=["고아"], genre="소설")
|
||||
c = ExtractedElements(characters=["앤", "다이애나"], motifs=["성장"], keywords=["고아", "친구"], genre="소설")
|
||||
sims = _element_similarities(q, c)
|
||||
assert sims["characters"] == 1 / 3
|
||||
assert sims["motifs"] == 1.0
|
||||
assert sims["genre"] == 1.0
|
||||
|
||||
|
||||
def test_dual_index_identical_text():
|
||||
docs = [ReferenceDoc(doc_id="d1", title="원본", text="어린왕자는 작은 별에서 온 소년이다 여우와 친구가 된다")]
|
||||
idx, ex = _build_index(docs)
|
||||
q_text = docs[0].text
|
||||
hits = idx.query(q_text, ex.extract(q_text), top_k=3)
|
||||
assert hits
|
||||
assert hits[0].text_sim > 0.9
|
||||
assert hits[0].lemma_sim > 0.9
|
||||
assert hits[0].score > 0.5
|
||||
|
||||
|
||||
def test_dual_index_ending_change_caught_by_lemma():
|
||||
"""전임자 핵심: 어미만 바꾼 표절은 lemma 점수가 결정적으로 잡아냄."""
|
||||
original = "홍길동은 활빈당을 만들어 탐관오리의 재물을 빼앗아 가난한 백성에게 나누어 준다"
|
||||
plagiarized = "홍길동이 활빈당을 만들고 탐관오리의 재물을 빼앗으며 가난한 백성에게 나누어 주었다" # 어미만 변경
|
||||
|
||||
docs = [ReferenceDoc(doc_id="d1", title="홍길동전", text=original)]
|
||||
idx, ex = _build_index(docs)
|
||||
hits = idx.query(plagiarized, ex.extract(plagiarized), top_k=3)
|
||||
assert hits
|
||||
# lemma 유사도가 매우 높아야 함 — 전임자가 강조한 신호
|
||||
assert hits[0].lemma_sim >= 0.70, f"어미 변경 표절을 lemma가 못 잡음: {hits[0].lemma_sim}"
|
||||
assert hits[0].score >= 0.40
|
||||
|
||||
|
||||
def test_dual_index_character_swap_detection():
|
||||
"""인물만 치환한 표절도 lemma + 모티프로 점수 확보."""
|
||||
original = "홍길동은 서자로 태어나 활빈당을 만들어 탐관오리의 재물을 빼앗아 가난한 백성에게 나누어 준다"
|
||||
plagiarized = "김민수는 서자로 태어나 정의단을 만들어 부패한 관리의 재물을 빼앗아 가난한 백성에게 나누어 준다"
|
||||
|
||||
docs = [ReferenceDoc(doc_id="d1", title="홍길동전", text=original)]
|
||||
idx, ex = _build_index(docs)
|
||||
hits = idx.query(plagiarized, ex.extract(plagiarized), top_k=3)
|
||||
assert hits
|
||||
assert hits[0].score >= 0.30, f"인물 치환 표절을 못 잡음: {hits[0]}"
|
||||
|
||||
|
||||
def _mk_hit(text_sim=0.0, lemma_sim=0.0, char=0.0, motif=0.0, score=None):
|
||||
return SimilarityHit(
|
||||
doc_id="d1", title="t",
|
||||
score=score if score is not None else (text_sim + lemma_sim + char + motif) / 4,
|
||||
text_sim=text_sim, lemma_sim=lemma_sim,
|
||||
element_sim={"characters": char, "motifs": motif, "keywords": 0.0, "genre": 0.0},
|
||||
evidence=[],
|
||||
)
|
||||
|
||||
|
||||
def test_classify_pure_copy_by_text():
|
||||
assert _classify(_mk_hit(text_sim=0.95, lemma_sim=0.95, char=1.0, motif=1.0, score=0.95)) == "copy"
|
||||
|
||||
|
||||
def test_classify_copy_by_lemma_only():
|
||||
"""전임자 시나리오: 어미만 바꾼 복붙. text 점수는 낮을 수 있지만 lemma가 높음."""
|
||||
assert _classify(_mk_hit(text_sim=0.30, lemma_sim=0.85, char=0.5, score=0.55)) == "copy"
|
||||
|
||||
|
||||
def test_classify_plot_borrow():
|
||||
assert _classify(_mk_hit(text_sim=0.15, lemma_sim=0.45, char=0.05, motif=0.7, score=0.40)) == "plot"
|
||||
|
||||
|
||||
def test_classify_character_borrow():
|
||||
assert _classify(_mk_hit(text_sim=0.10, lemma_sim=0.20, char=0.6, motif=0.1, score=0.30)) == "character"
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"""PDF v1.2 요구사항 충족 검증.
|
||||
|
||||
- 10종 메타 태그 + 38 케이스 로딩
|
||||
- MinHash + LSH 1차 필터 동작
|
||||
- 자서전 모드 전처리 (공통 표현 제거 + NER 마스킹)
|
||||
- 다중 태그 부여 + 케이스 매핑
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.engine.autobiography_filter import (
|
||||
mask_entities,
|
||||
preprocess_for_autobiography,
|
||||
remove_common_patterns,
|
||||
)
|
||||
from app.engine.corpus import ReferenceDoc
|
||||
from app.engine.lsh_filter import LshIndex
|
||||
from app.engine.taxonomy import load_taxonomy
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ========== 분류체계 ==========
|
||||
|
||||
def test_taxonomy_loads_10_tags_and_cases():
|
||||
tax = load_taxonomy(ROOT / "data/taxonomy")
|
||||
assert tax is not None
|
||||
assert len(tax.meta_tags) == 10, f"PDF IV장: 10종 메타 태그 필요, 현재 {len(tax.meta_tags)}"
|
||||
# PDF 본문에는 "38개"라고 명시되어 있으나 그룹별 통계
|
||||
# (A27+B4+C3+D1+E2+X2)는 39건이므로 그룹별 통계를 기준으로 검증
|
||||
assert 38 <= len(tax.cases) <= 39, f"PDF IX장: 38~39 케이스, 현재 {len(tax.cases)}"
|
||||
|
||||
|
||||
def test_taxonomy_has_required_legal_tags():
|
||||
tax = load_taxonomy(ROOT / "data/taxonomy")
|
||||
ids = {t.id for t in tax.meta_tags}
|
||||
required = {
|
||||
"reproduction", "public_transmission", "distribution", "derivative_work",
|
||||
"publication", "attribution", "integrity",
|
||||
"citation_missing", "false_authorship", "substandard_derivative",
|
||||
}
|
||||
assert ids == required, f"누락된 태그: {required - ids}, 잉여 태그: {ids - required}"
|
||||
|
||||
|
||||
def test_taxonomy_case_groups():
|
||||
tax = load_taxonomy(ROOT / "data/taxonomy")
|
||||
groups = {c.case_id[0] for c in tax.cases}
|
||||
assert {"A", "B", "C", "D", "E", "X"}.issubset(groups)
|
||||
a_cases = [c for c in tax.cases if c.case_id.startswith("A")]
|
||||
assert len(a_cases) == 27, f"PDF: A그룹(저자 가해) 27건, 현재 {len(a_cases)}"
|
||||
|
||||
|
||||
def test_case_mapping_finds_a1_for_reproduction_citation():
|
||||
tax = load_taxonomy(ROOT / "data/taxonomy")
|
||||
# A1: 시·노래 가사 본문 무단 인용 — 주 태그: reproduction, citation_missing
|
||||
case = tax.find_case(["reproduction", "citation_missing"])
|
||||
assert case is not None
|
||||
assert case.case_id.startswith("A"), f"reproduction+citation은 A그룹 매칭 기대, got {case.case_id}"
|
||||
|
||||
|
||||
def test_high_risk_cases_marked():
|
||||
tax = load_taxonomy(ROOT / "data/taxonomy")
|
||||
high_risk = [c for c in tax.cases if c.high_risk]
|
||||
# PDF IX-6: 가중 위험 9건 — A10, A13, A21, A22, A23, B3, B4, C3, D1
|
||||
assert len(high_risk) >= 7 # 본문에 high_risk=true 표시한 것 기준
|
||||
|
||||
|
||||
# ========== MinHash + LSH ==========
|
||||
|
||||
def test_lsh_index_finds_identical_text():
|
||||
docs = [
|
||||
ReferenceDoc(doc_id="d1", title="홍길동전",
|
||||
text="홍길동은 서자로 태어나 활빈당을 만들어 탐관오리의 재물을 빼앗는다"),
|
||||
ReferenceDoc(doc_id="d2", title="어린왕자",
|
||||
text="어린왕자는 작은 별에서 온 소년이다 여우와 친구가 된다"),
|
||||
]
|
||||
lsh = LshIndex(docs, threshold=0.5)
|
||||
cands = lsh.query("홍길동은 서자로 태어나 활빈당을 만들어 탐관오리의 재물을 빼앗는다", top_k=5)
|
||||
assert cands
|
||||
assert cands[0].doc_id == "d1"
|
||||
assert cands[0].jaccard > 0.5
|
||||
|
||||
|
||||
def test_lsh_filters_unrelated():
|
||||
docs = [
|
||||
ReferenceDoc(doc_id="d1", title="홍길동전", text="홍길동 활빈당 탐관오리 율도국 도술"),
|
||||
]
|
||||
lsh = LshIndex(docs, threshold=0.5)
|
||||
cands = lsh.query("오늘 아침에 커피를 마시면서 신문을 읽었다", top_k=5)
|
||||
# 매칭은 있을 수 있으나 jaccard는 매우 낮아야
|
||||
if cands:
|
||||
assert cands[0].jaccard < 0.2
|
||||
|
||||
|
||||
# ========== 자서전 특화 ==========
|
||||
|
||||
def test_autobiography_common_pattern_removal():
|
||||
text = "그는 초등학교에 입학하였다 그리고 결혼식을 올렸다 군에 입대했다"
|
||||
cleaned = remove_common_patterns(
|
||||
text, str(ROOT / "data/autobiography/common_patterns.txt"),
|
||||
)
|
||||
# 공통 패턴 3개가 모두 제거되어야
|
||||
assert "초등학교에 입학하였다" not in cleaned
|
||||
assert "결혼식을 올렸다" not in cleaned
|
||||
assert "군에 입대했다" not in cleaned
|
||||
|
||||
|
||||
def test_entity_masking_replaces_proper_nouns():
|
||||
masked = mask_entities("홍길동은 서울에서 김민수를 만났다 2023년 5월 12일이었다")
|
||||
# 인명/지명이 [PERSON]으로 치환
|
||||
assert "[PERSON]" in masked or "[PLACE]" in masked
|
||||
# 원본 인명은 사라져야
|
||||
assert "홍길동" not in masked or "[PERSON]" in masked
|
||||
# 날짜 마스킹
|
||||
assert "[DATE]" in masked
|
||||
|
||||
|
||||
def test_autobiography_preprocessing_reduces_false_positive():
|
||||
"""공통 표현만 가득한 자서전 두 편은 전처리 후 거의 다른 텍스트가 되어야."""
|
||||
text_a = "나는 초등학교에 입학하였다. 결혼식을 올렸다. 군에 입대했다."
|
||||
text_b = "그녀는 초등학교에 입학하였다. 결혼식을 올렸다. 군에 입대했다."
|
||||
cleaned_a = preprocess_for_autobiography(
|
||||
text_a, str(ROOT / "data/autobiography/common_patterns.txt"), enable_mask=False,
|
||||
)
|
||||
cleaned_b = preprocess_for_autobiography(
|
||||
text_b, str(ROOT / "data/autobiography/common_patterns.txt"), enable_mask=False,
|
||||
)
|
||||
# 공통 표현 모두 제거 후엔 거의 빈 문자열
|
||||
assert len(cleaned_a) < len(text_a) * 0.5
|
||||
assert len(cleaned_b) < len(text_b) * 0.5
|
||||
|
||||
|
||||
# ========== 다중 태그 부여 ==========
|
||||
|
||||
def test_assign_tags_lemma_high_yields_reproduction():
|
||||
from app.engine.detector import PlagiarismDetector
|
||||
from app.engine.similarity import SimilarityHit
|
||||
|
||||
# detector 인스턴스 없이 _assign_tags 사용 위해 mock
|
||||
class FakeDetector(PlagiarismDetector):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
det = FakeDetector()
|
||||
hit = SimilarityHit(
|
||||
doc_id="d1", title="t", score=0.85,
|
||||
text_sim=0.4, lemma_sim=0.85,
|
||||
element_sim={"characters": 0.2, "motifs": 0.3, "keywords": 0.3, "genre": 0.0},
|
||||
evidence=[],
|
||||
)
|
||||
tags = det._assign_tags(hit, "copy")
|
||||
tag_ids = {t.tag for t in tags}
|
||||
# lemma 높음 → 복제권 + 인용 누락이 주 태그
|
||||
primary_ids = {t.tag for t in tags if t.role == "primary"}
|
||||
assert "reproduction" in primary_ids
|
||||
assert "citation_missing" in primary_ids
|
||||
|
||||
|
||||
def test_assign_tags_structural_borrow_yields_derivative_work():
|
||||
from app.engine.detector import PlagiarismDetector
|
||||
from app.engine.similarity import SimilarityHit
|
||||
|
||||
class FakeDetector(PlagiarismDetector):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
det = FakeDetector()
|
||||
# 인물 일치 높지만 표면은 낮음 = 서사 차용
|
||||
hit = SimilarityHit(
|
||||
doc_id="d1", title="t", score=0.50,
|
||||
text_sim=0.20, lemma_sim=0.40,
|
||||
element_sim={"characters": 0.6, "motifs": 0.7, "keywords": 0.3, "genre": 1.0},
|
||||
evidence=[],
|
||||
)
|
||||
tags = det._assign_tags(hit, "plot")
|
||||
primary_ids = {t.tag for t in tags if t.role == "primary"}
|
||||
assert "derivative_work" in primary_ids
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""구조 분석(lemma 교집합) 단위테스트.
|
||||
|
||||
전임자 핵심 시나리오: "원본을 복붙하고 말투/어미만 바꾼 표절" 탐지 검증.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.structural import extract_lemmas, lemma_overlap_ratio
|
||||
|
||||
|
||||
def test_extract_lemmas_includes_content_words():
|
||||
lemmas = extract_lemmas("홍길동은 활빈당을 만들어 탐관오리의 재물을 빼앗았다")
|
||||
# 내용어들이 기본형으로 추출되어야 함
|
||||
assert "홍길동" in lemmas
|
||||
assert "활빈당" in lemmas
|
||||
assert "탐관오리" in lemmas
|
||||
assert "재물" in lemmas
|
||||
assert "만들다" in lemmas
|
||||
assert "빼앗다" in lemmas
|
||||
|
||||
|
||||
def test_extract_lemmas_excludes_function_words():
|
||||
lemmas = extract_lemmas("그는 그것을 보았다")
|
||||
# 조사/어미는 빠져야 함
|
||||
assert "은" not in lemmas
|
||||
assert "을" not in lemmas
|
||||
# 동사 lemma는 들어가야 함
|
||||
assert "보다" in lemmas
|
||||
|
||||
|
||||
def test_lemma_overlap_endings_dont_matter():
|
||||
"""전임자 핵심: 어미만 바꾼 표절은 lemma 교집합으로 100% 탐지."""
|
||||
original = "홍길동은 활빈당을 만들어 탐관오리의 재물을 빼앗았다"
|
||||
plagiarized_endings = "홍길동이 활빈당을 만들고 탐관오리의 재물을 빼앗는다" # 어미만 변경
|
||||
paraphrased = "전혀 다른 사람이 자선단체를 조직해 부패한 관료의 돈을 가져갔다" # 어휘 전체 교체
|
||||
|
||||
orig_lemmas = extract_lemmas(original)
|
||||
plag_lemmas = extract_lemmas(plagiarized_endings)
|
||||
para_lemmas = extract_lemmas(paraphrased)
|
||||
|
||||
overlap_endings = lemma_overlap_ratio(plag_lemmas, orig_lemmas)
|
||||
overlap_paraphrase = lemma_overlap_ratio(para_lemmas, orig_lemmas)
|
||||
|
||||
# 어미만 바꾼 표절은 lemma 교집합 80% 이상
|
||||
assert overlap_endings >= 0.80, f"어미 변경 표절을 못 잡음: {overlap_endings}"
|
||||
# 패러프레이즈는 50% 미만이어야 정상
|
||||
assert overlap_paraphrase < 0.50, f"패러프레이즈에 점수가 너무 높음: {overlap_paraphrase}"
|
||||
# 두 점수가 명확히 구분되어야 함
|
||||
assert overlap_endings - overlap_paraphrase >= 0.40
|
||||
|
||||
|
||||
def test_lemma_overlap_character_swap_partial():
|
||||
"""인물 이름만 치환한 경우: lemma 점수가 중간."""
|
||||
original = "홍길동은 활빈당을 만들어 탐관오리의 재물을 빼앗았다"
|
||||
swapped = "김민수는 정의단을 만들어 탐관오리의 재물을 빼앗았다" # 인물 lemma 2개만 다름
|
||||
|
||||
overlap = lemma_overlap_ratio(extract_lemmas(swapped), extract_lemmas(original))
|
||||
# 인물 lemma만 빠지므로 점수는 0.5~0.8 범위
|
||||
assert 0.40 <= overlap <= 0.85, f"기대 범위 벗어남: {overlap}"
|
||||
|
||||
|
||||
def test_lemma_overlap_empty():
|
||||
assert lemma_overlap_ratio([], ["a"]) == 0.0
|
||||
assert lemma_overlap_ratio(["a"], []) == 0.0
|
||||
|
||||
|
||||
def test_lemma_overlap_query_basis():
|
||||
"""짧은 query가 긴 reference의 부분집합일 때 100% 가까이 나와야."""
|
||||
short = ["홍길동", "활빈당"]
|
||||
long = ["홍길동", "활빈당", "탐관오리", "재물", "조선", "왕"] * 10
|
||||
assert lemma_overlap_ratio(short, long) == 1.0
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue