컴북스 데이터 수령 전 가능한 작업을 선행 구현. 데이터 도착 즉시 학습·검증에 착수할 수 있도록 알고리즘·평가 하니스·문서를 준비. 알고리즘/파이프라인: - 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>
110 lines
4.3 KiB
Python
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()
|