"""공부 암기노트 Phase 1 — 정규화 + 카드 가드 단위 테스트 (W-3 / G-3). card_normalize / study_memo_card_guards 는 stdlib 만 의존(DB/MLX 없음). 정량 토큰 정규화·dedup·근거(정량=evidence 원문)·누출·배치 dedup 동작(분기)을 검증. 정량 기대값은 hard gate 로 두지 않고 동작만 assert (메모리 규칙). """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "app")) from services.study import card_normalize as cn # noqa: E402 from services.study.study_memo_card_guards import ( # noqa: E402 guard_card, guard_cards, ) # ─── 정규화 (G-3) ─── def test_normalize_num_unit_space_removed(): assert cn.normalize_token("0.5 MPa") == "0.5MPa" assert cn.normalize_token("100 ℃") == "100℃" def test_normalize_thousands_separator_removed(): assert cn.normalize_token("1,000kg") == "1000kg" assert cn.normalize_token("5,000 kPa") == "5000kPa" def test_normalize_no_unit_conversion(): # 단위 환산 절대 금지 — 원문 표기 보존. assert cn.normalize_token("1000mm") == "1000mm" assert "m" in cn.normalize_token("1000mm") def test_normalize_decimal_comma_protected(): # 천단위가 아닌 소수 콤마(3자리 그룹 아님)는 보존. assert cn.normalize_token("3,14") == "3,14" def test_is_quantitative(): assert cn.is_quantitative("0.5MPa") is True assert cn.is_quantitative("0종 장소") is True # 숫자 0 포함 assert cn.is_quantitative("안전간극") is False def test_dedup_hash_stable_and_scoped(): # 공백 차이는 정규화로 동일 hash. assert cn.compute_dedup_hash(7, "cloze", "0.5 MPa") == cn.compute_dedup_hash(7, "cloze", "0.5MPa") # format 다르면 다른 hash. assert cn.compute_dedup_hash(7, "cloze", "0.5MPa") != cn.compute_dedup_hash(7, "qa", "0.5MPa") # source 다르면 다른 hash. assert cn.compute_dedup_hash(7, "qa", "x") != cn.compute_dedup_hash(8, "qa", "x") def test_leak_detection(): assert cn.is_cue_leak("정답은 0.5MPa 이다", "0.5 MPa") is True assert cn.is_cue_leak("설계압력은 얼마인가", "0.5 MPa") is False assert cn.is_cloze_self_leak("설계압력 [____] 즉 0.5 MPa 이다", "0.5MPa") is True assert cn.is_cloze_self_leak("설계압력은 [____] 이상이다", "0.5MPa") is False def test_evidence_match_normalized(): refs = [{"snippet": "최고압력 0.5 MPa 이상", "source_id": 1, "source_type": "document"}] assert len(cn.matching_evidence("0.5MPa", refs)) == 1 assert cn.matching_evidence("9.9MPa", refs) == [] # ─── 카드 가드 (W-3) ─── EVID = [{"snippet": "내압 방폭구조의 안전간극은 0.5 MPa 기준", "source_id": 1, "source_type": "document"}] EXPL = "내압 방폭구조는 안전간극을 통해 화염 온도를 낮춘다. 0종 장소는 항상 존재하는 장소다." def _g(card, evid=EVID, expl=EXPL): return guard_card(card, source_question_id=1, ai_explanation=expl, evidence_refs=evid) def test_guard_valid_qa_via_explanation(): # 비정량 fact 가 ai_explanation 에 등장 → 통과 (evidence 불필요). g = _g({"format": "qa", "cue": "내압 방폭구조의 화염온도를 낮추는 것은?", "fact": "안전간극"}) assert g is not None and g.format == "qa" and g.dedup_hash def test_guard_valid_cloze_quant_in_evidence(): # 정량 토큰이 evidence 원문에 등장 → 통과 + 매칭 evidence 기록. g = _g({ "format": "cloze", "cue": "안전간극 기준 압력", "fact": "0.5MPa", "cloze_text": "안전간극은 [____] 기준이다", }) assert g is not None and g.format == "cloze" assert len(g.matched_evidence) == 1 def test_guard_drop_quant_not_in_evidence(): # 정량 토큰이 evidence 에 없으면 drop (할루시네이션 차단). g = _g({"format": "cloze", "cue": "압력", "fact": "9.9MPa", "cloze_text": "압력은 [____]"}) assert g is None def test_guard_drop_cue_leak(): g = _g({"format": "qa", "cue": "안전간극이 정답이다", "fact": "안전간극"}) assert g is None def test_guard_drop_cloze_self_leak(): g = _g({ "format": "cloze", "cue": "압력 기준", "fact": "0.5MPa", "cloze_text": "기준은 [____] 즉 0.5 MPa 이다", }) assert g is None def test_guard_drop_invalid_format_or_empty(): assert _g({"format": "ox", "cue": "a", "fact": "안전간극"}) is None assert _g({"format": "qa", "cue": "", "fact": "안전간극"}) is None assert _g({"format": "qa", "cue": "a", "fact": ""}) is None def test_guard_drop_cloze_without_blank(): g = _g({"format": "cloze", "cue": "압력", "fact": "0.5MPa", "cloze_text": "빈칸 없는 문장"}) assert g is None def test_guard_drop_hallucinated_concept(): # 비정량이지만 explanation/evidence 어디에도 없으면 drop. g = _g({"format": "qa", "cue": "무엇?", "fact": "존재하지않는개념용어XYZ"}) assert g is None def test_guard_cards_batch_dedup(): # 같은 (qid, format, 정답) 2장 → dedup_hash 동일 → 1장만. cards = [ {"format": "qa", "cue": "화염온도를 낮추는 것은?", "fact": "안전간극"}, {"format": "qa", "cue": "내압 방폭의 핵심 원리는?", "fact": "안전간극"}, ] out = guard_cards(cards, source_question_id=1, ai_explanation=EXPL, evidence_refs=EVID) assert len(out) == 1 def test_guard_cards_all_dropped_returns_empty(): cards = [{"format": "qa", "cue": "x", "fact": "할루시네이션없는근거XYZ"}] out = guard_cards(cards, source_question_id=1, ai_explanation=EXPL, evidence_refs=EVID) assert out == [] _TESTS = [v for k, v in dict(globals()).items() if k.startswith("test_")] if __name__ == "__main__": for t in _TESTS: t() print(f"OK ({len(_TESTS)} tests)")