"""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)}" # v1.3 IX장 총괄표 본문의 통계 줄은 "●16/○22 = 38"로 적혀 있으나 표를 실제로 # 세면 ●19/○20 = 39 다. 표를 기준으로 고정한다. assert len(tax.cases) == 39, f"v1.3 IX장 총괄표: 39 케이스, 현재 {len(tax.cases)}" assert sum(c.detectable_internal for c in tax.cases) == 19 assert sum(c.handling == "technical_detection" for c in tax.cases) == 19 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_returns_full_tie_group_not_one_arbitrary_pick(): """reproduction+citation_missing 은 7개 케이스가 주/보조 태그까지 동일하다. 하나만 고르면 나머지 6건이 결과에 영원히 등장하지 않으므로 동점군을 모두 낸다. """ tax = load_taxonomy(ROOT / "data/taxonomy") ids = [c.case_id for c in tax.find_cases(["reproduction", "citation_missing"])] assert set(ids) == {"A1", "A2", "A3", "A4", "A5", "A6", "A15"}, ids assert all(i.startswith("A") for i in ids) def test_every_case_carries_v13_precedent_mapping(): """39건 전부가 대표판례를 갖거나, 없는 이유를 명시한다. 빈칸은 허용하지 않는다.""" tax = load_taxonomy(ROOT / "data/taxonomy") for c in tax.cases: assert c.representative_precedents or c.representative_precedent_note, c.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