Files
hyungi_document_server/tests/test_study_memo_card_guards.py
T
hyungi 19f544fb5e feat(study): 공부 암기노트 Phase 1 — 정정/삭제 훅 + needs_review 큐 + 알람 재료 (HR/A)
추출 파이프라인(287~298, 별 커밋) 위 HR/A. 신규 마이그레이션 0 (DDL은 295~298 재사용).

- HR 정정/삭제 훅: PATCH 본문 수정 → 파생 study_memo_cards needs_review=auto(source_changed),
  soft-DELETE → source_deleted. flag_cards_for_source 헬퍼(임시 플래그, 최종정리는 워커 supersede).
- HR needs_review: PATCH set/clear(flagged_by='user' 서버강제) + GET /study-questions/needs-review
  목록·count(부분인덱스 술어 일치, 동적 {id} 라우트보다 먼저 등록해 int 파싱 충돌 회피).
- A 알람 재료: study_topics.focused_at 공부중 토글 + study_reminder cron(09/13/19 KST, due 술어
  quiz_selection SQL 재현·시간슬롯 truncate 멱등·LLM 0) + GET /api/study-reminders/latest(없으면 204).
- 테스트: 가드/정규화 18/18 (정량=evidence 원문·cue/cloze 누출·dedup·배치).

검증: 앱 부팅 import+mapper OK · 가드 18/18 PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:08:55 +09:00

162 lines
5.8 KiB
Python

"""공부 암기노트 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)")