From 3b69bdf0f055ee91aa0065889f8ac08e76349bcc Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 13 May 2026 11:20:17 +0900 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20O2O=20=EC=A0=80=EC=9E=91?= =?UTF-8?q?=EA=B6=8C=20=EC=B9=A8=ED=95=B4=20=EC=97=AC=EB=B6=80=20=ED=83=90?= =?UTF-8?q?=EC=A7=80=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF v1.2 요구사항 반영 완료: - 10종 법령 메타 태그 + 39개 케이스 분류체계 - 3단 캐스케이딩: MinHash+LSH → 삼중 유사도 → 분류 - 자서전 특화: 공통 표현 사전 제거 + NER 마스킹 - KoSimCSE 한국어 임베딩 (자체 산출물 방어) - 보수적 임계값 0.85 - 검토 콘솔 UI (탐지 + 코퍼스 관리 탭) - Docker 배포 패키지 + 31개 테스트 통과 --- .dockerignore | 7 + .env.example | 35 + .gitignore | 42 + Dockerfile | 28 + README.md | 188 +++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/routes.py | 248 ++++++ app/api/schemas.py | 185 +++++ app/core/__init__.py | 0 app/core/auth.py | 13 + app/core/config.py | 65 ++ app/engine/__init__.py | 0 app/engine/autobiography_filter.py | 105 +++ app/engine/corpus.py | 114 +++ app/engine/detector.py | 281 +++++++ app/engine/extractor.py | 169 ++++ app/engine/lsh_filter.py | 82 ++ app/engine/similarity.py | 272 +++++++ app/engine/structural.py | 69 ++ app/engine/taxonomy.py | 123 +++ app/jobs/__init__.py | 0 app/jobs/store.py | 57 ++ app/main.py | 67 ++ app/static/index.html | 762 ++++++++++++++++++ data/autobiography/common_patterns.txt | 93 +++ data/reference/ref-0001__어린왕자.txt | 1 + data/reference/ref-0002__빨간머리앤.txt | 1 + data/reference/ref-0003__홍길동전.txt | 1 + data/taxonomy/cases_v1.2.json | 99 +++ data/taxonomy/meta_tags_v1.0.json | 104 +++ data/training/.gitkeep | 0 docker-compose.yml | 14 + reports/01_score_distributions.png | Bin 0 -> 41518 bytes reports/02_threshold_curves.png | Bin 0 -> 83953 bytes reports/03_model_comparison.png | Bin 0 -> 33238 bytes reports/REPORT.md | 41 + requirements.txt | 12 + scripts/evaluate_o2o_dataset.py | 237 ++++++ scripts/evaluate_pairs.py | 100 +++ scripts/generate_plagiarism_pairs.py | 189 +++++ scripts/sample_curl.sh | 40 + scripts/sample_python.py | 81 ++ scripts/visualize_eval.py | 311 +++++++ tests/test_api.py | 64 ++ tests/test_dual_similarity.py | 112 +++ tests/test_pdf_compliance.py | 178 ++++ tests/test_structural.py | 72 ++ 나누구_저작권침해_아카이빙_실무방안_v1.2.docx | Bin 0 -> 38193 bytes 나누구_저작권침해_아카이빙_실무방안_v1.2.pdf | Bin 0 -> 610988 bytes 50 files changed, 4662 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/routes.py create mode 100644 app/api/schemas.py create mode 100644 app/core/__init__.py create mode 100644 app/core/auth.py create mode 100644 app/core/config.py create mode 100644 app/engine/__init__.py create mode 100644 app/engine/autobiography_filter.py create mode 100644 app/engine/corpus.py create mode 100644 app/engine/detector.py create mode 100644 app/engine/extractor.py create mode 100644 app/engine/lsh_filter.py create mode 100644 app/engine/similarity.py create mode 100644 app/engine/structural.py create mode 100644 app/engine/taxonomy.py create mode 100644 app/jobs/__init__.py create mode 100644 app/jobs/store.py create mode 100644 app/main.py create mode 100644 app/static/index.html create mode 100644 data/autobiography/common_patterns.txt create mode 100644 data/reference/ref-0001__어린왕자.txt create mode 100644 data/reference/ref-0002__빨간머리앤.txt create mode 100644 data/reference/ref-0003__홍길동전.txt create mode 100644 data/taxonomy/cases_v1.2.json create mode 100644 data/taxonomy/meta_tags_v1.0.json create mode 100644 data/training/.gitkeep create mode 100644 docker-compose.yml create mode 100644 reports/01_score_distributions.png create mode 100644 reports/02_threshold_curves.png create mode 100644 reports/03_model_comparison.png create mode 100644 reports/REPORT.md create mode 100644 requirements.txt create mode 100644 scripts/evaluate_o2o_dataset.py create mode 100644 scripts/evaluate_pairs.py create mode 100644 scripts/generate_plagiarism_pairs.py create mode 100755 scripts/sample_curl.sh create mode 100644 scripts/sample_python.py create mode 100644 scripts/visualize_eval.py create mode 100644 tests/test_api.py create mode 100644 tests/test_dual_similarity.py create mode 100644 tests/test_pdf_compliance.py create mode 100644 tests/test_structural.py create mode 100644 나누구_저작권침해_아카이빙_실무방안_v1.2.docx create mode 100644 나누구_저작권침해_아카이빙_실무방안_v1.2.pdf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b22d846 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +.venv +.env +.git +tests +scripts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad1ee65 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..babe90f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb1f70d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..afcff2b --- /dev/null +++ b/README.md @@ -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 # 자서전 빈출 표현 사전 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..2d6fbc3 --- /dev/null +++ b/app/api/routes.py @@ -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), + ) diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 0000000..526bd87 --- /dev/null +++ b/app/api/schemas.py @@ -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 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..1a330aa --- /dev/null +++ b/app/core/auth.py @@ -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 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..446c25f --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/engine/__init__.py b/app/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/engine/autobiography_filter.py b/app/engine/autobiography_filter.py new file mode 100644 index 0000000..b272c99 --- /dev/null +++ b/app/engine/autobiography_filter.py @@ -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 diff --git a/app/engine/corpus.py b/app/engine/corpus.py new file mode 100644 index 0000000..3848ec1 --- /dev/null +++ b/app/engine/corpus.py @@ -0,0 +1,114 @@ +"""레퍼런스 코퍼스 로더 + 관리(CRUD). + +저장 형식: data/reference/__.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 diff --git a/app/engine/detector.py b/app/engine/detector.py new file mode 100644 index 0000000..3f1493b --- /dev/null +++ b/app/engine/detector.py @@ -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 diff --git a/app/engine/extractor.py b/app/engine/extractor.py new file mode 100644 index 0000000..fe92ea1 --- /dev/null +++ b/app/engine/extractor.py @@ -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() diff --git a/app/engine/lsh_filter.py b/app/engine/lsh_filter.py new file mode 100644 index 0000000..22f365b --- /dev/null +++ b/app/engine/lsh_filter.py @@ -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] diff --git a/app/engine/similarity.py b/app/engine/similarity.py new file mode 100644 index 0000000..3dd4a9f --- /dev/null +++ b/app/engine/similarity.py @@ -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 # 후방 호환 diff --git a/app/engine/structural.py b/app/engine/structural.py new file mode 100644 index 0000000..15b4c98 --- /dev/null +++ b/app/engine/structural.py @@ -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)) diff --git a/app/engine/taxonomy.py b/app/engine/taxonomy.py new file mode 100644 index 0000000..cb07dc8 --- /dev/null +++ b/app/engine/taxonomy.py @@ -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 diff --git a/app/jobs/__init__.py b/app/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/jobs/store.py b/app/jobs/store.py new file mode 100644 index 0000000..6ca6183 --- /dev/null +++ b/app/jobs/store.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1ffb010 --- /dev/null +++ b/app/main.py @@ -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"}) diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e6bfa1a --- /dev/null +++ b/app/static/index.html @@ -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 저작권 침해 여부 탐지 — 검토 콘솔 + + + +
+
+

