"""공부 암기노트 카드 — 정량 토큰 정규화 + dedup 키 + 누출/근거 1차 primitives. 정규화 정책(보수적 = restrictive): - NFC 유니코드 정규화 - 수치와 단위 사이 공백 제거 ('0.5 MPa' -> '0.5MPa') - 천단위 구분자(콤마) 제거 ('1,000kg' -> '1000kg'), 숫자 3자리 그룹 한정 - 단위 환산 절대 금지 (원문 표기 보존 — LLM 오변환을 정규화로 흡수하지 않음) 대소문자는 보존한다 (MPa vs mpa 는 다른 단위라 lowercase 안 함). dedup_hash = sha256(source_question_id | format | normalize_token(정답토큰)). """ from __future__ import annotations import hashlib import re import unicodedata # 수치 다음의 공백 + (단위로 시작하는) 토큰 사이 공백 제거. _NUM_UNIT_SPACE = re.compile(r"(\d)\s+(?=[A-Za-z℃°%‰Ωµμ/])") # 천단위 콤마: 숫자 뒤 콤마 + 정확히 3자리 숫자 그룹이 이어질 때만 (소수점/일반 콤마 보호). _THOUSANDS = re.compile(r"(?<=\d),(?=\d{3}(?:\D|$))") _WS = re.compile(r"\s+") # cloze 빈칸 마커: [____] / [___] / {{...}} / ____ 등. _BLANK = re.compile(r"\[_+\]|\{\{[^}]*\}\}|_{2,}") _DIGIT = re.compile(r"\d") def normalize_token(s: str | None) -> str: """단일 정답 토큰 정규화 (대소문자 보존). dedup 키·근거 매칭의 단위.""" if not s: return "" s = unicodedata.normalize("NFC", s) s = _NUM_UNIT_SPACE.sub(r"\1", s) s = _THOUSANDS.sub("", s) return s.strip() def normalize_for_match(s: str | None) -> str: """근거 텍스트/문장 비교용 — 토큰 정규화 + 공백 축약 (대소문자 보존).""" if not s: return "" s = normalize_token(s) return _WS.sub(" ", s).strip() def compute_dedup_hash(source_question_id: int | None, fmt: str, answer_token: str | None) -> str: """정본 키: sha256(source_question_id | format | normalize_token(정답토큰)).""" key = f"{source_question_id}|{fmt}|{normalize_token(answer_token)}" return hashlib.sha256(key.encode("utf-8")).hexdigest() def is_quantitative(token: str | None) -> bool: """숫자를 포함하면 정량 토큰 (정량 cloze 는 evidence 원문 등장 필수).""" return bool(_DIGIT.search(normalize_token(token))) def text_contains(haystack: str | None, needle: str | None) -> bool: """needle(정답토큰)이 haystack 안에 정규화 후 부분문자열로 등장하면 True.""" n = normalize_for_match(needle) if not n: return False return n in normalize_for_match(haystack) def is_cue_leak(cue: str | None, answer_token: str | None) -> bool: """cue(앞면)에 정답토큰이 노출되면 True (drop 대상).""" return text_contains(cue, answer_token) def is_cloze_self_leak(cloze_text: str | None, answer_token: str | None) -> bool: """cloze_text 의 빈칸 마커를 제거한 평문에 정답토큰이 노출되면 True (drop 대상).""" if not cloze_text: return False stripped = _BLANK.sub(" ", cloze_text) return text_contains(stripped, answer_token) def matching_evidence(answer_token: str | None, evidence_refs: list[dict]) -> list[dict]: """정답토큰이 snippet 에 등장하는 evidence_refs 만 반환 (citation 적재용).""" out: list[dict] = [] for ref in evidence_refs or []: if text_contains(ref.get("snippet"), answer_token): out.append(ref) return out