19f544fb5e
추출 파이프라인(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>
162 lines
5.8 KiB
Python
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)")
|