diff --git a/docs/TEST_PLAN_2026_PHASE2.md b/docs/TEST_PLAN_2026_PHASE2.md index 31844e5..2b9f172 100644 --- a/docs/TEST_PLAN_2026_PHASE2.md +++ b/docs/TEST_PLAN_2026_PHASE2.md @@ -335,11 +335,21 @@ trainer.train() **비표절 데이터 예시 :** -``` -"pair_id": "legit-0001", -"text": "(인덱스에 포함되지 않은 작성자의 원문 — 게재용 예시는 익명화 완료 자료로 대체)" - +```json + { + "line_number": 1, + "original_text": "할아버지는 마을에서 존경받는 인물이셨다. 면장을 역임하셨던 할아버지는 마을의 +문제를 해결하고 주민들에게 존경받았다. 그리고 우리가족은 방앗간을 운영했다. 그러나 할아버지가 +쌓아 올린 신뢰와 존경의 기반은 아버지의 사업 문제로 인해 큰 어려움을 겪게 되었다. …", + }, ... + { + "line_number": 500, + "original_text": "나의 이름의 유래는 내가 성별에 따라 이름이 달라질 뻔했다는 이야기에서 +시작된다. 아직도 전통적인 정서를 따라 이름에 '승' 자가 들어가야 했기에, 다른 사촌들과 겹치지 +않는 이름을 고민했을 것이다. 부모님께 어떻게 이름을 지었는지 물었을 때, 철학관에서 이름을 +지었다 …", + }, ``` ``` @@ -350,15 +360,21 @@ trainer.train() **표절 데이터 예시 :** -``` -"pair_id": "plag-verbatim-0067", -"transformation": "verbatim", -"original_excerpt": "저는 어렸을 때부터 연애를 여러 번 해봤습니다. 어린 시절의 연애는 큰 의미 - 없이 가볍게 시작했었죠. 하지만 고등학교에 진학하고 나서 시작한 연애는 …", -"derived_text": "저는 어렸을 때부터 연애를 여러 번 해봤습니다. 어린 시절의 연애는 큰 의미 - 없이 가볍게 시작했었죠. 하지만 고등학교에 진학하고 나서 시작한 연애는 …" - +```json + { + "line_number": 1, + "original_text": "저는 어렸을 때부터 연애를 여러 번 해봤습니다. 어린 시절의 연애는 큰 의미 +없이 가볍게 시작했었죠. 하지만 고등학교에 진학하고 나서 시작한 연애는 느낌이 달랐습니다. +고등학교에 올라와서 처음으로 제대로 된 연애를 했습니다. 고1 때부터 친구의 친구로 알고 지내던 +사이 …", + }, ... + { + "line_number": 500, + "original_text": "이제 2020년입니다. 그와 동시에 코로나가 터졌습니다. 코로나는 대학 생활에 +대한 저의 기대를 모두 박살 냈지만, 새로운 기회를 주기도 했습니다. 코로나 덕분에 시간이 많이 +생겨 온라인 상담을 다녔고 국가 근로를 신청할 수 있게 되었고 아르바이트를 하며 돈을 모을 수 …", + }, ``` ``` @@ -388,57 +404,96 @@ trainer.train() **코드** ```python -# 공통 표현 제거 — 매칭된 자리를 공백으로 치환 def remove_common_patterns(text: str, patterns_path: str) -> str: + """1단계 — 자서전 공통 표현 제거. 매칭된 자리를 공백으로 치환한다.""" result = text for p in _common_patterns(patterns_path): result = result.replace(p, " ") return re.sub(r"\s+", " ", result).strip() -# 개체명 마스킹 — 인명/지명/날짜/숫자 _DATE_PATTERN = re.compile(r"\b(19|20)\d{2}\s*년|\d{1,2}\s*월\s*\d{1,2}\s*일|\d{4}-\d{2}-\d{2}") _NUM_PATTERN = re.compile(r"\b\d{2,}\b") + def mask_entities(text: str) -> str: + """2단계 — 개체명 마스킹. 날짜/숫자를 먼저 치환한 뒤 고유명사를 마스킹한다.""" masked = _DATE_PATTERN.sub("[DATE]", text) masked = _NUM_PATTERN.sub("[NUM]", masked) - tokens = _kiwi().tokenize(masked) # 형태소 분석 + + tokens = _kiwi().tokenize(masked) # 형태소 분석 parts = [(t.start, t.start + t.len, "[PERSON]") - for t in tokens if t.tag == "NNP"] # 고유명사 + for t in tokens if t.tag == "NNP"] # 고유명사 if not parts: return masked - parts.sort(key=lambda p: p[0], reverse=True) # 뒤에서부터 치환 + + parts.sort(key=lambda p: p[0], reverse=True) # 뒤에서부터 치환 (인덱스 보존) chars = list(masked) for start, end, repl in parts: chars[start:end] = list(repl) return "".join(chars) -# 내용어 lemma 추출 — 어미·조사 변경형을 기본형으로 환원 +# 내용어 태그 — 명사/동사/형용사/부사. 조사·어미 등 기능어는 제외한다. +_CONTENT_TAGS = ("NNG", "NNP", "VV", "VA", "MAG") + + def extract_lemmas(text: str, min_length: int = 1) -> list[str]: + """3단계 — 내용어 기본형(lemma) 추출. + + 어미·조사만 바꾼 표절을 잡기 위해 기본형으로 환원한다. + 예) 갔다 / 가던 / 가버렸다 → 가다 + """ tokens = _get_kiwi().tokenize(text) lemmas = [] for t in tokens: - if not any(t.tag.startswith(p) for p in ("NNG", "NNP", "VV", "VA", "MAG")): + if not any(t.tag.startswith(p) for p in _CONTENT_TAGS): continue 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|. + + "검사 대상이 레퍼런스에서 얼마나 가져왔는가" 를 묻는 것이 표절 판정 목적이므로 + 양방향 자카드 대신 한쪽 기준 비율을 쓴다. + """ + qc, rc = Counter(query_lemmas), Counter(ref_lemmas) + intersection = sum((qc & rc).values()) + total = sum(qc.values()) + return intersection / total if total else 0.0 ``` **예시** ``` -[전처리 전] 1978년 3월, 나는 춘천초등학교에 입학했다. 어머니께서 지어주신 새 옷을 입고 교문을 들어섰다. -[전처리 후] [DATE] 3월, 나는 [PERSON] . 어머니께서 지어주신 새 옷을 입고 교문을 들어섰다. -[lemma ] ['어머니', '짓다', '옷', '입다', '교문', '들어서다'] +[원문] +1978년 3월, 나는 춘천초등학교에 입학했다. 할아버지는 마을에서 존경받는 인물이셨다. +면장을 역임하셨던 할아버지는 주민들에게 존경받았다. 우리 가족은 방앗간을 운영했다. + +[1단계 공통 표현 제거] +1978년 3월, 나는 춘천 . 할아버지는 마을에서 존경받는 인물이셨다. 면장을 역임하셨던 +할아버지는 주민들에게 존경받았다. 우리 가족은 방앗간을 운영했다. + +[2단계 개체명 마스킹] +[DATE] 3월, 나는 [PERSON] . 할아버지는 마을에서 존경받는 인물이셨다. 면장을 역임하셨던 +할아버지는 주민들에게 존경받았다. 우리 가족은 방앗간을 운영했다. + +[3단계 내용어 lemma 추출] +['할아버지', '마을', '존경', '받다', '인물', '면장', '역임', '할아버지', '주민', + '존경', '받다', '가족', '방앗간', '운영'] ``` -연월일이 `[DATE]`, 고유명사 "춘천초등학교" 가 `[PERSON]` 으로 치환되고, -자서전 빈출 표현인 "에 입학했다" 가 공통 표현 사전에 걸려 제거되었다. -남은 내용어는 어미·조사를 떼고 기본형(`짓다`, `입다`, `들어서다`)으로 환원된다. +자서전 빈출 표현인 "초등학교에 입학했다" 가 1단계에서 제거되고, 연월일이 `[DATE]`, +고유명사 "춘천" 이 `[PERSON]` 으로 치환된다. 3단계에서는 어미·조사를 떼고 기본형 +(`받다`)으로 환원하여, 말투만 바꾼 표절이 동일 lemma 로 잡히도록 한다. + +> **전처리를 두는 이유** — 자서전은 입학·결혼·군 입대 등 공통 경험 서술이 많아 +> 표면 유사도가 자연스럽게 높아진다. 전처리 없이 비교하면 서로 무관한 원고끼리도 +> 유사도가 올라 거짓 양성이 발생한다. #### 3. 표절 여부 판별