O2O 저작권 침해 여부 탐지 — 검토 콘솔

+
KOCCA 출판환경변화 과제 · 오투오 1단계 산출물 (콘텐츠 표절 여부 AI 탐지 모듈)
+
+
+ + 엔진 확인 중… +
+
+ +
+
+

① 검사 대상 입력

+ + +
+ + + + + +
+ + + + + + + + + + + + + + + + +
+ ⚙ 고급 옵션 (임계값, 자서전 모드) +
+ + +
+ 낮을수록 더 많이 매칭 (재현율↑) · 높을수록 엄격 (정밀도↑) · PDF v1.2 권장 0.85 +
+ + +
+ + + +
+
+ ON일 때 공통 표현 사전 + NER 마스킹으로 거짓 양성 방지 +
+
+
+ +
+ + +
+
+ +
+

② 검사 결과

+
+

아직 검사하지 않았습니다

+
좌측에서 본문을 입력하고 “검사 시작”을 누르세요.
+
+ + +
+
+ +
+
+
+

새 자서전 추가

+

+ 업로드된 자서전은 검사 비교 기준이 됩니다. 컴북스 측이 직접 자서전을 등록·관리할 수 있도록 설계되었습니다. + 업로드 직후 인덱스가 자동 재빌드됩니다. +

