Files
hyungi 0a7402b327 feat(study): 공부 암기노트 Phase 1 — card_extract 추출 파이프라인 (순수 additive)
study_memo_cards 추출 파이프라인 + 버전키 폴러 + needs_review 컬럼. 운영 SR 코드(session_finalize/quiz_selection) 무수정.

- migrations 287~298: study_memo_cards/_evidence/_jobs/_progress(P1 휴면)·study_reminders·study_topics.focused_at·study_questions needs_review 3컬럼. dedup PARTIAL UNIQUE(deleted_at IS NULL).
- 워커: in-process RAG gather → MLX {cards} → 카드 가드(정량=evidence 원문 등장·cue/cloze 누출·dedup) → supersede 구버전 retire → append. 별 consumer 로 기존 study_queue 격리.
- 폴러 study_card_enqueue: 버전키 NOT EXISTS(source_version) 멱등 + ai_explanation_generated_at NOT NULL 가드 + per-poll LIMIT(thundering-herd).
- 검증: 실 prod 스키마 덤프 위 12 마이그 적용 OK + dedup/supersede/active-unique 기능 7/7 PASS + 정규화 util 15/15.

plan: PKM plans/2026-06-05-study-memo-card-p1-plan.html

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

106 lines
3.5 KiB
Python

"""공부 암기노트 카드별 가드 — 추출된 카드 1장 검증 파이프라인.
explanation 워커의 단일 answer_choice 환각가드를 카드 배열로 확장한다. 가드 4종:
1. 형식 유효성 — format in {qa, cloze}, cue/fact 비공백, cloze 는 cloze_text + 빈칸 마커 필요.
2. 근거(hallucination) — 정답토큰(fact)이 신뢰 텍스트에 등장해야 채택.
정량 토큰(숫자 포함): evidence 원문 snippet 에 등장 필수 (평문화된 ai_explanation 만으론 불충분).
비정량(개념): ai_explanation 또는 evidence snippet 에 등장.
3. 누출 — cue 에 정답 노출 / cloze 평문에 정답 노출 시 drop.
4. dedup — (source_question_id, format, normalize(정답토큰)) hash. 배치 내 중복 1장.
무결성은 구조로(메모리 규칙): dedup_hash PARTIAL UNIQUE(migration 288)가 DB 최종 방어선,
본 가드는 1차. 전부 drop 이면 빈 리스트 → 워커가 all_dropped 로 종결.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from services.study import card_normalize as cn
_VALID_FORMATS = {"qa", "cloze"}
@dataclass
class GuardedCard:
format: str
cue: str
fact: str
cloze_text: str | None
dedup_hash: str
matched_evidence: list[dict] = field(default_factory=list)
def guard_card(
card: dict,
*,
source_question_id: int | None,
ai_explanation: str | None,
evidence_refs: list[dict],
) -> GuardedCard | None:
"""카드 1장 검증. 통과하면 GuardedCard, 탈락하면 None."""
fmt = (card.get("format") or "").strip()
cue = (card.get("cue") or "").strip()
fact = (card.get("fact") or "").strip()
cloze_text = card.get("cloze_text")
cloze_text = cloze_text.strip() if isinstance(cloze_text, str) else None
# 1. 형식 유효성
if fmt not in _VALID_FORMATS or not cue or not fact:
return None
if fmt == "cloze":
if not cloze_text or not cn._BLANK.search(cloze_text):
return None
# 3. 누출 (정답 노출)
if cn.is_cue_leak(cue, fact):
return None
if fmt == "cloze" and cn.is_cloze_self_leak(cloze_text, fact):
return None
# 2. 근거 (hallucination 차단)
matched = cn.matching_evidence(fact, evidence_refs)
if cn.is_quantitative(fact):
# 정량 토큰은 evidence 원문 등장 필수
if not matched:
return None
else:
# 비정량은 ai_explanation 또는 evidence 에 등장
if not matched and not cn.text_contains(ai_explanation, fact):
return None
return GuardedCard(
format=fmt,
cue=cue,
fact=fact,
cloze_text=cloze_text if fmt == "cloze" else None,
dedup_hash=cn.compute_dedup_hash(source_question_id, fmt, fact),
matched_evidence=matched,
)
def guard_cards(
cards: list[dict],
*,
source_question_id: int | None,
ai_explanation: str | None,
evidence_refs: list[dict],
) -> list[GuardedCard]:
"""카드 배열 검증 + 배치 내 dedup_hash 중복 1장. 통과 카드만 반환."""
out: list[GuardedCard] = []
seen: set[str] = set()
for card in cards or []:
if not isinstance(card, dict):
continue
g = guard_card(
card,
source_question_id=source_question_id,
ai_explanation=ai_explanation,
evidence_refs=evidence_refs,
)
if g is None or g.dedup_hash in seen:
continue
seen.add(g.dedup_hash)
out.append(g)
return out