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>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
-- 287_study_memo_cards.sql
|
||||
-- 공부 암기노트 Phase 1: 추출 플래시카드 본체 (FK 트리 루트).
|
||||
--
|
||||
-- 출처(source_kind): question (P1 활성) / subject_note / document (P3 예약).
|
||||
-- 포맷(format): qa (cue->fact) / cloze (빈칸). 강한 enum 미사용 — read-time 매핑.
|
||||
-- needs_review DEFAULT true: 생성물이라 추출 직후 검토 대기 (study_questions 의 false 와 반대).
|
||||
-- source_generated_at: 추출 당시 study_questions.ai_explanation_generated_at — stale 판정/버전 핀.
|
||||
-- source_question_id 만 nullable + ON DELETE CASCADE (문제 물리삭제 시 카드 동반삭제;
|
||||
-- 단 study_questions 는 soft-delete 만이라 실전은 정정/삭제 훅이 needs_review 마킹).
|
||||
-- 인용(evidence) 은 별 테이블 study_memo_card_evidence (append-only).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_memo_cards (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_topic_id BIGINT NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE,
|
||||
|
||||
source_kind VARCHAR(40) NOT NULL,
|
||||
source_question_id BIGINT REFERENCES study_questions(id) ON DELETE CASCADE,
|
||||
source_subject_note_id BIGINT,
|
||||
|
||||
format VARCHAR(20) NOT NULL,
|
||||
cue TEXT NOT NULL,
|
||||
fact TEXT NOT NULL,
|
||||
cloze_text TEXT,
|
||||
extra JSONB,
|
||||
|
||||
source_generated_at TIMESTAMPTZ,
|
||||
dedup_hash VARCHAR(64) NOT NULL,
|
||||
|
||||
needs_review BOOLEAN NOT NULL DEFAULT true,
|
||||
flagged_at TIMESTAMPTZ,
|
||||
flagged_by VARCHAR(40),
|
||||
|
||||
model VARCHAR(120),
|
||||
generated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 288_study_memo_cards_dedup_uq.sql
|
||||
-- dedup_hash 중복 카드 차단의 최종 방어선 (구조로 강제).
|
||||
-- append_card 의 ON CONFLICT (dedup_hash) DO NOTHING 이 매칭할 UNIQUE 제약 — 필수.
|
||||
-- PARTIAL (WHERE deleted_at IS NULL): supersede 로 retire 된 구버전 카드가
|
||||
-- 같은 dedup_hash 의 새 추출을 막지 않도록 살아있는 카드만 유일성 강제.
|
||||
-- dedup_hash = sha256(source_question_id | format | normalize(정답토큰)).
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_study_memo_cards_dedup
|
||||
ON study_memo_cards (dedup_hash)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 289_study_memo_cards_source_q_idx.sql
|
||||
-- 정정/삭제 훅의 일괄 UPDATE (WHERE source_question_id=...) 와
|
||||
-- 워커 supersede (구버전 카드 retire) 조회 가속.
|
||||
-- PARTIAL (WHERE deleted_at IS NULL): 살아있는 카드만 색인.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_memo_cards_source_q
|
||||
ON study_memo_cards (source_question_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 290_study_memo_card_evidence.sql
|
||||
-- 카드별 인용(citation) append-only 원장.
|
||||
-- card-context 가 모은 evidence_refs (source_type document|question, source_id, snippet)
|
||||
-- 를 카드 추출 워커가 그대로 적재. UPDATE/DELETE 없음 — updated_at/deleted_at 미포함.
|
||||
-- card_id ON DELETE CASCADE: 카드 삭제 시 인용 동반삭제.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_memo_card_evidence (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
card_id BIGINT NOT NULL REFERENCES study_memo_cards(id) ON DELETE CASCADE,
|
||||
source_type VARCHAR(40) NOT NULL,
|
||||
source_id BIGINT,
|
||||
chunk_index INTEGER,
|
||||
snippet TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 291_study_memo_card_jobs.sql
|
||||
-- card_extract 비동기 작업 큐 (231_study_question_jobs.sql 복제 + 다형 소스).
|
||||
-- 라이프사이클: pending -> processing -> completed | failed | skipped
|
||||
-- error_code 권장값: parse_fail / llm_timeout / unknown (재시도 대상),
|
||||
-- all_dropped (0장 생성, completed 로 종결해 재추출 차단),
|
||||
-- no_ready_explanation (skipped, 비재시도).
|
||||
-- source_question_id 직접 FK 대신 source_kind/source_id 다형 참조 (question|subject_note|document).
|
||||
-- source_version = 추출 대상 study_questions.ai_explanation_generated_at (버전 멱등키) —
|
||||
-- 폴러의 NOT EXISTS(... AND source_version=현재버전) 가 같은 버전 재추출을 차단.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_memo_card_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
source_kind VARCHAR(40) NOT NULL,
|
||||
source_id BIGINT NOT NULL,
|
||||
source_version TIMESTAMPTZ,
|
||||
|
||||
kind VARCHAR(40) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
max_attempts SMALLINT NOT NULL DEFAULT 2,
|
||||
error_code VARCHAR(40),
|
||||
error_message TEXT,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 292_study_memo_card_jobs_active_uq.sql
|
||||
-- (source_kind, source_id) 활성 행 중복 차단 (232 패턴).
|
||||
-- terminal status (completed/failed/skipped) 는 누적 이력이라 unique 대상 X.
|
||||
-- 동시 active 1행만 보장; 버전 멱등(같은 source_version 재추출 차단)은 폴러 NOT EXISTS 책임.
|
||||
-- 키에 source_version 을 넣지 않음 — 같은 (kind,id) 의 동시 active 추출은 1건이어야 함.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_study_memo_card_jobs_active
|
||||
ON study_memo_card_jobs (source_kind, source_id)
|
||||
WHERE status IN ('pending', 'processing');
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 293_study_memo_card_jobs_lookup_idx.sql
|
||||
-- 폴러(study_card_enqueue)의 버전 멱등 NOT EXISTS 조회 가속:
|
||||
-- NOT EXISTS (SELECT 1 FROM study_memo_card_jobs
|
||||
-- WHERE source_kind='question' AND source_id=sq.id
|
||||
-- AND source_version=sq.ai_explanation_generated_at)
|
||||
-- terminal 행까지 전부 봐야 하므로 partial 아님(active uq 와 별개).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_memo_card_jobs_lookup
|
||||
ON study_memo_card_jobs (source_kind, source_id, source_version);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 294_study_memo_card_progress.sql
|
||||
-- 카드 SR(간격반복) 미러 — P1 휴면.
|
||||
-- P1 에서는 writer 가 없어 빈 테이블만 생성한다 (SR 산술/sr_schedule 공용추출은 P3).
|
||||
-- 미리 만드는 이유: P3 에서 ALTER 없이 데이터 채우기만 하도록 스키마 선확보.
|
||||
-- 226_study_question_progress.sql 골격을 카드용으로 미러 (question -> card).
|
||||
-- 간격 상수 정본(P3 적용): REVIEW_INTERVAL_DAYS={1:3,2:7,3:14}, MASTERED=4, FIRST_DUE=1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_memo_card_progress (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_topic_id BIGINT NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE,
|
||||
card_id BIGINT NOT NULL REFERENCES study_memo_cards(id) ON DELETE CASCADE,
|
||||
|
||||
last_outcome VARCHAR(20),
|
||||
last_reviewed_at TIMESTAMPTZ,
|
||||
due_at TIMESTAMPTZ,
|
||||
review_stage SMALLINT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT uq_card_progress_user_card UNIQUE (user_id, card_id)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 295_study_topics_focused_at.sql
|
||||
-- 공부중 태그. focused_at IS NOT NULL = 포커스 중 (알람/세션-prep 대상).
|
||||
-- PATCH 토글로 set/clear. 폴러 초기 스코프 + reminder 스코프 술어가 참조.
|
||||
-- DEFAULT 없음 (NULL = 비포커스) — DEFAULT now() 면 기존 전 토픽이 포커스로 오인됨.
|
||||
|
||||
ALTER TABLE study_topics
|
||||
ADD COLUMN IF NOT EXISTS focused_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 296_study_questions_needs_review_cols.sql
|
||||
-- 검토 대기 플래그 3컬럼 (정정/삭제 훅 + needs_review 큐가 set/clear).
|
||||
-- needs_review DEFAULT false: 기존 문제는 기본 정상 (study_memo_cards 의 true 와 반대).
|
||||
-- flagged_by 값은 서버측 상수만 적재: 'user' / 'source_changed' / 'source_deleted'
|
||||
-- (raw 사용자 입력 금지). 강한 enum 미사용 — read-time 매핑.
|
||||
-- NOT NULL DEFAULT false 는 PG11+ 메타데이터 fast-path (즉시) — 빈 시간대 배포 권장.
|
||||
|
||||
ALTER TABLE study_questions
|
||||
ADD COLUMN IF NOT EXISTS needs_review BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS flagged_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS flagged_by VARCHAR(40);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 297_study_questions_needs_review_idx.sql
|
||||
-- needs_review 큐 뷰(GET /study-questions?needs_review=true) + count 용 부분 인덱스.
|
||||
-- WHERE 술어(deleted_at IS NULL AND needs_review)는 큐 뷰 쿼리 WHERE 와 글자 단위로
|
||||
-- 일치해야 partial index 가 선택된다 (HR-5 쿼리와 정합 필수).
|
||||
-- soft-delete 행 제외(deleted_at IS NULL 합류).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_questions_needs_review
|
||||
ON study_questions (study_topic_id)
|
||||
WHERE deleted_at IS NULL AND needs_review;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 298_study_reminders.sql
|
||||
-- 알람 재료 append-only 원장. study_reminder cron(09/13/19 KST)이 발화 시 1행 INSERT,
|
||||
-- GET /reminders/latest 가 읽는다. UPDATE/DELETE 없음.
|
||||
-- fired_at 은 발화 시각의 '시간 슬롯' 으로 truncate 해서 박는다 (raw now() 마이크로초면
|
||||
-- UNIQUE 가 사실상 안 걸려 멱등 무효). UNIQUE(user_id, fired_at) + on_conflict_do_nothing.
|
||||
-- study_topic_id ON DELETE SET NULL: 토픽 삭제돼도 과거 알람 이력 보존(CASCADE 아님).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_reminders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_topic_id BIGINT REFERENCES study_topics(id) ON DELETE SET NULL,
|
||||
due_count INTEGER,
|
||||
focus_topic_names JSONB,
|
||||
fired_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT uq_study_reminders_user_fired UNIQUE (user_id, fired_at)
|
||||
);
|
||||
Reference in New Issue
Block a user