매일 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 케이스 추가.
asyncpg 'cannot insert multiple commands into a prepared statement' 회피.
가설: 한국어 코멘트의 special char (lambda/arrow) + '::jsonb' cast 가 asyncpg
prepare 에서 multi-statement 오인. Phase 4 101 SQL 패턴과 정확히 맞춤 — JSONB
column 이라 default literal 은 자동 cast.
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 frontend (commit 4/4). plan v9 Memo Intake Upgrade.
PR-2B 분류 표시 + 1-click promote:
- 메모 카드 상단에 AI 분류 배지 (task/calendar/activity/reference + confidence%)
- ai_event_kind != 'note' 메모 하단에 4 버튼:
· [할 일로] [일정으로] [활동으로] (AI 추천 kind 는 색깔 highlight)
· [그냥 메모] (dismiss → ai_event_kind='note' 강제)
- promote 후 메모 카드에 "→ events #N" link 배지 (사용자 시각 확인)
PR-2C 음성 메모 표시:
- source_channel='voice' 메모는 🎙️ "음성" 배지
- audio player (<audio src=/api/documents/{id}/file?token=>) — 기존 file endpoint 재활용
- STT 대기 중인 voice 메모는 "음성 → 텍스트 변환 대기 중…" placeholder
API helpers:
- promoteMemo(memoId, kind) → POST /memos/{id}/promote-to-event
- dismissEventSuggestion(memoId) → POST /memos/{id}/dismiss-event-suggestion
- voiceAudioUrl(memoId) → /api/documents/{id}/file?token= (access token URL pattern)
Sidebar 영향 0 (events 진입점은 이미 PR-2 에서 추가됨).
원칙 (재명시): AI worker 는 events row 직접 생성 X — 본 UI 의 promote 버튼만이 events 진입.
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 의 자연 사용으로 닫힘.
PR-2 (frontend UI MVP) 진입 전 reference doc. plan: beszel-tingly-sloth.md v6.
내용:
- JWT 인증 flow (curl 예시)
- 9 endpoint 표 (Create/List/Detail + 4 Lifecycle + 3 View)
- kind / status enum 의미 + UI 분기 hint
- 빠른 행동 기록 5초 UX (PR-2 핵심 가설)
- PR-2 smoke 로 자연 검증할 5건 (PR-1 closure 의 deferred 항목)
- events_history 조회 endpoint 미존재 (필요 시 PR-2 에서 추가)
authoritative API contract = /openapi.json. 본 doc 은 frontend cheat sheet.
Storage Backbone NAS 트랙의 첫 PR. plan v6 명시대로 read-only inventory PR
— 운영 변경 / mount 변경 / file_path 갱신 / asset 이동 모두 0건. 문서만.
산출물:
- docs/storage_layout.md 영구 정책 문서 (정책 / 마운트 매트릭스 / NFS 옵션 baseline)
- reports/storage_inventory_2026-05-11.md 측정 결과 snapshot
핵심 인사이트:
1. NAS binary layer 는 이미 잘 분리되어 있음 — PKM/extracted_images/
study_question_images 모두 이미 NAS. 추가 이관 PR-3/4 작업량 거의 없음.
2. 현 GPU NFS mount = plan v6 권고안 baseline 과 정확히 같음
(soft, vers=4.1, timeo=10, retrans=3) — PR-2 는 mount 옵션 변경 아닌
애플리케이션 layer (정규화 wrapper / 장애 처리 / uid 매핑) 에 집중.
3. fastapi 만 NAS rw, worker 는 ro — 원본 안전 분리 OK.
4. Postgres pgdata = 1.1GB (DB 본체 이관 안 함, plan 결정 = GPU 잔류).
5. PR-4 도입 시 extracted_emails/ 신규 디렉토리 추가 예정 (Storage PR-5 합류).
실측 명령: SSH 100.111.160.84 → df/mount/du/docker volume ls/docker run
-v ... alpine du. 모두 read-only. 운영 영향 0.
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>
cron dry-run 검증 중 발견:
- /app/scripts/ 는 bind-mount 활성 (Phase 2 main FF 후 컨테이너 가시화 ✓)
- /app/evals/ 는 fastapi 이미지에도 없고 compose 마운트도 없음
- 이전 README/plan 의 --log-tsv /app/evals/markdown/... 은 컨테이너
writable layer 에 쓰여 재기동 시 유실되는 문제
해결: nightly --log-tsv 와 post-report --output-* 는 /app/logs/ 사용
(rw bind-mount → host ~/Documents/code/hyungi_Document_Server/logs/ 영구).
주 1회 git commit 시 logs/ → evals/markdown/ 로 cp 후 add.
post-report 도 동일 패턴.
Plan/README 가 /app/scripts 를 통일 경로로 가정했으나 실측 결과 read-only
bind-mount 라 docker cp 불가. soft lock 으로 --build 도 금지. 단계별로
다른 경로 사용해야 함:
- 2-B canary (pre-merge): /app/logs/phase2_backfill.py + /app/logs/*.csv
(docker cp worktree → /app/logs rw bind-mount). canary 검증 동안
미검증 코드 main 진입 회피.
- 2-C nightly (post-merge canonical): /app/scripts/phase2_backfill.py +
/app/evals/markdown/phase2_* (feat/phase2-backfill main 머지 +
parent git pull 후 bind-mount 자동 활성). cron 도 canonical path.
evals/markdown/README.md 의 enqueue 예제 + 신규 #### 경로 정책 섹션 반영.
`<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>
`:payload::jsonb` 의 `::` postfix 캐스트가 SQLAlchemy text() 의 named-param prefix
`:` 와 충돌해 asyncpg syntax error. doc 3757 sample reprocess 시 발견.
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>
사용자 quality 평가:
"애플펜슬로 필기한건 내 글씨체 이슈에 더해서 좋은 자료를 뽑아내지
못하네 그 외에는 잘되는거 같은데"
분류:
overall_pass=true 24건 — 일반 PDF (born-digital + scan-like 中
5127 같이 정상 변환되는 케이스)
overall_pass=false 4건 — 애플펜슬 필기 4건 (4798/4813/4815
controlled_backfill + 4809 anchor)
overall_pass=empty 2건 — page_count > MAX_PAGES=200 의도 skip
(5178 ASME 272p, 5180 ASME Sec I 453p)
정식 rubric 5축 (text_accuracy/structure/noise_rate/multi_script/
completeness) 점수는 비워둠 — 사용자 약식 판정으로도 의사결정 매트릭스
분기 (필기만 fail → SKIP rule 확장) 가 명확해 정식 채점 over-investment.
후속 라운드 (Marker 튜닝/대안 OCR 도입 시) 같은 30건 재평가에는 정식
rubric 채울 가치 있음.
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>
Round 2 sample 에 existing_success 5건 (anchor doc 4809 + calibration 4)
이 포함되었지만, cmd_enqueue 가 sample_source 무시하고 30건 전부 enqueue
하던 버그. 결과:
- existing 5건 marker 재처리 (~25분 marker 시간 낭비)
- 동일 quality output 으로 md_content overwrite → baseline 유실
- anchor (doc 4809) 의 "before" 상태가 사라져 후속 라운드 비교 anchor 손상
Fix:
- default = sample_source == "controlled_backfill" 만 enqueue (25건)
- --include-existing flag 추가 (후속 Marker 튜닝 라운드에서 anchor 재처리
필요 시 사용)
- print 로 mode 명시 + 제외된 ids 표시
야간 단발 sweep (23:00 KST) 예약 실행 전 fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 phase1d_pilot.py (단순 ai_domain × file_size 3-bucket) 를 plan
~/.claude/plans/stratified-mingling-otter.md 의 4축 + sample_source 분리
+ forced_include 로 augment.
Round 1 (ai_domain × file_size 3-bucket) 의 한계:
pending PDFs 의 자연 분포만 반영 → 알려진 약점 (필기/스캔/한중일
mixed OCR) 이 sample 에 안 들어옴. 1C 시각 확인에서 doc 4809
(Note_240805_용접교육 필기) 가 실제로 그 패턴을 보였는데, 자연
selection 에 맡기면 다음 라운드도 같은 case 가 빠질 위험.
Round 2 디자인:
- 4 축 stratification: doc_type × file_size_band × text_density_band
× handwritten_hint
- sample_source ∈ {existing_success(5), controlled_backfill(25)}
- forced_include doc 4809 — known bad anchor. 다음 튜닝/대안 도입 후
같은 문서 재변환 결과와 1:1 비교 가능.
- text_density = LENGTH(extracted_text) / (file_size / 1024) chars/KB
가장 깨끗한 단일 proxy. 0.17(필기 4809) ↔ 94(born-digital 3759)
양 끝 검증.
- script_mix proxy: Hangul/CJK/Hiragana/Katakana/Latin Unicode block
ratio → korean_dominant / mixed_korean_cjk / mixed_korean_latin /
cjk_dominant / latin_dominant / unknown.
- page_count_estimate: existing_success 는 md_extraction_quality.
metrics.source_page_count 사용. controlled_backfill 은 NULL
(marker 가 PyMuPDF 로 어차피 다시 읽음).
- 시드 SAMPLE_SEED=20260502 고정, 재현성 보장.
Sample 분포 (실측 2026-05-02):
bucket_label: born_digital=12, mixed=5, existing_calibration=4,
handwritten=3, scan_likely=3, large=2, existing_anchor=1
doc_type: Academic_Paper=7, study_note=6, Standard=5, Note=4,
Reference=3, Manual=3, Drawing=1, Report=1
file_size_band: M=14, S=12, L=4
text_density_band: born-digital=15, scan-likely=9, mixed=6
handwritten_hint: lo=26, hi=4 (모집단 1.1% 대비 13배 over-sample)
forced anchor doc 4809 = density 0.17 (사용자 시각 확인의 그 문서)
새 subcommand:
eval_template — pilot_1d_eval.csv 스켈레톤 (rubric 5축 1~5 +
overall_pass + notes). 사용자가 MarkdownDoc + PDF 토글 비교하며
점수 채움.
기존 cmd_enqueue (snapshot/backup/dedup) + cmd_report (quality 메트릭)
는 유지.
산출물:
scripts/phase1d_pilot.py — 4축 + sample_source + forced_include +
eval_template subcommand. CSV+JSON dual output.
evals/markdown/README.md — rubric + decision matrix + workflow guide.
evals/markdown/pilot_1d_sample.csv — 30 rows × 15 cols (시드 결과,
재현성 보존).
evals/markdown/pilot_1d_eval.csv — 빈 스켈레톤 (사용자 평가 후 채움).
실행 경계:
Step 1~3 (selection / template / dry-run) = 본 PR 으로 완료.
Step 4 (--yes enqueue, 실제 30건 markdown 큐 인입) = 사용자 timing
승인 + 야간 단발 sweep 윈도우 (23:00~03:00 KST) 안에서 별도 실행.
marker-service BATCH_SIZE=1, 30건 평균 5분/건 ≈ 2.5h.
Verify:
GPU 서버 fastapi 컨테이너에서 select 실행 → 30건 sample CSV 생성됨.
eval_template subcommand 동작 확인. enqueue dry-run 으로 30 doc_ids
+ snapshot 출력 후 사용자 취소 분기 확인.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1B marker_worker 결과(현재 success 29건, 전부 PDF)를 사용자 흐름에
연결하고 1D pilot 품질 평가 데이터를 확보하기 위한 viewer 마무리 작업.
빠진 부분 3가지를 닫는다:
1) PDF viewerType 기본 view = Markdown
- md_status='success' AND md_content 비어있지 않음일 때 MarkdownDoc 기본 표시.
- 사용자가 "PDF 원본" 토글 시 iframe.
- pdfViewMode 초기화는 doc.id 변경 시에만 (lastDocId tracker) — reactive cycle
이 사용자 토글을 덮어쓰지 않도록 보호.
- markdown 사라지는 케이스(success → failed 재처리)는 자동으로 pdf 로 보호.
2) Image renderer → placeholder card (docMarkdown.ts)
- md_content 의 69%(20/29)에 image syntax 포함. asset serving(1B.5) 미구현
상태에서 raw <img> 를 emit 하면 깨진 아이콘 → 1D pilot 평가가 markdown
품질이 아닌 viewer 미완성 문제로 오염됨.
- href / alt / basename 모두 escape 후 figure.md-image-placeholder 로 렌더.
- 원본 src 는 data-md-image-src 에 escape 보존 → 1B.5 ImgAuth selector 로
실제 <img> 로 교체할 entry point 마련.
- DOMPurify ADD_ATTR 에 data-md-image-src 추가.
3) MarkdownStatusBadge (신규) — 4-state badge
- pending 숨김(legacy 9792건 시각 노이즈 회피).
- processing/success/skipped/failed 표시.
- success tooltip: md_extraction_quality 의 metrics raw 일부
(markdown_heading_count / markdown_table_row_count / markdown_image_count /
text_length_ratio / warnings) 만 노출. text_length_ratio / null /
metrics nested / flat fallback 모두 방어.
- skipped/failed tooltip: md_extraction_error 또는 정책 문구.
- MarkdownDoc 내부 + PDF iframe fallback 양쪽에서 재사용 → failed 같이
MarkdownDoc 가 안 렌더되는 경로에서도 사용자가 상태를 알 수 있음.
기존 markdown/hwp-markdown/article 분기에도 mdExtractionQuality prop 전달.
Out of scope (1B.5 또는 후속):
- ImgAuth blob URL 실제 wiring (data-md-image-src selector + Bearer raw)
- /data/assets/<doc_id>/ 저장 + 서빙
- Caddy /data/assets/* 라우팅
- localStorage 사용자 view preference 저장
- side-by-side viewer (1D pilot 결과 본 후)
- quality chip 별도 UI (1D 후)
Verify:
- npm run build 통과
- npm run lint:tokens 신규 파일 위반 0
- 관련 plan: ~/.claude/plans/iterative-nibbling-catmull.md
- pre-flight: md_extraction_quality 실제 shape 확인 ({score, metrics:{...}, warnings:[]})
Risks:
- feature/design-system worktree 가 [id]/+page.svelte 의 stale 버전 보유
(main 보다 212 commits behind, MarkdownDoc 부재). 1C 머지 후 worktree
머지 시 conflict 확정 — 그쪽 rebase 필요 (별건).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
case 3/4 의 setup 이 EXPLANATION_MAX_CHARS (1200) 보다 작은 text 를 만들어
assert 실패. 한글 chunk 반복 횟수 늘려 1200 자 이상 보장.
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>