+ + + + + + + + + + + + + + + + +
+ + +
+ +
+
+ +
+

현재 코퍼스 (0건)

+

+ 탐지 검사 시 비교 대상이 되는 자서전 목록입니다. +

+ + + + + + + + + + + + +
doc_id제목크기
로딩 중…
+
+
+
+ + + + + + diff --git a/data/autobiography/common_patterns.txt b/data/autobiography/common_patterns.txt new file mode 100644 index 0000000..daaeda0 --- /dev/null +++ b/data/autobiography/common_patterns.txt @@ -0,0 +1,93 @@ +# 자서전 빈출 공통 표현 사전 (PDF VII-4 오탐 방지) +# 이 패턴들은 자서전 어디서나 나타나 표면 유사도를 자연스럽게 높이므로 +# 정밀 비교 전에 본문에서 제거 (또는 가중치 0). +# 한 줄 1패턴, # 으로 시작하는 줄은 주석. + +# 학교·교육 단계 +초등학교에 입학하였다 +초등학교에 입학했다 +중학교에 입학하였다 +중학교에 입학했다 +고등학교에 입학하였다 +고등학교에 입학했다 +대학교에 입학하였다 +대학교에 입학했다 +학교에 다녔다 +초등학교를 다녔다 +고등학교를 졸업하였다 +고등학교를 졸업했다 +대학교를 졸업하였다 +대학교를 졸업했다 + +# 가족·출생 +태어났다 +태어났습니다 +태어났던 것이다 +남동생이 태어났다 +여동생이 태어났다 +아들이 태어났다 +딸이 태어났다 +형이 있었다 +누나가 있었다 +부모님은 농사를 지으셨다 +어머니는 살림을 하셨다 +아버지는 회사에 다니셨다 + +# 결혼·가정 +결혼을 하였다 +결혼을 했다 +결혼식을 올렸다 +신혼생활을 시작했다 +첫아이를 낳았다 + +# 군대·직장 +군대에 입대하였다 +군대에 입대했다 +군에 입대했다 +군대를 제대했다 +회사에 취직하였다 +회사에 취직했다 +직장에 들어갔다 +직장 생활을 시작했다 +퇴직을 하였다 +퇴직했다 +은퇴를 하였다 +은퇴했다 + +# 이사·거주 +이사를 갔다 +이사를 했다 +이사를 다녔다 +서울로 올라왔다 +시골에서 자랐다 + +# 시간·계절 흔한 시작 +어느 날이었다 +그때 그 시절 +그 시절에는 +지금도 생각이 난다 +지금도 잊을 수 없다 +세월이 흘렀다 +시간이 지났다 +오랜 시간이 흘렀다 + +# 감정·회고 정형 표현 +지금 생각해도 +돌이켜보면 +돌아보면 +지나고 보니 +그땐 몰랐다 +그땐 어렸다 + +# 죽음·이별 정형 +세상을 떠나셨다 +돌아가셨다 +별세하셨다 +하늘나라로 가셨다 +영면에 드셨다 + +# 친목·만남 +친구를 만났다 +오랜 친구를 만났다 +동창들을 만났다 +가족과 함께 식사를 했다 diff --git a/data/reference/ref-0001__어린왕자.txt b/data/reference/ref-0001__어린왕자.txt new file mode 100644 index 0000000..6556b7c --- /dev/null +++ b/data/reference/ref-0001__어린왕자.txt @@ -0,0 +1 @@ +어린왕자는 작은 별 B-612에서 온 호기심 많은 소년이다. 그는 자신의 별에서 장미 한 송이를 사랑하며 살았다. 어느 날 그는 별을 떠나 여러 행성을 여행하기 시작한다. 첫 번째 행성에서는 명령만 내리는 왕을 만나고, 두 번째 행성에서는 허영심에 가득 찬 사람을 만난다. 세 번째 행성에서는 술을 마시는 사람, 네 번째 행성에서는 별을 세는 사업가를 만난다. 다섯 번째 행성에서는 가로등을 켜는 사람, 여섯 번째 행성에서는 지리학자를 만난다. 마침내 일곱 번째 행성인 지구에 도착한 어린왕자는 사막에 불시착한 비행기 조종사를 만난다. 그곳에서 여우와 만나 길들임의 의미를 배우고, 자신이 사랑한 장미가 세상에서 유일한 존재임을 깨닫는다. 어린왕자는 자신의 별로 돌아가기 위해 뱀에게 도움을 청한다. 조종사는 어린왕자가 떠난 자리에서 별을 바라보며 그를 그리워한다. diff --git a/data/reference/ref-0002__빨간머리앤.txt b/data/reference/ref-0002__빨간머리앤.txt new file mode 100644 index 0000000..e80c579 --- /dev/null +++ b/data/reference/ref-0002__빨간머리앤.txt @@ -0,0 +1 @@ +앤 셜리는 상상력이 풍부하고 말이 많은 고아 소녀다. 그녀는 매슈와 마릴라 남매가 사는 초록 지붕 집으로 입양된다. 매슈와 마릴라는 원래 농장 일을 도울 남자아이를 입양하려 했지만, 착오로 앤이 오게 된다. 처음에는 앤을 돌려보내려 했지만, 그녀의 따뜻한 마음과 풍부한 상상력에 매료되어 함께 살기로 결정한다. 앤은 학교에서 다이애나 배리와 평생 친구가 되고, 길버트 블라이스와는 처음에는 다투지만 점차 라이벌이자 친구가 된다. 그녀는 자신의 빨간 머리를 싫어하지만 점차 자신을 사랑하는 법을 배운다. 앤은 공부에 매진하여 퀸스 학원에 진학하고 장학금을 받으며 성장한다. 매슈가 세상을 떠나자 앤은 마릴라와 농장을 지키기 위해 대학 진학을 포기하고 마을의 교사가 되기로 결심한다. diff --git a/data/reference/ref-0003__홍길동전.txt b/data/reference/ref-0003__홍길동전.txt new file mode 100644 index 0000000..8c8cba1 --- /dev/null +++ b/data/reference/ref-0003__홍길동전.txt @@ -0,0 +1 @@ +홍길동은 조선시대 한 재상의 서자로 태어났다. 그는 어려서부터 비범한 재주를 보였으나, 서자라는 신분 때문에 아버지를 아버지라 부르지 못하고 형을 형이라 부르지 못하는 한을 품고 자란다. 자객의 위협을 피해 집을 떠난 길동은 도적의 무리에 들어가 우두머리가 된다. 그는 무리의 이름을 활빈당이라 짓고, 부패한 관리와 탐관오리의 재물을 빼앗아 가난한 백성에게 나누어 준다. 조정에서는 길동을 잡으려 하나, 그는 도술과 기지로 번번이 빠져나간다. 결국 조정은 그를 병조판서에 임명하여 회유하지만, 길동은 조선을 떠나 율도국으로 향한다. 그곳에서 그는 왕이 되어 이상적인 나라를 세운다. diff --git a/data/taxonomy/cases_v1.2.json b/data/taxonomy/cases_v1.2.json new file mode 100644 index 0000000..923c30e --- /dev/null +++ b/data/taxonomy/cases_v1.2.json @@ -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": "명예훼손 영역, 약관·운영 절차로 처리"} + ] +} diff --git a/data/taxonomy/meta_tags_v1.0.json b/data/taxonomy/meta_tags_v1.0.json new file mode 100644 index 0000000..1968f78 --- /dev/null +++ b/data/taxonomy/meta_tags_v1.0.json @@ -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": "한국 저작권법상 상업용 음반·일부 프로그램 한정, 어문저작물 미적용" + } + ] +} diff --git a/data/training/.gitkeep b/data/training/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23d8c41 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/reports/01_score_distributions.png b/reports/01_score_distributions.png new file mode 100644 index 0000000000000000000000000000000000000000..365cfcc543b885c33bc2a64a9850d0434a8cec17 GIT binary patch literal 41518 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%0DMy%RFZSzgYD#} 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 literal 0 HcmV?d00001 diff --git a/reports/02_threshold_curves.png b/reports/02_threshold_curves.png new file mode 100644 index 0000000000000000000000000000000000000000..55f133a4469f0549ec3bf250bd30eb5014395a73 GIT binary patch literal 83953 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