document_chunks.country 가 7일 분포 기준 99.9% NULL 이었던 root cause = news_collector 가
summarize + embed 만 enqueue 하고 chunk 를 enqueue 하지 않아 chunk_worker 가 news 문서에 한 번도 안 돌고 있었음.
queue_consumer.next_stages 의 summarize 키 부재가 follow-up 미연결 원인.
news 외 summarize 흐름 부수영향 회피를 위해 next_stages 가 아니라 news_collector RSS/API 양쪽에 chunk
enqueue 1줄씩 명시 추가. days_old <= 30 가드 안에서 embed 와 동일 정책.
scripts/news_chunk_country_backfill.py — doc 단위 small batch, 실패 doc skip,
50건마다 progress. queue 우회 직접 chunk_worker.process 호출로 timing 통제.
Gate (PR closure):
A) chunked_doc_pct > 95% 최근 7일 news doc 중 chunk 보유 비율
B) country null_pct < 5% 최근 7일 news chunk country NULL 비율
plan: ~/.claude/plans/7-whimsical-crab.md (PR-News-Prep-Layer-1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
watch_inbox() 가 new_count/changed_count 둘 다 0 일 때 silent — PR-NAS-Watch-Folder 검증 시 fire 추적 부재 확인 후 보완. else 분기 추가해 매 5min fire 마다 "변경 없음 (idle)" info 로그 한 줄.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 요청 (2026-05-13):
- 오늘 briefing 만 보여주고 과거 못 보는 게 아쉬움 → 날짜 선택 UI
- 시간대 별 나열은 오히려 불편 → date dropdown 1단계 선택
- 각 카드에 읽음/하이라이트 토글
Schema (migrations 263~266, 단일 statement):
- briefing_topics.is_read BOOL NOT NULL DEFAULT false
- briefing_topics.read_at TIMESTAMPTZ
- briefing_topics.highlighted BOOL NOT NULL DEFAULT false
- briefing_topics.highlighted_at TIMESTAMPTZ
API (app/api/briefing.py):
- TopicResponse 에 id / is_read / read_at / highlighted / highlighted_at 추가
- GET /api/briefing/dates → 사용 가능 날짜 목록 (60일 cap)
· briefing_date / total_topics / total_articles / status / read_count / highlighted_count
- PATCH /api/briefing/topics/{id}/read body {value: bool} → 읽음 토글
- PATCH /api/briefing/topics/{id}/highlight body {value: bool} → 하이라이트 토글
- 토글 시 *_at 컬럼 자동 설정/NULL
UI (frontend/src/routes/news/+page.svelte):
- 헤더 우측 <select> date dropdown — 최신 + N일치 (highlighted_count 별 표시)
- 선택 시 /api/briefing?date=… 로 해당 날짜 briefing 로드
- 카드 우측 상단 ★ (하이라이트) + 읽음 버튼
- 하이라이트 = Card class ring-2 ring-yellow-400
- 읽음 = 외부 div class opacity-60 (시각 차분화, 펴기 가능)
- 토글 즉시 PATCH 호출 + 로컬 state 갱신
each key topic.topic_rank → topic.id 변경 (이미 unique).
배포 후 관측 결과 (2026-05-13 새벽):
- 126 docs / 7 countries 인데 THRESHOLD=0.78 로 raw_clusters=124, dropped_min_articles=122, kept=1.
- 거의 매 article 이 별 cluster 로 갈려 토픽 묶음 실패.
- 같은 cron 어제 (5/12) 는 101 docs 에서 6 topics 성공 — 그날 뉴스가 우연히 같은 토픽으로 더 모인 case.
수동 측정 (5/13 동일 docs):
- 0.78 → kept=1
- 0.70 → kept=5 (allowed)
영구 변경 = THRESHOLD=0.70. cross-country 필터 (MIN_COUNTRIES≥2) + min_articles(≥2) 그대로
유지하므로 noise topic 위험은 제한적.
원본 주석 (0.75~0.80 중간값) 도 갱신.
매일 20:00 KST cron fire 시 fail:
UndefinedFunctionError: operator does not exist: date = character varying
원인: today 가 strftime("%Y-%m-%d") 로 string, func.date(created_at) 가 date 타입.
PostgreSQL 가 date = string 비교 거부.
Fix: today = datetime.now(ZoneInfo("Asia/Seoul")).date() — date 객체로.
KST 기준은 scheduler cron 이 KST 20:00 에 fire 되므로 자연 일치.
scope: app/workers/daily_digest.py:24
매일 KST 05:10 morning_briefing_run 자동 실행. scheduler timezone=Asia/Seoul
이라 hour=5 minute=10 만 명시. Phase 4 04:00 cron 종료 후 70분 buffer + MLX
semaphore 충돌 회피.
LLM 이 article_ids 를 자율적으로 비워두는 케이스 (2026-05-12 첫 briefing 6
topics 모두 빈 list) 를 서버에서 보정.
후처리 정책 (_resolve_article_ids):
1. LLM 이 준 id ∩ cluster member id (엉뚱한 id 차단, hallucination 방어)
2. 비어있으면 같은 country cluster member top weight N 개 자동 주입
3. cluster 안 country 매칭 멤버 0 → []
per-country cap = MAX_ARTICLE_IDS_PER_COUNTRY = 5. weight 내림차순.
API 계약 강화: country_perspectives 가 있는 topic 은 article_ids ≥ 1 보장
(같은 country cluster member 존재 시). frontend / 외부 채널 / archive UI
모두 신뢰 가능.
tests 3 케이스 추가.
Phase 4 Global Digest 의 클러스터링 핵심 알고리즘 (time-decay weight,
adaptive threshold, greedy cosine assign + EMA centroid, importance
normalize) 을 `app/services/clustering_common.py` 로 추출. country
축은 caller 책임 — Phase 4 cluster_country 는 그대로 country 별 호출,
신규 morning briefing 모듈이 country 없이 cluster_global 로 호출 예정.
selection.py 의 중복 _normalize 도 공통 util 로 통일.
동작 변경 0:
- LAMBDA / threshold / EMA alpha / MIN_ARTICLES 모두 Phase 4 기본값 유지
- docs.sort (in-place) → sorted (copy) 변경했으나 caller 가 정렬된
docs 를 재사용하지 않으므로 무관 (dict element 의 weight 부여는
reference 라 그대로 반영)
다음 commit 에서 Phase 4 회귀 검증 (digest regenerate diff 0).
GPU 서버 main pull 후 /api/memos/?archived=false 가 500 — doc_type enum 에
'audio' 값 없음 (immutable/editable/note 만). list_memos WHERE file_type IN
('note', 'audio') 가 invalid_text_representation.
수정:
- voice upload Document.file_type = 'audio' → 'immutable' (기존 audio 컨테이너
인입과 같은 패턴: file_type='immutable' + category='audio' + source_channel='voice')
- list_memos 필터에서 file_type 조건 제거 (source_channel IN ('memo','voice') 만으로
분리 — file_type='immutable' 필터는 일반 PDF 까지 끌어옴, 위험)
- module docstring + voice upload 주석 업데이트
원본 plan 의 file_type='audio' 결정은 doc_type enum 미확인이 원인.
enum 확장(ALTER TYPE ADD VALUE 'audio') 대신 기존 패턴 재사용 — 안전 + 회귀 X.
PR-2B/2C backend 2/2. plan v9 commit 분할 2~3 통합 (memos.py 단일 파일 변경).
PR-2B promote-to-event:
- POST /api/memos/{memo_id}/promote-to-event — 메모 → events 1-click 승급
· kind 결정: body.kind > documents.ai_event_kind > 400
· activity_log 면 status=done + ended_at=now() 자동 (5초 행동 기록 UX)
· calendar_event + start_at 있으면 status=scheduled
· Event row + events_history(create) 자동 생성
· memo_document_id 자동 link + source='memo' + raw_metadata 에 AI 추천값 보존
· 한 메모 → N events 가능 (사용자 의도에 따라 dedup 없음)
- POST /api/memos/{memo_id}/dismiss-event-suggestion — '그냥 메모' (ai_event_kind='note' 강제)
· MVP: AI 추천값과 사용자 확정값 같은 컬럼 (정확도 측정 흐려질 수 있음)
· 백로그: user_event_kind 별 컬럼 분리 (plan Memo Intake Upgrade 백로그)
- MemoResponse 확장: ai_event_kind / ai_event_confidence / source_channel / file_type / file_path
- list_memos 필터 완화: file_type IN (note, audio) + source_channel IN (memo, voice)
→ voice 메모도 같은 inbox list 에 표시 (사용자 의도: 메모 = 모든 입력의 inbox)
PR-2C voice upload:
- migration 254: ALTER TYPE source_channel ADD VALUE 'voice'
- POST /api/memos/voice (multipart audio + recorded_at + device_hint)
· 검증: Content-Type audio/* + size ≤ 50MB + 확장자 화이트리스트
· NAS 저장: /documents/PKM/Recordings/{YYYY-MM}/{uuid}.{ext}
· fsync + rename(atomic) 패턴 (NAS soft mount 안전)
· Document row: file_type='audio' + source_channel='voice' + category='audio'
· enqueue stt 큐 → 기존 stt_worker → classify (PR-2B triage) → embed → chunk
· extract_meta 에 device_hint / recorded_at 보존
- 응답: MemoResponse (file_path 포함, frontend audio player 용)
원칙: AI worker 는 events row 직접 생성 X. 본 endpoint 가 사용자 의도 channel.
PR-2B (Memo Inbox Triage) backend 1/2. plan: beszel-tingly-sloth.md 라운드 13.
사용자 비전 = 메모는 inbox, AI 는 triage assistant. AI worker 는 events row 직접 생성 X.
Migrations 250–253 (실측 N=250):
- 250 CREATE TYPE event_kind_hint AS ENUM (note|task|calendar_event|activity_log|reference)
- 251 ALTER TABLE documents ADD ai_event_kind event_kind_hint
- 252 ALTER TABLE documents ADD ai_event_confidence NUMERIC(3,2) + CHECK 0–1
- 253 CREATE INDEX idx_documents_ai_event_kind partial WHERE ai_event_kind IS NOT NULL
ORM:
- Document.ai_event_kind / ai_event_confidence 컬럼 추가 (Enum SQLAlchemy 동기)
- source_channel enum 에 'voice' 추가 (PR-2C 와 호환)
Worker:
- classify_worker Phase 3 (Gemma 4B triage) 확장
· TriageOutput 에 event_kind_hint + event_kind_confidence 필드 추가
· 4B 응답에 hint 가 있을 때만 Document 에 저장 (enum 외 값은 무시)
- prompt p3a_short_summary.txt 확장 — note/task/calendar_event/activity_log/reference
분류 기준 + confidence + default='note' 명시
원칙: AI worker 는 hint 만 제공. events 생성은 다음 commit 의 promote endpoint 에서만.
plan v6 PR-2 scope. 5초 행동 기록 UX 가 핵심 가설.
Backend:
- GET /api/events/{id}/history — events_history timeline 조회 (lifecycle op 자동 기록)
Frontend (SvelteKit 5 runes mode):
- /events 메인 — 4-tab (오늘/Inbox/예정/활동) + 빠른 행동 기록 widget
· 단일 입력 + Enter → POST /api/events kind=activity_log
· status=done + 시간 default 채워짐 (서버 측) → Activity 탭 즉시 반영
· 새 항목을 list 최상단 prepend (refetch 불필요)
· 연속 입력 위해 입력 ref focus 유지
· lifecycle 버튼 (complete/defer/cancel/reactivate) — activity_log 는 lifecycle 대상 X
- /events/[id] 상세 — PATCH 허용 필드 edit (title/desc/시간/priority/project_tag) + history timeline
· PATCH 금지 필드는 UI 노출 X (status/completed_at/cancelled_at/defer_until 은 별 버튼)
- /events/new — kind 선택 (task/calendar_event/activity_log) 후 필드 분기 form
· task: due_at + start_at (선택, "14:00 전화" 같은 시각 task 허용 — 라운드 10)
· calendar_event: start_at 필수 + end_at + all_day
· activity_log: started_at/ended_at 비우면 서버 default now()
- Sidebar 메모 옆에 events 진입점 (CalendarCheck icon)
API helpers: frontend/src/lib/utils/events.ts (createEvent / logActivity / list*
/ lifecycle ops / kind&status enum label/color).
quickref doc: docs/events_api_quickref.md (이전 commit, PR-2 frontend reference).
PR-2 핵심 가설 검증 = 빠른 입력 → 저장 → Activity 즉시 반영 → 새로고침 유지.
PR-1 deferred HTTP behavior 5건도 본 UI 의 자연 사용으로 닫힘.
D9 Track B revised (2026-05-08):
1) STT owner GPU 정식 복귀:
- docker-compose.yml: stt-service profiles:[legacy] 제거 → 상시 활성
- fastapi STT_ENDPOINT = http://stt-service:3300 (compose 내부 DNS)
- 정책: Mac mini = Gemma 26B 전용 우선이므로 STT/Whisper 는 호출량 무관
GPU 서버 소유. 이전 "Mac mini 이전본" 주석은 trace 오인 기반.
2) KGS Code 등 외부 학습 자료 추가 스캔 경로:
- ADDITIONAL_WATCH_TARGETS env (쉼표 구분, PKM 상대경로)
- app/core/config.py: additional_watch_targets list 설정 추가
- app/workers/file_watcher.py: 추가 watch path 처리
- app/workers/classify_worker.py: KGS Code 분류 분기 (가스기사 학습 자료)
- 모두 expected_category=library 처리 (md/pdf/docx 만)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`<img src=>` 가 Authorization header 를 못 보내서 /api/documents/{id}/images/{key}/raw
가 401 반환 → 이미지 안 보임. 기존 /file?token= iframe 패턴과 동일하게 access token
쿼리 파라미터로 전달.
backend: get_current_user 의존성 제거하고 token 쿼리 파라미터 직접 검증 (기존 /file
엔드포인트와 동일 흐름).
frontend: MarkdownDoc 의 swap selector 가 img.src 에 ?token={getAccessToken()} 부여.
로그아웃 상태면 placeholder 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Markdown Canonical Phase 1B.5 — marker 가 추출하던 이미지를 NAS 에 영구 저장하고
DB 메타 + 인증 라우트 + 프론트 swap 까지 wiring.
핵심 변경:
- marker-service /convert 응답에 base64 image 리스트 포함 (stateless 유지, NAS write 권한 X)
- marker_worker 가 NAS `/documents/extracted_images/{doc_id}/` 에 persist + UPSERT +
고아 row DELETE + md_content ref 를 `docimg:img_NNN` stable scheme 으로 정규화
- /api/documents/{id}/images/{key}/raw 인증 라우트 (Cache-Control private + ETag = content_hash)
- frontend MarkdownDoc 가 placeholder card 안의 docimg ref 를 실제 <img> 로 swap
원칙:
- 이미지 binary = NAS, metadata = Postgres (학습 섹션 패턴 동일)
- image_key sequence 기반 결정적 → 재변환 idempotent
- MARKDOWN_IMAGE_PERSIST=false env 로 rollback 가능 (placeholder card 폴백 자연 유지)
기존 28건 marker success 문서는 본 PR 에서 건드리지 않음 — deploy + 신규 업로드 1건 +
sample 5건 검증 후 scripts/marker_reprocess_existing_success.py 로 targeted reprocess.
plan: ~/.claude/plans/piped-humming-crystal.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존: (ConnectError, TimeoutException) 만 transient → raise → queue retry.
ReadError / WriteError / RemoteProtocolError 같은 다른 transport 류는
'except Exception' 이 잡아 _fail 처리 → max_attempts 무시하고 final fail.
Phase 1D pilot 에서 5111/5115 두 건이 'Server disconnected without
sending a response' (RemoteProtocolError) 로 retry 없이 final fail.
Fix: except (ConnectError, TimeoutException) → except TransportError.
TransportError 가 Connect/Read/Write/RemoteProtocol/Timeout 의 공통 부모
라서 모든 transport 계층 오류가 transient queue retry 대상이 됨.
5135 의 ReadTimeout (queue exhausted) 는 본 fix 와 별개 — 8.4MB PDF 가
MARKER_TIMEOUT=300s 안에 못 끝나 3번 retry 다 timeout. timeout 자체를
늘리거나 큰 PDF 분할 처리하는 별도 결정 필요.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1D pilot (2026-05-02 야간 sweep, 25 controlled_backfill 결과) 에서
필기 PDF 3건 (4798 / 4813 / 4815) 이 status='success' 로 변환됐으나
사용자 quality 평가에서 좋은 자료 추출 불가 판정. 근본 원인은 Marker
설정 부족이 아니라 입력 자체 (애플펜슬 손글씨 + 사용자 글씨체 = OCR/
layout 모델 한계 영역). Marker 튜닝으로 해결될 영역이 아니므로 enqueue
단계에서 자동 skip.
가드 로직:
marker_worker.process() 의 doc_type SKIP 직후 (1.5 단계) title/path 의
보수적 키워드 4개 (필기, 손글씨, handwritten, handwriting) 매칭 시
_set_skipped() 호출. md_content/md_content_hash NULL clear,
md_extraction_error='skipped: handwritten note (title/path heuristic)',
content_origin='extracted'.
키워드 선정 (보수적):
포함: 필기 / 손글씨 / handwritten / handwriting
제외 (false positive 위험):
- 노트 (노트북 매뉴얼 / release notes / Note_240528_워크숍 같이
필기 아닌 정상 문서까지 잡음)
- scan / 스캔 (스캔 PDF 中 정상 변환되는 케이스 있음, 1D 결과
doc 5127 표준기계설계(KS)_08_핀 density 1.59 / scan_likely 인데
성공)
logger:
markdown_skip_handwritten_hint id=<id> keyword=<matched> title=<...>
regex 단위 테스트 15 케이스 (실 production fastapi venv) 전부 통과:
매칭: Note_240805_용접교육 필기 / Note_240827_필기 / 손글씨 모음 /
Handwritten Notes 2024 / handwriting practice / path/필기/* /
path/handwritten_collection/* (8건)
비매칭: 다이아프람워크숍 / 노트북 매뉴얼 / Release notes v2 / PIPE
FABRICATORS / 표준기계설계 / scan documentation / 스캔 문서 (7건)
이번 가드는 enqueue 시점 적용. 이미 success 인 4건의 md_content 는
보존 (사용자가 직접 보고 싶을 때 표시 가능). 정리 필요 시 별건.
후속 (별 PR):
- A2 (정식 doc_type='필기노트' 라벨): 1D 3건 sample 너무 적어 라벨
정의 보류. 필기 PDF 누적 후 별도 검토.
- C (Phase 2 풀 backfill plan): 본 PR 머지 후 별도 라운드.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
운영 데이터에서 ready 박힌 풀이가 793/838/866자 — 권장 200~400 대비 큰 편.
1차 운영 후 결과 화면 가독성 + 토큰 사용량 통제 위해 prompt 강화 + 저장 전 cap.
Prompt (study_explanation_envelope.txt):
- explanation_md 권장 300~600자, 최대 900자 명시
- 핵심 개념 + 정답 근거 + 헷갈리는 1~2개 오답만 — 모든 오답 풀이 X
- explanation_md 안 줄바꿈 최소화 (parse_json fix 와 결합 — invalid escape 줄임)
- LaTeX 수식 자제 — \\circ/\\text/\\, 매크로 가능하면 평문 ('0°C', 'C')
- 출력은 raw JSON 한 객체만 — 코드 펜스/thinking/메타 X 강조
Worker (study_explanation_worker.py):
- _cap_explanation_md(text, max_chars=1200) 헬퍼 신규
· 1200자 이하 passthrough
· 초과 시 마지막 200자 안에서 \\n\\n / \\n / '. ' / '다.' / '요.' 경계 탐색
· 경계에서 자르기 + '…' (단어 중간 자르기 회피)
· 경계 못 찾으면 단순 자르기 + '…'
- save 전 cap 적용. ai_explanation_status='ready' 유지 (cap 됐다고 failed X)
- payload 에 운영 분석 metadata: explanation_len_original / _saved / capped 플래그
검증:
- tests/test_explanation_cap.py (6 케이스)
· short passthrough / exact at limit / paragraph boundary / sentence boundary
· no boundary fallback / empty input
- scripts/phase4_health.sql 섹션 8/9 추가
· ai_explanation 길이 p50/p95/max (study_questions.ready)
· cap 작동 빈도 (job.payload 의 explanation_capped/_original/_saved)
cap 1200 = 800 (4-B summary_md) 보다 여유 — 기사시험 풀이는 공식+오답+개념 묶이면
800 빡빡함. 운영 후 800~1000 으로 조정 검토.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
직전 fallback 의 무차별 newline replace 가 string 외부 (object 구조) 의 raw newline
까지 escape 해서 JSON 거부. 또 LaTeX 수식 (\circ, \text, \, etc) 의 invalid backslash
는 newline 이슈와 별개라 별도 fix 필요.
state machine: in_string 토글 (`\"` 만남). string literal 안에서만:
- raw LF/CR/TAB → \\n/\\r/\\t 로 변환
- backslash 다음에 valid escape char (\"\\/bfnrtu) 면 그대로
- backslash 다음에 invalid (\\c, \\,) 면 backslash 자체를 \\\\ 로 escape
- string 외부 raw newline 은 JSON whitespace 라 보존
운영 데이터 id=243 의 raw 940자에 \\circ \\text \\, \\approx \\times 등 다수 LaTeX +
markdown 줄바꿈 → 새 walker 가 두 케이스 모두 fix. 다른 worker (classify/triage/
study_explanation/evidence/study_session_analysis) 자동 혜택.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4-A debug 결과 study_question_jobs.parse_fail 33건의 raw preview 분석:
- 모델이 explanation_md 안에 raw newline (LF) 그대로 박음 ('### [풀이]\n\n**자료...')
- JSON 표준상 string literal 안 raw control char 금지 → json.loads 거부
- 4단계 fallback (greedy slice) 도 이 때문에 실패
5단계 fallback 추가: candidate 의 \r\n/\n/\r 을 ``\\n``/``\\r`` escape 로 치환 후 재시도.
이미 escape 된 ``\\n`` (Python str = backslash+n 두 글자) 는 raw newline 아니라 영향 없음.
다른 worker (classify/triage/study_explanation/evidence/study_session_analysis) 모두
같은 파서를 공유하므로 자동으로 혜택.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
운영 데이터에서 4-A study_question_jobs 의 33/114 가 'envelope JSON parse failed'
로 종결. parse_json_response 의 balanced 정규식이 못 잡는 케이스 다수 추정.
원인 분류 위해:
1. 파서 보강 (app/ai/client.py)
- 기존 4단계 파싱 (fenced / balanced finditer / 전체 cleaned) 보존
- 5단계 fallback 추가: first '{' ~ last '}' greedy slice → json.loads
- envelope JSON 안에 내부 따옴표/뉴라인/escape 때문에 balanced 가 못 잡는
케이스 방어. 모델이 JSON 앞뒤 자유 텍스트 섞어도 본체만 추출.
- 회귀 위험 낮은 추가만 (앞 단계 성공 시 즉시 반환)
2. parse_fail 시 raw preview 저장 (study_explanation_worker)
- 3개 inline parse_fail 분기 (not_dict / invalid_answer_choice /
empty_explanation_md) 모두 _save_raw_preview() 헬퍼 호출
- job.payload.debug_raw_preview = raw_text[:1000]
- job.payload.parse_fail_reason = 분류 키
- 향후 parse_fail row 의 payload 분석으로 원인 정확히 분류 가능
다음 단계: 배포 후 재발생 추이 + raw preview 분석 → prompt 추가 강화 또는
parser 추가 보강.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4-B v1 첫 검증 결과 자료 부족 토픽인데도 모델이 confidence='high'
박는 케이스 발견. 정의 (high = 자료 + 다른 ai_explanation 으로 패턴 명확)
보다 과신 — UX 신뢰도 위험. 자동 cap 보정 + 운영 관찰 SQL 추가.
confidence calibration (services/study/session_summary_guard):
- calibrate_confidence(c, ctx_docs_count, ready_explanation_count) 신규
· ctx_docs_count == 0 AND ready_explanation_count == 0 → 'low' cap
· ctx_docs_count == 0 (ready 만 있음) → 'medium' cap
· ctx_docs_count >= 1 → 모델 값 그대로
- 모델이 정의보다 더 보수적인 값 박은 경우 (모델 'low' + cap 'medium') 는
보존 — 더 보수적인 값을 절대 올리지 않음
worker 적용 (study_session_analysis_worker):
- ctx_docs_count = len(ctx_docs)
- ready_explanation_count = sum(1 for a in prompt_attempts if a.get('ai_explanation'))
- calibrate_confidence 호출 → study_quiz_session_analysis.confidence 박힘
- job.payload 에 운영 분석 metadata 보존:
· ctx_docs_count / ready_explanation_count
· model_confidence_raw (모델 응답) vs calibrated_confidence (cap 후)
· prompt_attempts / valid_attempts_total / summary_len
→ SQL 4 번 쿼리가 cap 작동 빈도 측정
scripts/phase4_health.sql (신규 운영 점검 SQL 7 섹션):
1. 4-A study_question_jobs status × error_code 분포
2. 4-B study_quiz_session_jobs status × error_code 분포
3. 4-B confidence 분포 (calibrated)
4. 4-B model_confidence_raw vs calibrated 차이 (cap 작동 빈도)
5. 4-A/4-B 최근 7일 처리 지연 p50/p95/max/avg
6. 4-A/4-B skipped 사유 분포
7. 4-B guard_fail / parse_fail / llm_timeout 비율
ship gate (단위 테스트):
- test_calibrate_confidence_no_evidence_caps_to_low (3 케이스)
- test_calibrate_confidence_only_explanations_caps_to_medium (3 케이스)
- test_calibrate_confidence_with_documents_passthrough (3 케이스)
- test_calibrate_confidence_normalizes_invalid_first (2 케이스)
Plan: ~/.claude/plans/nifty-sparking-spindle.md (Phase 4-B v1 후속)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전 attempt 가 llm_timeout/parse_fail 박은 후 다음 attempt 가 정상 완료해도
error_code 가 잔존해서 운영 분석 시 혼선. status='completed' 박는 시점에
error_code = None / error_message = None 으로 명시 reset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
세션 1 (wrong+unsure 84건) 에서 prompt 가 23K자 넘어 30초 timeout. plan 가정
(5~30건) 대로 MAX_ATTEMPTS_IN_PROMPT=30 cap 추가. 가장 최근 attempts 우선
(answered_at asc 정렬의 뒤쪽). 기존 valid_attempts 카운트 검증 (5건 미만 skip)
은 그대로 유지 — cap 은 prompt 입력만, 검증은 전체 기준.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
결과 화면에서 사용자가 [AI 해설 보기] 누를 때 캐시 hit/miss 가 불투명함.
헤더에 한 줄 indicator 추가 — 오답·모르겠음 대상 N건 중 ready 박힌 카운트
+ 진행 중/실패/자료 부족 분포.
Backend (study_topics.py get_quiz_session):
- questions[i].ai_explanation_status 응답에 추가 (q.ai_explanation_status 그대로)
· frontend 가 attempts.outcome (wrong/unsure) 와 결합해 카운트
Frontend (quiz-sessions/[sid]/+page.svelte):
- $derived aiExplProgress — wrong/unsure attempts 와 question.ai_explanation_status
결합 카운트 (target / ready / pending / failed / skipped)
- 헤더에 Sparkles 아이콘 + "AI 풀이 자동 생성: N/M (P%)" 한 줄
· pending > 0: "생성 중 N" (warning 색)
· failed > 0: "실패 N" (error 색)
· skipped > 0: "자료 부족 N" (dim)
· 셋 다 0인데 ready < target: "대기열 처리 대기" (worker 1분 주기 안내)
이 indicator 는 GET fallback enqueue 와 함께 작동 — 결과 화면 진입 시점에
backfill 이 누락된 wrong/unsure 가 이미 enqueue 되고, 1분 주기로 ready 박힘.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4-A 가 wrong/unsure 풀이를 background batch 로 캐시하는데, 사용자/운영자
입장에서 (1) 지금까지 얼마나 캐시 채워졌는지, (2) 환각 차단/파싱 실패/자료 없음
같은 worker 결과 분포를 볼 수 없었음. 통계 대시보드에 카드 추가.
Backend (study_question_progress.py /stats):
- StatsAiExplanation 신규 응답 섹션
· status_distribution — 토픽 전체 study_questions.ai_explanation_status 분포
(none/ready/failed/skipped/stale/pending 6 키 default 0)
· target_total / target_ready — wrong/unsure progress 의 ready 비율
(캐시 hit 가능성 추정 핵심 지표)
· recent_jobs — 최근 7일 study_question_jobs 의 (status, error_code) 분포
('completed', 'failed:guard_fail', 'failed:parse_fail', 'skipped:evidence_missing'
같은 합성 키)
Frontend (/study/topics/[id]/stats):
- 신규 Card "AI 풀이 캐시" — Sparkles 아이콘
· 큰 숫자 + 진행률 바: ready / wrong+unsure
· 토픽 전체 status 분포 inline (한국어 라벨)
· 최근 7일 worker 결과 grid (환각 차단 / 파싱 실패 / 자료 없음 skip 등 분리)
- statusLabel / jobLabel 헬퍼 — 운영자 친화 한국어
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
검증 결과 모델이 envelope 안에서 자료 근거로 정답 번호를 재판단해서 거의 매번
guard_fail (answer_choice != correct_choice). 환각 가드는 정확히 작동했지만
caching 효율 0%.
PR-3 의 free-form 풀이는 "사용자 정답 우선, 충돌 명시" 라 정상 ready 박혔지만
envelope.txt 가 "자료 근거 우선" 으로 충돌. 환각 가드의 본질 — 모델이 envelope
형식을 어겨 임의로 다른 번호를 박는 케이스 차단 — 을 유지하되, answer_choice
값은 사용자 정답 (correct_choice) 을 그대로 박도록 명시.
자료 근거와 사용자 정답이 다를 경우 explanation_md 안에 짧게 명시만 하고
answer_choice 는 보존. 정답 자체를 바꾸는 게 환각 가드의 차단 대상이라고 강조.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자가 며칠 안 들어오면 due_today 가 누적되어 학습 페이스 압박. Phase 1
plan 위험 항목 처리. 자동 batch 대신 사용자 명시 액션으로 통제권 보장.
Backend:
- POST /study-topics/{tid}/review-queue/redistribute — overdue 를 round-robin
분산. days_offset = i % spread_days + 1 (오늘 + 1~7일). 같은 날 안에서도
i*7분 spread 로 시간 분산. review_stage 는 보존 (재배치만, stage 리셋 X).
body { spread_days: 1~14, default 7 }. 응답 { redistributed_count, spread_days }.
- GET /review-queue?tab=due_today 응답에 overdue_count: int 옵션 필드 — UI 가
경고 + [정리] 노출 판단. due_at < today 0시 (UTC) + stage<4 카운트.
Frontend (review-queue):
- due_today 탭에서 overdue_count>0 시 노란 banner — "정체 N건" + [정리] 버튼.
- 정리 클릭 → confirm → POST → toast (N건을 7일에 분산) → 카운트/목록 reload.
- 다른 탭에서는 banner 미노출 (backend 가 overdue_count=0 응답).
Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-F)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
복습함 카드 단위 체크박스 + sticky bottom bar 로 N개 골라 한 quiz_session.
backend QuizSessionStartRequest 에 question_ids 파라미터 추가 — 우선순위
stage > question_ids > 기존 subject 경로. 명시되면 selection 우회 + 검증
(user × topic 소속 + 미삭제 + 최대 200 + 중복 제거 순서 보존).
Backend:
- question_ids: list[int] | None — Field 한도 200
- valid_set 검증: 다른 user/topic 또는 deleted_at 인 qid 는 silent drop
- subject_distribution 자동 계산 (결과 카드용)
- 빈 wanted / 무효 qid → 400
Frontend (review-queue 페이지):
- 카드 좌측 체크박스 (분리 영역, 본문 클릭은 기존대로 문제 페이지)
- "이 페이지 전체 선택 / 해제" 토글
- 선택 N>0 시 sticky bottom bar — `{N}개 풀이 시작` 버튼
- 탭 변경 시 선택 초기화 (다른 의도 묶음 가능성)
- 페이지 이동 시 선택 유지 (Set<question_id>)
- 진행 중 in_progress 세션 있으면 confirm 후 abandon
- 200 한도 도달 시 toast 경고
Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-E)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 finalize 가 계산하던 SessionSummary 가 응답에 포함되지 않고 discard
되던 것을 quiz_session row 4 컬럼으로 영속화. 결과 화면 헤더에 회복/퇴행/
새로 맞힘/반복 오답 누적 변화 카운트 + "바로 할 일" 콜아웃 (지금 시점
progress 기반 동적 카운트 — pending_review/chronic/regressed). 동적 카운트는
결과 GET 호출 시점에만 계산 (목록 endpoint 비용 회피).
확인완료 통합 — 결과 카드의 [학습완료] 버튼이 attempts.reviewed_at 만 박던
것을 progress.last_reviewed_at + (wrong/unsure 면 due_at 최초 부여) 도 같이
박도록. reviewed=false 토글은 attempts 만 되돌림 (다른 attempt 가 검토 표시
했을 수 있어 progress 의 last_reviewed_at 은 보존).
- migrations/230 — quiz_sessions 4 컬럼 ADD (단일 ALTER TABLE)
- StudyQuizSession 모델 + finalize_session 가 row 영속화
- QuizSessionSummary 응답에 4 스냅샷 + 3 동적 필드 (default 0)
- _build_session_summary include_progress_counts=True 시 SQL 3회
- review-mark 가 reveiwed=true 시 progress 동기화
- 결과 화면: 헤더 변화 카운트 줄 + 바로 할 일 콜아웃 (값 있을 때만)
Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-B)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
문서 상세 페이지에서 canonical markdown(md_content) 을 우선 렌더하고
없으면 extracted_text fallback. md_frontmatter 가 있으면 본문 위에 메타
박스. h1~h6 에 GFM heading id + hover 시 # 링크 표시. 이미지 alt 가
있으면 figure + figcaption. KaTeX 수식 ($...$ / $$...$$) 지원.
Backend:
- DocumentDetailResponse 신규 (DocumentResponse + extracted_text + md_*)
- GET /documents/{doc_id} 응답 모델 전환
- 리스트 응답은 DocumentResponse 그대로 (페이로드 비대화 회피)
Frontend:
- lib/utils/docMarkdown.ts — 별도 Marked 인스턴스 (study mathMarkdown.ts
영향 0). marked-katex-extension + marked-gfm-heading-id + custom image
renderer (figure/figcaption + data-md-img marker).
- lib/components/MarkdownDoc.svelte — md_content/extracted_text 우선순위,
frontmatter 박스, mdStatus=failed 안내 배지, heading anchor DOM 후처리.
- /documents/[id] markdown / hwp-markdown / article viewer 3 곳 wiring.
- app.css — .markdown-doc heading-anchor / md-figure / katex 가로 스크롤.
이미지 ImgAuth 후처리(blob URL 교체) wiring 은 Phase 1B.5 에서. 현재는
data-md-img="1" 마킹만 두고 marker 출력 src 그대로.
Plan: ~/.claude/plans/plan-idempotent-sundae.md (Phase 1C)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vision 의 단일 풀이 진입점 — stage (intro/learning/pre_exam) + size 옵션으로
같은 endpoint 가 다른 분포의 문제 출제.
services/study/quiz_selection.py:
- bucket: unattempted / wrong_or_unsure / due_review / regressed / frequent / random
- stage 별 비율:
- intro: unattempted 55, wrong_or_unsure 30, frequent 15
- learning: due_review 20, wrong_or_unsure 40, unattempted 30, frequent 10
- pre_exam: due_review 20, wrong_or_unsure 30, regressed 10, frequent 20, random 20
- bucket 우선순위 (dict 순서) — 다음 bucket 은 이미 뽑힌 qid 제외
- 후보 부족 시 random backfill, 그래도 부족 시 ValueError
api/study_topics.py:
- QuizSessionStartRequest 에 stage / size 옵션 추가
- stage 명시 시 select_questions_for_quiz 사용
- stage 미명시 시 기존 PR-12-B 경로 (subject bucket + spacing) 호환 유지
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vision (풀이 → 확인 → 학습 → 복습 → 다음 풀이 가중치) 의 데이터 계층.
데이터 모델 (migrations 222~225):
- study_question_progress 테이블 — user × topic × question 단위 현재 상태 캐시
- 마지막 시도: last_outcome, last_attempted_at, last_attempt_id
- 검토 상태: last_reviewed_at
- 복습 큐: due_at, review_stage
- 패턴 분류 (derived): pattern_state, pattern_updated_at, pattern_window_attempts
- 3 partial idx (due / topic_pattern / pending_review) — 탭별 빠른 조회
패턴 분류 (services/study/learning_pattern.py):
- 7 분류: unattempted/unsure/chronic_wrong/regressed/recovered/stable/unstable
- 윈도우 = 최근 3회 + 과거 correct/wrong 존재 여부
- chronic_wrong > regressed > recovered 우선순위 (보수적 학습)
- 가드: wrong 1회만으로 regressed 안 됨 (이전 correct 이력 필요)
- stable 은 3 연속 correct 부터
세션 종료 집계 (services/study/session_finalize.py):
- attempts append-only 원본 보존, progress upsert 만
- 마지막 attempt 직후 finalize hook 자동 발동
- finalize 는 last_* + pattern_state 만 갱신, due_at 미진입 문제는 NULL 유지
- 이미 due_at 박힌 문제는 finalize 가 stage 갱신 (correct → +1 / wrong → 리셋)
API (api/study_question_progress.py):
- POST /study-topics/{tid}/questions/{qid}/review-complete
→ last_reviewed_at + (wrong/unsure 인 경우만) due_at 최초 부여
- GET /study-topics/{tid}/review-queue?tab=due_today|pending_review|chronic|regressed|mastered
→ 5 탭 paginated 조회
→ pending_review 는 last_reviewed_at < last_attempted_at 까지 포함 (이전 확인완료 후 다시 wrong 잡힘)
Phase 1-E (풀이 선별 알고리즘) 은 후속 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 222 가 DB enum 에 markdown 을 추가했지만 SQLAlchemy ORM 측 enum
정의 (app/models/queue.py) 에 누락되어 LookupError 발생.
테스트 enqueue → consumer 실행 시:
LookupError: 'markdown' is not among the defined enum values.
DB enum 마이그레이션은 migration 222 가 처리. ORM 측은 SQLAlchemy 가
직렬화/역직렬화에 사용하는 Python 측 enum mirror 역할.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- migrations 220/221: study_questions 에 related_repeat/similar JSONB + 카운트/grade/computed_at/threshold_version + partial idx
- 임베딩 워커: ready 처리 직후 같은 트랜잭션에서 related 계산·저장 + 같은 토픽 ready 행들의 related_computed_at=NULL invalidation
- 신규 cron study_q_related_refresh (1분, batch=20) — stale 캐시 일괄 재계산
- API list_related_types: cache hit (computed_at + threshold version 일치) 시 SELECT 1번으로 응답. miss 면 즉시 계산+저장 후 응답
- update_question PATCH: 본문/exam_round 변경 시 related_computed_at=NULL
- soft delete: 같은 토픽 ready 행 invalidation
threshold 변경 시: related_types.THRESHOLD_VERSION 갱신 + UPDATE WHERE version != '<신>' SET computed_at=NULL 한 번이면 cron 자동 일괄 재계산.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- round_count 별 등급 매핑 (단골/잘 나오는 반복 출제/반복 출제/신출/빈출)
- ≥7 단골, 5–6 잘 나오는 반복 출제, 3–4 반복 출제,
2 + max(연도)≥2024 신출, 2 + 모두 옛 빈출
- SIMILAR_THRESHOLD 0.88 → 0.85 (5-source 분포 측정 결과 자연 갭 위치 반영)
- API 응답 + 프론트 3곳 (보기/통합뷰/결과 카드) 라벨 일괄 통일
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>