"""구조 분석 - 형태소 분석 + 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))