o2o-plagiarism-ai/tests/test_clustering.py
hbyang 0472ae1f8a 2단계 고도화 선행 작업: 군집화·요약·평가환경·데이터 스펙 (데이터 수령 전)
컴북스 데이터 수령 전 가능한 작업을 선행 구현. 데이터 도착 즉시
학습·검증에 착수할 수 있도록 알고리즘·평가 하니스·문서를 준비.

알고리즘/파이프라인:
- engine/clustering.py: 군집화 기반 부분 표절(요소 교체) 판별,
  detect 응답에 partial_signal 필드 노출 (계획서 2단계 표절검출 고도화)
- engine/summarizer.py + POST /v1/summary: TextRank 추출적 요약 +
  통합(LLM) 골격 (과제2 ② 스토리 요약/분석)
- engine/preference.py + scripts/build_preference_dataset.py:
  Human Feedback 선호학습(DPO) 데이터 파이프라인 골격

평가 하니스 (정답셋 도착 즉시 측정):
- engine/rouge.py + scripts/eval_rouge.py: 요약 ROUGE (지표 No.7)
- engine/metadata_eval.py + scripts/eval_metadata_f1.py: 메타 추출 F1
  (지표 No.3, KLUE NER 호환)
- engine/case_coverage.py + scripts/analyze_case_coverage.py:
  39 케이스 갭 분석 → 컴북스 데이터 요청 목록 (정밀도 97% 근거)

문서:
- docs/DATA_REQUEST_SPEC.md: 요청 데이터 스펙 + 요약 정답셋/HF 라벨 가이드
- docs/INTEGRATION_INTERFACE.md: 2단계 통합 인터페이스
- docs/SW_COPYRIGHT_REGISTRATION.md: SW 저작권 등록 준비
- docs/PHASE2_PROGRESS.md, docs/CASE_COVERAGE.md

테스트 31 → 67건 전부 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:16:07 +09:00

110 lines
4.3 KiB
Python

"""군집화 기반 표절 판별 단위테스트 (계획서 2단계 고도화).
핵심 시나리오: "본문(lemma)·키워드는 그대로 두고 인물만 바꾼 표절" 을
element_swap_plagiarism 으로 분해해서 잡는다.
"""
from __future__ import annotations
from app.api.schemas import ExtractedElements
from app.engine.clustering import (
ClusterIndex,
signature_similarity,
_element_signature,
)
def _elem(characters=None, motifs=None, keywords=None, genre=None) -> ExtractedElements:
return ExtractedElements(
characters=characters or [],
motifs=motifs or [],
keywords=keywords or [],
genre=genre,
)
def test_signature_similarity_identical():
a = _element_signature(_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리"], genre="역사"))
assert signature_similarity(a, a) == 1.0
def test_clusters_group_similar_docs():
# 두 홍길동 변형은 한 군집, 어린왕자는 별도 군집
ids = ["hong-a", "hong-b", "prince"]
elems = [
_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"], genre="역사"),
_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"], genre="역사"),
_elem(characters=["어린왕자", "여우"], keywords=["사막", "장미", "별"], genre="동화"),
]
idx = ClusterIndex(ids, elems, link_threshold=0.35)
assert idx.cluster_of("hong-a") == idx.cluster_of("hong-b")
assert idx.cluster_of("prince") != idx.cluster_of("hong-a")
def test_route_to_nearest_cluster():
ids = ["hong", "prince"]
elems = [
_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"], genre="역사"),
_elem(characters=["어린왕자"], keywords=["사막", "장미", "별"], genre="동화"),
]
idx = ClusterIndex(ids, elems, link_threshold=0.9) # 분리 유지
q = _elem(characters=["홍길동"], keywords=["활빈당", "재물"], genre="역사")
routed = idx.route(q)
assert routed is not None
assert "hong" in routed.members
def test_element_swap_plagiarism_detected():
"""본문 lemma·키워드 유지 + 인물만 교체 → element_swap_plagiarism."""
ids = ["original"]
elems = [_elem(characters=["홍길동"], motifs=["복수"], keywords=["활빈당", "탐관오리", "재물", "의적"])]
lemmas = [["만들다", "빼앗다", "재물", "탐관오리", "활빈당", "나누다"]]
idx = ClusterIndex(ids, elems, doc_lemmas=lemmas, link_threshold=0.35)
# 인물(홍길동→김민수)·모티프만 바꾸고 본문 lemma/키워드는 그대로
q_elem = _elem(characters=["김민수"], motifs=["정의"], keywords=["활빈당", "탐관오리", "재물", "의적"])
q_lemmas = ["만들다", "빼앗다", "재물", "탐관오리", "활빈당", "나누다"]
sig = idx.partial_signal("original", q_elem, q_lemmas)
assert sig is not None
assert sig.verdict == "element_swap_plagiarism", sig.per_element
assert "characters" in sig.changed_elements
assert "lemmas" in sig.retained_elements
def test_near_duplicate_verdict():
ids = ["original"]
elems = [_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"])]
lemmas = [["만들다", "빼앗다", "재물"]]
idx = ClusterIndex(ids, elems, doc_lemmas=lemmas)
sig = idx.partial_signal(
"original",
_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"]),
["만들다", "빼앗다", "재물"],
)
assert sig is not None
assert sig.verdict == "near_duplicate"
def test_unrelated_gets_none_or_weak():
ids = ["original"]
elems = [_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리"])]
lemmas = [["만들다", "빼앗다"]]
idx = ClusterIndex(ids, elems, doc_lemmas=lemmas)
sig = idx.partial_signal(
"original",
_elem(characters=["우주비행사"], keywords=["로켓", "행성"]),
["날다", "탐사하다"],
)
assert sig is not None
assert sig.verdict in ("none", "weak")
assert sig.verdict != "element_swap_plagiarism"
def test_empty_index():
idx = ClusterIndex([], [])
assert idx.num_clusters == 0
assert idx.route(_elem(characters=["x"])) is None
assert idx.candidate_ids(_elem(characters=["x"])) == set()