"""PDF v1.2 요구사항 충족 검증. - 10종 메타 태그 + 38 케이스 로딩 - MinHash + LSH 1차 필터 동작 - 자서전 모드 전처리 (공통 표현 제거 + NER 마스킹) - 다중 태그 부여 + 케이스 매핑 """ from __future__ import annotations from pathlib import Path from app.engine.autobiography_filter import ( mask_entities, preprocess_for_autobiography, remove_common_patterns, ) from app.engine.corpus import ReferenceDoc from app.engine.lsh_filter import LshIndex from app.engine.taxonomy import load_taxonomy ROOT = Path(__file__).resolve().parent.parent # ========== 분류체계 ========== def test_taxonomy_loads_10_tags_and_cases(): tax = load_taxonomy(ROOT / "data/taxonomy") assert tax is not None assert len(tax.meta_tags) == 10, f"PDF IV장: 10종 메타 태그 필요, 현재 {len(tax.meta_tags)}" # PDF 본문에는 "38개"라고 명시되어 있으나 그룹별 통계 # (A27+B4+C3+D1+E2+X2)는 39건이므로 그룹별 통계를 기준으로 검증 assert 38 <= len(tax.cases) <= 39, f"PDF IX장: 38~39 케이스, 현재 {len(tax.cases)}" def test_taxonomy_has_required_legal_tags(): tax = load_taxonomy(ROOT / "data/taxonomy") ids = {t.id for t in tax.meta_tags} required = { "reproduction", "public_transmission", "distribution", "derivative_work", "publication", "attribution", "integrity", "citation_missing", "false_authorship", "substandard_derivative", } assert ids == required, f"누락된 태그: {required - ids}, 잉여 태그: {ids - required}" def test_taxonomy_case_groups(): tax = load_taxonomy(ROOT / "data/taxonomy") groups = {c.case_id[0] for c in tax.cases} assert {"A", "B", "C", "D", "E", "X"}.issubset(groups) a_cases = [c for c in tax.cases if c.case_id.startswith("A")] assert len(a_cases) == 27, f"PDF: A그룹(저자 가해) 27건, 현재 {len(a_cases)}" def test_case_mapping_finds_a1_for_reproduction_citation(): tax = load_taxonomy(ROOT / "data/taxonomy") # A1: 시·노래 가사 본문 무단 인용 — 주 태그: reproduction, citation_missing case = tax.find_case(["reproduction", "citation_missing"]) assert case is not None assert case.case_id.startswith("A"), f"reproduction+citation은 A그룹 매칭 기대, got {case.case_id}" def test_high_risk_cases_marked(): tax = load_taxonomy(ROOT / "data/taxonomy") high_risk = [c for c in tax.cases if c.high_risk] # PDF IX-6: 가중 위험 9건 — A10, A13, A21, A22, A23, B3, B4, C3, D1 assert len(high_risk) >= 7 # 본문에 high_risk=true 표시한 것 기준 # ========== MinHash + LSH ========== def test_lsh_index_finds_identical_text(): docs = [ ReferenceDoc(doc_id="d1", title="홍길동전", text="홍길동은 서자로 태어나 활빈당을 만들어 탐관오리의 재물을 빼앗는다"), ReferenceDoc(doc_id="d2", title="어린왕자", text="어린왕자는 작은 별에서 온 소년이다 여우와 친구가 된다"), ] lsh = LshIndex(docs, threshold=0.5) cands = lsh.query("홍길동은 서자로 태어나 활빈당을 만들어 탐관오리의 재물을 빼앗는다", top_k=5) assert cands assert cands[0].doc_id == "d1" assert cands[0].jaccard > 0.5 def test_lsh_filters_unrelated(): docs = [ ReferenceDoc(doc_id="d1", title="홍길동전", text="홍길동 활빈당 탐관오리 율도국 도술"), ] lsh = LshIndex(docs, threshold=0.5) cands = lsh.query("오늘 아침에 커피를 마시면서 신문을 읽었다", top_k=5) # 매칭은 있을 수 있으나 jaccard는 매우 낮아야 if cands: assert cands[0].jaccard < 0.2 # ========== 자서전 특화 ========== def test_autobiography_common_pattern_removal(): text = "그는 초등학교에 입학하였다 그리고 결혼식을 올렸다 군에 입대했다" cleaned = remove_common_patterns( text, str(ROOT / "data/autobiography/common_patterns.txt"), ) # 공통 패턴 3개가 모두 제거되어야 assert "초등학교에 입학하였다" not in cleaned assert "결혼식을 올렸다" not in cleaned assert "군에 입대했다" not in cleaned def test_entity_masking_replaces_proper_nouns(): masked = mask_entities("홍길동은 서울에서 김민수를 만났다 2023년 5월 12일이었다") # 인명/지명이 [PERSON]으로 치환 assert "[PERSON]" in masked or "[PLACE]" in masked # 원본 인명은 사라져야 assert "홍길동" not in masked or "[PERSON]" in masked # 날짜 마스킹 assert "[DATE]" in masked def test_autobiography_preprocessing_reduces_false_positive(): """공통 표현만 가득한 자서전 두 편은 전처리 후 거의 다른 텍스트가 되어야.""" text_a = "나는 초등학교에 입학하였다. 결혼식을 올렸다. 군에 입대했다." text_b = "그녀는 초등학교에 입학하였다. 결혼식을 올렸다. 군에 입대했다." cleaned_a = preprocess_for_autobiography( text_a, str(ROOT / "data/autobiography/common_patterns.txt"), enable_mask=False, ) cleaned_b = preprocess_for_autobiography( text_b, str(ROOT / "data/autobiography/common_patterns.txt"), enable_mask=False, ) # 공통 표현 모두 제거 후엔 거의 빈 문자열 assert len(cleaned_a) < len(text_a) * 0.5 assert len(cleaned_b) < len(text_b) * 0.5 # ========== 다중 태그 부여 ========== def test_assign_tags_lemma_high_yields_reproduction(): from app.engine.detector import PlagiarismDetector from app.engine.similarity import SimilarityHit # detector 인스턴스 없이 _assign_tags 사용 위해 mock class FakeDetector(PlagiarismDetector): def __init__(self): pass det = FakeDetector() hit = SimilarityHit( doc_id="d1", title="t", score=0.85, text_sim=0.4, lemma_sim=0.85, element_sim={"characters": 0.2, "motifs": 0.3, "keywords": 0.3, "genre": 0.0}, evidence=[], ) tags = det._assign_tags(hit, "copy") tag_ids = {t.tag for t in tags} # lemma 높음 → 복제권 + 인용 누락이 주 태그 primary_ids = {t.tag for t in tags if t.role == "primary"} assert "reproduction" in primary_ids assert "citation_missing" in primary_ids def test_assign_tags_structural_borrow_yields_derivative_work(): from app.engine.detector import PlagiarismDetector from app.engine.similarity import SimilarityHit class FakeDetector(PlagiarismDetector): def __init__(self): pass det = FakeDetector() # 인물 일치 높지만 표면은 낮음 = 서사 차용 hit = SimilarityHit( doc_id="d1", title="t", score=0.50, text_sim=0.20, lemma_sim=0.40, element_sim={"characters": 0.6, "motifs": 0.7, "keywords": 0.3, "genre": 1.0}, evidence=[], ) tags = det._assign_tags(hit, "plot") primary_ids = {t.tag for t in tags if t.role == "primary"} assert "derivative_work" in primary_ids