[#1 모든 획이 안 들어옴]
- pointerleave 핸들러 제거 — stale leave 가 isDrawing=false 만들어 다음
pointermove 가 다 무시되던 핵심 누락 원인 차단.
pointerup / pointercancel 만으로 finalize.
- 1점 stroke (짧은 탭) 도 strokes 에 보존. length>1 검사 제거.
[#2 점선 stroke (긴 직선이 ........)]
- pushPointWithInterp: 점 사이 거리가 8px 초과 시 중간 점 자동 보간.
iPad 60Hz pointermove + 빠른 펜 이동에서 sparse point 일 때도 매끈.
- perfect-freehand 옵션 재튜닝:
thinning 0.4 → 0.25 (얇아지지 않게)
smoothing 0.62 → 0.85 (sparse point 도 부드럽게)
streamline 0.5 → 0.65 (손떨림 보정 강화)
[#4 Safari 팝업 가끔 뜸]
- pointerdown 시점에 document.getSelection().removeAllRanges() 강제 clear.
selectstart preventDefault 만으로 부족한 케이스 (펜이 이미 선택된 영역
위에서 시작) 방어.
기존 문제: 점선 stroke / 연속 입력 누락 / 버튼 focus zoom / Safari 선택 팝업.
원인을 4축으로 분리해서 한꺼번에 fix.
[1] 입력 수집 (PointerEvent 상태머신)
- isDrawing flag + activePointerId 매칭으로 stroke 누락 방지
- pointerdown: 이전 inflight 가 살아있으면 finalize 후 새 stroke 시작
- setPointerCapture (try-catch) — element 외 pointer move 도 받음
- pointerup / pointercancel / pointerleave 통합 endStroke
- pointerType === 'pen' (mouse 도 데스크톱) 만, 손가락 거부
[2] coalesced events
- pointermove 의 e.getCoalescedEvents() 모두 points 에 push
- 빠른 필기에서 sparse point → 점선 현상 방지 핵심
- normalizePressure: 0/비정상 값은 0.5 fallback
[3] 렌더링: perfect-freehand polygon fill
- getStroke(thinning:0.4, smoothing:0.62, streamline:0.5, last:true)
- getSvgPathFromStroke (perfect-freehand README 표준 builder)
→ Path2D → ctx.fill() — anti-aliased polygon
- 1점 케이스: arc fill 폴백
- last: true 항상 (진행 중에도 polygon 닫힘)
[4] autosave 입력 분리
- 3초 idle debounce
- flushSave 는 setTimeout 0 으로 다음 macrotask
- PATCH 응답이 strokes 를 덮어쓰지 않음 (응답 무시, fire-and-forget)
[5] Safari/Chrome hardening
- 캔버스/컨테이너: touch-action: none + user-select: none +
-webkit-touch-callout: none + -webkit-tap-highlight-color: transparent
- canvas 에 oncontextmenu / onselectstart preventDefault
- 모든 toolbar 버튼: clickThenBlur(fn) + tabindex=-1 + BTN_STYLE
→ button focus zoom 차단 (사용자 보고 "버튼 누르면 화면 확대" 핵심)
[6] resize 정책
- ResizeObserver + window resize/orientationchange 만 트리거
- pointermove 마다 resize 절대 안 함
- DPR 반영 + setTransform(dpr,...) 으로 retina 선명
수정 범위 (사용자 명시): HandwriteCanvas.svelte 만. 다른 영역 무수정.
증상 1 (사용자 보고): 펜/지우개/굵기 등 어떤 toolbar 버튼이든 누르면 화면
확대. 창을 옮기면 정상 크기. 다시 누르면 또 확대.
원인: iPad/Chrome 의 button focus 시 자동 zoom (focus 후 layout 변경 또는
브라우저 자체 zoom). 우리 fix 들이 핀치줌만 보고 focus zoom 을 놓침.
Fix 1 — clickThenBlur + tabindex=-1:
- 모든 toolbar/header button 의 onclick 을 clickThenBlur(fn) 로 감쌈.
click 시 즉시 e.currentTarget.blur() 호출 → focus 안 받음 → zoom 안 일어남.
- tabindex={-1} 추가 — 키보드 포커스 자체 차단.
증상 2 (사용자 사진): 빠르게 그린 stroke 가 점선처럼. perfect-freehand 의
polygon outline 이 sparse point 에서 깨짐.
Fix 2 — perfect-freehand 제거, 단순 quadratic bezier:
- ctx.moveTo + 점-점 사이 quadraticCurveTo 보간 + ctx.stroke() 한 번 호출.
- lineCap/lineJoin round, lineWidth = effectiveSize.
- 압력 효과는 미반영 (단일 굵기) — 안정성 우선. 점선 안 됨.
- 1점/2점 케이스 폴백 (arc / lineTo).
P1 데스크톱 trackpad pinch 줌 차단 (Chrome/Firefox macOS):
- wheel + ctrlKey/metaKey preventDefault 추가 (페이지 zoom 방지)
- 데스크톱 Chrome 은 gesture 이벤트 미발화, wheel + ctrlKey 만 발화
- 사용자 사진 8854/8855: 모드 토글 사이 trackpad pinch 로 페이지 zoom 발생
P2 iPad 입력 씹힘 — main thread 블록 해소:
- offscreen buffer canvas 도입. 완료 stroke 들은 buffer 에 한 번만
perfect-freehand getStroke + Path2D fill 로 그림.
- 매 frame 의 redraw 는 ctx.drawImage(buffer) + inflight 만 처리.
- strokes 변경 시만 bufferDirty=true → 다음 redraw 에서 rebuild.
- iPad CPU 에서 33+ stroke 매 frame 재계산이 16ms 초과해 pointer event
누락하던 문제 해소.
Helper:
- setStrokes(next): strokes 재할당 시 buffer rebuild 자동 마킹.
모든 strokes 갱신 (snapshot, eraseAt, finalize, undo, redo, clear,
restoreFromLocalStorage) 에 적용.
여전히 발생하는 입력 누락 / 지우개 누르면 확대 재시도.
P1 줌 차단 강화:
- gesturestart/change/end 를 document level 로 다시 등록 (element-level
ongesturestart 가 일부 iPad Safari 빌드에서 미발화)
- touchstart/touchmove 의 e.touches.length > 1 도 preventDefault — gesture
이벤트 자체가 안 들어오는 경우의 핀치 zoom 백업 방어
P2 입력 누락 — 입력 루프와 redraw/저장 분리:
- pointermove 의 redraw() 를 RAF throttle (scheduleRedraw) — 60Hz 보다 빠른
pointermove 에서 매번 redraw 하던 부담 제거. input 처리 즉시, render 는 frame 당 1회.
- autosave: 5 stroke 즉시 flush 제거 — 빠른 필기 중 JSON.stringify 부하 차단.
3초 idle debounce 만 유지.
- onChange 호출을 setTimeout 0 으로 다음 macrotask 에 ship — 직렬화가
pointer event 와 충돌 안 함.
P1 Safari 줌 차단:
- viewport meta 의 maximum-scale / user-scalable=no 제거 (접근성)
- 페이지 root div 의 ongesturestart/change/end preventDefault — 영역 제한
- 모든 toolbar/header button 에 직접 inline style 적용:
touch-action: manipulation, user-select/-webkit-user-select: none,
-webkit-touch-callout: none, -webkit-tap-highlight-color: transparent
P2 연속 stroke 누락:
- onPointerDown: 이전 inflight 강제 finalize 후 새 stroke 시작
- onPointerMove: pointerId 매칭 완화, isPenLike + inflight 만 체크
(Apple Pencil pointerId 재사용/변경 케이스 방어)
- endStroke: pointerleave race 방어, pointerup/pointercancel 은 무조건 finalize
- 자동 저장 (PATCH) 은 fire-and-forget 그대로 — 입력과 분리
P3 점선 렌더링 품질:
- perfect-freehand 표준 getSvgPathFromStroke + Path2D fill 로 교체
(직접 quadraticCurveTo 보다 안정적)
- thinning 0.5, smoothing 0.7, streamline 0.55 로 튜닝
- normalizePressure: 0/비정상 값은 0.5 fallback (점선 방지)
- coalesced events 모두 points 에 push (빠른 필기 샘플 간격 좁힘)
- 단일 점 (탭) 은 작은 원으로 폴백
증상 (사용자 사진 8856): 펜으로 쓰는데 "복사하기 / Google 으로 검색" 같은
iOS 텍스트 선택 메뉴가 뜸. Safari 가 펜 입력을 텍스트 선택으로 해석.
Fix:
- 캔버스 + 컨테이너 + 페이지 root 에 user-select / -webkit-user-select /
-webkit-touch-callout / -webkit-tap-highlight-color 적용
- canvas 에 oncontextmenu preventDefault — long-press 후 메뉴 차단
증상 (사용자 사진 8854/8855): 펜 → 지우개 토글 사이에 두 손가락이 캔버스에
닿으면서 페이지 전체가 핀치줌되어 글자가 커보이고 stroke 점들이 띄엄띄엄
표시. undo/redo 도 zoom 된 좌표계라 효과 안 보임.
원인: touch-action: none / manipulation 만으로 iOS Safari 의 visualViewport
스케일 기반 핀치줌이 차단되지 않음.
Fix:
- /study/write/[id] 페이지 단위 viewport meta override:
maximum-scale=1, minimum-scale=1, user-scalable=no
(페이지 unmount 시 svelte:head 가 자동 해제)
- document level gesturestart/gesturechange/gestureend 이벤트
preventDefault — iOS 비표준 gesture 이벤트 차단
- onDestroy 에서 cleanup
필기감:
- perfect-freehand 재도입 (effect race 제거됐으니 안전)
- thinning 0.6, smoothing 0.65, streamline 0.5
- simulatePressure false → 실제 e.pressure 반영
- outline polygon 을 quadratic bezier 로 연결 → 부드러운 곡선 (직선 segment ❌)
- ctx.fill() anti-aliased
UI:
- 굵기 토글 (가늘게/보통/굵게) — baseSize × {0.6, 1, 1.6}
- Pencil only (touch 차단)
연속 stroke race fix:
- setPointerCapture/release 제거 → 빠른 pointerup→pointerdown race 차단
- onPointerDown 시 이전 inflight 강제 보존 (드물지만 stale 한 경우)
- pointerleave 핸들러는 inflight 가 살아있을 때만 endStroke
- endStroke: inflight 없으면 즉시 return, activePointerId 만 정리
이전 보고: "ㄱ 쓰고 ㅏ 바로 쓰면 ㅏ 가 입력 안됨" 핵심 원인은 stale
pointerleave 가 두번째 stroke 를 강제 종료시킨 것. 위 race fix 로 해결.
- isPenLike: 'touch' 제거. pen/mouse 만 허용 → 손가락 stroke/지우개 차단
- 페이지/툴바 영역에 touch-action: manipulation → 버튼 빠른 두 번 탭 시
iOS Safari 더블탭 줌 차단. 지우개/펜 토글 시 화면 확대되던 현상 fix.
원인: \$effect(initialStrokes 동기화) 가 strokes 도 의존성으로 추적함.
사용자가 펜으로 그린 후 strokes 변경 → effect 재실행 → 조건
"initialStrokes.strokes !== strokes" 가 true → strokes 를 옛 initialStrokes
값으로 되돌림 → 새 stroke 사라짐.
지우개 누르면 글자가 커지는 현상도 같은 effect 가 trigger 되며 strokes 가
옛 값으로 reset + canvas 비율 재계산이 겹쳐 발생.
Fix:
- \$effect 제거. 초기 strokes 는 \$state initial value 로 한 번만 set.
부모가 prop 새 값을 줘도 무시 (사용자 진행 stroke 우선).
- traceText effect 는 명시적 prev 비교로만 redraw 트리거.
- 디버그용 빨간 사각형 / 빨간 strokeStyle 제거. 정상 색 (--text) 복귀.
stroke 가 안 보이는 원인 격리. iPad 화면에서:
- 좌상단 빨간 50x50 사각형 보임 + 빨간 stroke 보임 → 토큰 색 문제
- 사각형 보임 + stroke 안 보임 → drawStroke / strokeStyle 문제
- 사각형도 안 보임 → redraw 미호출 또는 canvas 자체 가려짐
증상: stroke count 는 올라가는데 화면에 그려지지 않음 + 위치 어긋남.
원인 격리 시도:
- perfect-freehand 의 polygon fill 이 일부 환경에서 제대로 그려지지 않는 것으로
보여 단순 ctx.beginPath/moveTo/lineTo/stroke() 로 갈아치움. lineCap/lineJoin
'round' + lineWidth=baseSize 로 자연스러운 라인. 압력 효과는 일시 제거.
- getLocalXY 에 scale 보정 추가: canvas.style.width(cssWidth) 와 rect.width 가
다른 ResizeObserver 지연 케이스에서 좌표가 어긋나지 않도록 비율 보정.
이번 변경으로도 stroke 가 안 보이면 디버그 오버레이의 좌표/크기를 보고
다른 경로 (캔버스 자체 비활성, layer 가림 등) 추적.
증상: iPad 에서 펜 입력이 안 들어가거나 다른 위치에 그려지는 보고. 원인은
좌우 분할 layout 에서 우측 캔버스 영역이 좁거나 layout 이 stale.
UI:
- /study/write/[id] layout 을 캔버스 풀스크린 + 좌측 floating panel 로 변경
- 헤더에 패널 토글 버튼. 패널 default closed → 캔버스가 화면 거의 전체
- 캔버스 컨테이너에 border-default/30 추가 (영역 가시화)
좌표/입력:
- isPenLike: 'touch' 도 허용 (iPad 일부 빌드에서 Pencil 이 'pen' 으로 안 들어오는 케이스 방어)
- 디버그 오버레이: 캔버스 크기 + 마지막 pointer 좌표/pressure/type 표시
- ResizeObserver 외에 window resize / orientationchange 리스너 추가
- 마운트 직후 RAF×2 후 한 번 더 resizeCanvas (flex 레이아웃 0x0 첫 paint 방어)
stroke 가 안 그려지는 이슈 수정 + 사용자 요청한 부분 지우개 추가.
렌더링 fix:
- last:true 항상 (진행 중 stroke 도 양쪽 outline + cap 완성, polygon 닫힘 보장).
이전엔 inflight 일 때 last:false 라서 outline 한쪽만 그려져 fill 영역 거의 0.
- thinning 0.5 → 0.3 (시작/끝 부분이 너무 얇아지지 않게)
- baseSize default 4 → 6
- pointermove: main 점을 항상 push (coalesced 는 보간 보조)
부분 지우개:
- tool: 'pen' | 'eraser' state. 툴바에 펜/지우개 토글
- eraser 모드: pointer 가 지나가는 stroke 를 점-원 hit-test 로 즉시 삭제
- eraserRadius = baseSize * 4 (최소 16 px)
- 삭제된 stroke 는 undoStack 으로 — undo 로 복구 가능
- cursor: eraser 면 'cell', 펜이면 'crosshair'
- 전체 지우기는 별도 Trash2 버튼으로 분리
문제: dark mode 에서 stroke #111 이 --bg #0f1117 와 거의 같아 안 보임 +
Apple Pencil pressure 0 케이스 방어 부재.
수정:
- strokeColor 를 마운트 시 --text 토큰 실측 (e4e4e7 등) 으로 갱신
- simulatePressure true 로 변경 — 압력 0 으로 들어와도 속도 기반으로 굵기 보장
- thinning 0.55 → 0.5
3일 telemetry (599 triage / 555 deep) 기반 임계치 재평가:
1. 에스컬레이션 비율 — 임계치 의미 reframe
- 기존: >20% 적색 (튜닝 필요) → 항상 적색 (운영 패턴 97%)
- 신규: <80% 적색 (정책 매칭 실패 증가)
- 메시지: "safety 정책상 95~100% 가 정상" 보조 표시
- safety_reference 99.7%, generic 100% (fallback risk_flag), msds 46.2%
→ 운영 정상 패턴 확인
2. Deep summary 안정성 — 신규 카드 추가
- mode='summary_deep' 의 error_code IS NOT NULL 비율
- 현재 5.2% (call_failed 21 + parse:ValidationError 8)
- >5% 적색 임계
- MLX 호출 timeout / JSON 파싱 실패 모니터
3. triage JSON 건강도, Backlog Suppression — 임계치 유지
- 현재 0%, 1% — 매우 안정. 보수적 임계 유효.
Backend: TierHealthStack 에 deep_total / deep_err_total 추가
Frontend: 카드 그리드 3열 → 4열 (lg), Day 4 신규 카드.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3일 운영 결과 doc 4811, 5181 가 extracted_text='' (빈 문자열) 인데
IS NOT NULL 만 걸려 enqueue → classify_worker 의 not doc.extracted_text
truthy 체크에서 ValueError → max_attempts(3) 도달 → status=failed.
다음 backfill 사이클에서 다시 enqueue 되어 12회 반복, failed 24건 누적.
수정: tier_backfill.py + backfill_tier.py 양쪽 SQL 에
LENGTH(extracted_text) > 0 추가. 빈 문자열 문서는 enqueue 자체에서 제외.
기존 failed 24건 정리 SQL (사용자가 수동 실행):
DELETE FROM processing_queue
WHERE stage='classify' AND status='failed'
AND error_message LIKE '%extracted_text%';
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
asyncpg prepared statement 는 single-command 만 허용. 원래 한 파일이던 study_sessions
스키마(CREATE TABLE x2 + CREATE INDEX x8)를 143~146 분할 패턴 따라 10개로 분리.
164: CREATE TABLE study_sessions
165~169: study_sessions 인덱스 5개 (partial)
170: CREATE TABLE study_session_assets
171~173: study_session_assets 인덱스 3개
문제: cannot insert multiple commands into a prepared statement
원인: _run_migrations 가 conn.exec_driver_sql 로 단일 prepared statement 실행
PR-B refactor 과정에서 e88640d 의 process() 진입부 source_channel='law_monitor'
skip 분기가 사라져 매일 07:00 신규 법령 분할마다 26B legacy classify(8s) +
26B legacy summarize(10s) + 4B triage(1.5s) 전부 호출되고 있었다.
법령 분리 PR (stateless-churning-raccoon) 의 명제:
"법령은 외부 source-of-truth + immutable + 자동 재수집 → 다른 수명주기"
와 일치하도록 process() 진입부에 skip 분기 복원. 최소 필드 (ai_domain='법령',
ai_tags=['법령'], importance='medium') 만 세팅 후 return. queue_consumer 의
NEXT_STAGES['classify']=['embed','chunk'] 가 자동 chain 하므로 검색 영향 0.
법령 도메인 AI 산출물 가치 분석:
- ai_summary: 법령 해석 환각 위험 (ASME/안전 엔지니어 사고 책임 소지)
- ai_tldr/bullets: 이미 title 이 같은 정보 노출 — redundant
- ai_inconsistencies: 공식 정합 문서라 100% false positive
→ 비용 (월 ~14분 26B 점유) 대비 가치 음수, skip 합당.
tier_backfill.py 도 함께 수정:
- DOMAIN_PRIORITY 에서 ('law', source_channel='law_monitor') 항목 제거
- safety 필터에 source_channel != 'law_monitor' 추가 (기존 ai_domain LIKE
'Industrial_Safety%' 매칭 안에 backfill 기 처리한 법령 doc 들이 잡혀
들어가는 case 차단)
- 사유: skip 처리될 doc 을 enqueue 하면 야간마다 enqueue→skip→NULL→
enqueue 무한 루프
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
본문에 `- [x]` 로 직접 입력된 체크 항목도 checked_at 가 기록되어 10초 후
자동 숨김 대상이 되도록 create_memo / update_memo 에 sync 로직 추가.
- _sync_task_state_with_content: - [x] 에 checked_at 없으면 현재 시각으로 기록,
- [ ] 또는 사라진 index 는 state 에서 정리
- scripts/backfill_memo_task_state.py: 배포 이전 기존 노트에 현재 시각 backfill
(docker compose exec fastapi python /app/scripts/backfill_memo_task_state.py --apply)
6720건 레거시 문서를 야간에 자동으로 tier triage + deep_summary 처리.
app/workers/tier_backfill.py (신규):
- APScheduler 30분 주기 트리거. KST 00:00~06:00 시간대만 실제 enqueue.
- safety > law > manual 우선순위 25건씩 classify 큐 재투입.
- classify 큐 40건 이상 쌓여있으면 MLX 부하 보호로 skip.
- drive_sync / memo / news 는 제외 (plan 스코프 밖 또는 가치 낮음).
- off-switch: settings.ai.tier_backfill.enabled = false 로 전면 중단 가능.
app/main.py lifespan:
- scheduler.add_job(tier_backfill_run, interval=30min, id='tier_backfill').
- AsyncIOScheduler 이미 timezone='Asia/Seoul' 로 설정돼 tier_backfill 내부의
zoneinfo('Asia/Seoul') 와 일치.
수치 예상: 야간 6시간 × 2회/시간 × 25건 = 150건/야간.
6720 / 150 = 약 45일이면 전체 레거시 소화.
MLX 부하 제어가 가장 강한 관심 — R2 backlog guard 와 중복 안전장치.
운영 중 과부하 감지 시: config.yaml 에 `ai.tier_backfill.enabled: false` 만
넣으면 즉시 정지 (재시작 없이 스케줄러가 매번 체크).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-B B-1 배포 이전에 classify 된 6770건 레거시 문서에 대해 ai_tldr /
ai_bullets / ai_detail_summary 등 tier 산출물을 채우기 위한 백필 도구.
사용:
docker exec hyungi_document_server-fastapi-1 \
python /app/scripts/backfill_tier.py --domain safety --limit 50 --dry-run
docker exec hyungi_document_server-fastapi-1 \
python /app/scripts/backfill_tier.py --domain safety --limit 50 --apply
도메인 필터: safety / law / manual / news / drive_sync / memo
ORDER BY created_at DESC 로 최신 우선. ON CONFLICT DO NOTHING 이라
기존 pending/processing 행 있으면 중복 enqueue 방지.
MLX 26B 단일 Semaphore 경로라 처리 속도 ~1건/분. 50건 ≈ 1시간.
대량 백필은 야간 분할 권장. 이번 세션 Industrial_Safety 50건이
첫 smoke 대상.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cherry-pick 시점에 main 이 PR-B B-2 (161_analyze_events_answerability +
162_analyze_events_answerability_idx) 까지 포함해 번호 충돌. fastapi
부팅 시 _parse_migration_files 가 "migration 버전 중복: 161_..." 로
RuntimeError. 163 로 재지정 (schema_migrations 의 기존 161/162 레코드는 그대로 유효).
체크박스 체크 후 10초 경과 항목을 대시보드 핀 메모 / /memos 에서
자동 숨김, 메모 푸터 "완료 N개 보기" 버튼으로 토글.
- migration 161: documents.memo_task_state JSONB — {"<idx>":{"checked_at":"ISO"}}
- PATCH /memos/{id}/tasks/{task_index} 전용 엔드포인트:
· SELECT FOR UPDATE 로 동시 토글 race 차단
· task_index drift 시 stale state 자동 정리 (400 대신 200)
· AI 재처리/큐 enqueue 의도적 스킵 + memo_task_toggle_skip_ai 로그
- renderMemoHtml(taskStates, now) → 경과 항목에 memo-task-hidden 클래스
- Svelte 5 $effect cleanup 으로 setInterval 누수 방지
체크박스 토글 같은 {content}-only PATCH 에서 body.title==None 을 무조건
_auto_title(content)로 재생성해 제목이 체크박스 라인으로 덮어씌워지는 버그.
Pydantic model_fields_set 으로 title 전송 여부를 구분해 PATCH semantics 정상화.
실측 발견 (safety 8건 재분류):
- 10574 KRAS (safety_operational) → escalate=true (guard 전 pass)
- 10568 JSA (safety_operational) → escalate=false suppressed=True
- 10570 PPE (safety_operational) → escalate=false suppressed=True
- 동일 도메인인데 4건 중 1건만 26B 처리. 같은 질의 종류 문서가
누구는 깊이 있고 누구는 짧음 → 사용자 관점 일관성 붕괴.
원인: risk_flag_requires_26b 가 soft escalate 분류 → R2 backlog guard
의 ratio 임계치(0.3) 에 걸림. 방금 classify 8건 enqueue 중 앞선 건들이
deep_summary 큐 채우자 뒤 건들이 전부 suppress.
수정: HARD_ESCALATE_REASONS 에 risk_flag_requires_26b 추가. safety/
health/chemical 등 도메인 정책 기반 escalate 는 절대 억제하지 않음.
soft 영역은 여전히 남아있음: self_declare (4B 자가선언), deep_requested
(recommend_deep_summary). 이 둘만 backlog guard 가 억제 대상.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
실측 버그 (doc 10573 산업안전보건법 deep 처리):
- 26B MLX 응답 길이 1131자 (8192 token 한도 미도달) 에서 응답이
\`entities_confirmed\` 섹션 중간에 잘림.
- parse_json_response 의 regex \`{[^{}]*(?:{[^{}]*}[^{}]*)*}\` 가 1단계
중첩까지만 매칭 + reversed 순회로 "가장 마지막 valid JSON" 우선 반환.
- 결과적으로 entities_confirmed 내부 객체 (\`{"people":[],"orgs":[],...}\`)
가 파싱돼 detail/tldr/bullets 전부 손실 → ai_detail_summary 빈값.
수정: deep_summary_worker 에 \`_parse_outermost_json\` helper 추가.
brace balance + 문자열 리터럴 인식으로 첫 '{' 부터 최외곽 '}' 까지 추출.
응답이 잘려 closure 없으면 남은 depth 만큼 '}' 보강 후 재시도 (partial
응답도 최대한 복구). parse_json_response 는 fallback.
이 수정 후 doc 10573 재처리 smoke 필요. entities_confirmed 필드는 정보창
UI 에 안 쓰므로 응답에서 제거하는 프롬프트 조정은 다음 라운드.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
실측 발견 (safety md 8건 tier triage 결과):
1. **분류 오분류**: 본문에 "MSDS" 한 번 스쳐도 msds 도메인 매칭됨.
개인보호구/중대재해/밀폐공간/산업안전보건법 전부 msds 로 잘못 판정.
2. **RoutingDecision 무시**: PR-A domain_policy 의 high_impact=true 와
risk_flag_requires_26b 때문에 RoutingDecision.escalate_to_26b=True 이지만
내 _classify_escalation_reason 이 이걸 안 봐서 escalate=False 로 마감.
safety/msds/hazard_specific 전부 4B 만 돌고 26B 정책 우회.
수정:
- _match_subject_domain: (a) title 기반 매칭 우선 추가 — 파일명이 의도의
1차 시그널. (b) 본문 키워드는 **2회 이상 등장**해야 match (single-mention
오분류 방지). 우선순위도 재배열 (msds 맨 앞 → hazard/safety 뒤로).
- _classify_escalation_reason: routing_decision 파라미터 추가. 4B 자체
판정 (long_context / low_confidence / self_declare / deep_requested)
이후 PR-A routing_decision.escalate_to_26b 가 True 이면 그 escalation_reasons
중 "high_impact" 외의 구체 사유(risk_flag_requires_26b 등) 를 채택.
- _run_tier_triage: routing_decision 을 먼저 계산하여 _classify_escalation_reason
에 전달. _apply_triage_result 는 routing_decision 을 param 으로 받음
(중복 계산 제거).
이 변경 후 safety/msds/hazard_specific/incident_report 도메인 문서는 항상
26B escalate → deep_summary 큐. MLX 부하 증가하지만 plan 의도대로 정책 준수.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
doc 5260 (confidence 0.3 low_confidence 에스컬레이션) 실측에서 발견:
EscalationEnvelope(from_stage='summary_triage') 가 PR-A ValidFromStage
({triage, summarize_short, advice_trigger, classify, night_sweep, ask_pre,
unknown}) 에 없어 ValueError 발생 → 모든 deep_summary enqueue 가 envelope
생성 단계에서 터짐. tldr/bullets 기록은 envelope 실패 전에 완료되어 영향
없음 (try/except 가 classify 전체는 보호).
P3a short summary 에서의 에스컬레이션 의미에 맞춰 'summarize_short' 로 변경.
내부 task 이름 (SUMMARY_TRIAGE_TASK = 'p3a_short_summary') 는 analyze_events.
prompt_version 기록 전용이라 그대로 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 본래 의도: 근거 선별은 4B, 합성은 26B.
- evidence_service: LLM 호출을 primary(26B MLX) → triage(4B Ollama) 로 전환.
Ollama concurrent 가능하므로 get_mlx_gate() 제거. synthesis 는 여전히
llm_gate Semaphore(1) 경유로 MLX 보호.
- prompt_version v3-evidence-triage bump (synthesis 프롬프트 자체는 v2-600char
그대로, evidence LLM 경로 변경을 분리 추적).
- migrations 161/162: analyze_events 에 answerability / partial_basis /
suggested_query_count 컬럼 + partial index. /ask 는 이미 ask_events 에
completeness (full/partial/insufficient) 기록 운영 중이므로, analyze_events
쪽은 향후 문서 분석에서 answerability 개념 도입 시 활용 예비.
- telemetry record_analyze_event 에 answerability / partial_basis /
suggested_query_count 파라미터 확장.
기존 /ask 3-state completeness 로직 (classifier_service + 7-tier gate) 은
그대로 유지 — 이미 Phase 3.5a 에서 완성된 상태. B-2 는 LLM 부하 재분배와
관측성 확장에 집중.
MLX 부하 감소 효과: 이전엔 쿼리 1건당 evidence(26B) + synthesis(26B) 2번
MLX 호출. 이제는 evidence(4B Ollama) + synthesis(26B MLX) 로 MLX 호출 절반.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docker-compose.yml stt-service 를 profiles:[legacy] 로 이동. GPU 의
stt-service 는 더 이상 기동하지 않고, fastapi STT_ENDPOINT 가
Mac mini (기본 100.76.254.116:8804 Tailscale, MAC_MINI_HOST env 로
LAN IP 주입) 를 바라보도록 변경. 복원 필요 시
`docker compose --profile legacy up -d stt-service`.
- config.yaml: classifier 섹션을 gemma4:e4b-it-q8_0 으로 복원. 이전
B-0 커밋이 classifier 를 주석 처리했는데, 실제로는 classifier_service
가 쓰고 있어 gate 유효. exaone 은 이미 제거됐으니 모델만 gemma4 로
통일. classifier_service 의 hasattr 체크는 유지되어 fallback 안전.
D13 (STT 이전) drift 를 main 으로 승격. inventory 갱신은 B-3 마감
단계에서 3-tier + STT 경로 묶어서 일괄.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- config.yaml: ai.models 에 triage (gemma4:e4b-it-q8_0, GPU Ollama,
context_char_limit=120k, timeout 30s) 신규. primary (MLX gemma-4-26b)
는 에스컬레이션 전용 역할 명시. fallback 을 gemma4:e4b 로 통일
(exaone 제거 이미 반영). classifier/verifier 는 optional 유지,
vision 은 optional 로 완화 (미사용 정리 준비).
- core/config.py: AIConfig 에 triage 필드 추가, vision 은 Optional 로
전환. AIModelConfig.context_char_limit + DeepSummaryBacklogConfig
(R2 backlog guard 임계치 ratio 0.3 / pending 5 / window 30min)
스키마 신설. load_settings 가 models.get("vision") graceful.
- ai/client.py: call_triage / call_primary / call_fallback 3-tier
진입점 신규. primary 는 caller 가 get_mlx_gate() 블록 안에서 호출
해야 한다는 계약 docstring. classify/summarize 는 DEPRECATED 주석
만 추가, 기존 호출부 (eval runner 등) 를 위해 유지.
PR-B B-0 Day 1. 기존 primary 경로 변경 없음 — 회귀 0 기대.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
배포 검증 중 발견: domain_policy.yaml 이 repo root 에 있지만 fastapi
컨테이너의 build context 는 ./app 이라 COPY 가 포함하지 못함. 결과
load_policy() 가 FileNotFoundError.
1. docker-compose.yml: config.yaml 과 동일 패턴으로 읽기전용 bind mount
- ./domain_policy.yaml:/app/domain_policy.yaml:ro
2. app/policy/loader.py: _resolve_path 에 4 개 후보 검색 추가 —
cwd / /app / /app/.. / <this>.parent.parent.parent 순으로 파일 존재
확인. 첫 매칭 반환. 로컬/컨테이너/다른 배포 환경 모두 호환.
CI: pytest tests/policy/ -q → 98 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
프로덕션 컨테이너는 /app 을 cwd 로 실행하고 import 는 `from api...`,
`from core...`, `from workers...` 처럼 무접두 스타일을 사용한다.
PR-A 내부 import 가 `from app.policy...`, `from app.ai.envelope` 로
되어 있어서 컨테이너에서 ModuleNotFoundError 발생.
변경:
- app/policy/*.py: `from app.policy.X` → `from policy.X`
- app/services/prompt_versions.py: lazy import 도 `from policy.prompt_render`
- app/ai/envelope.py: 영향 없음 (내부 import 없음)
- tests/policy/*.py: 모두 `from policy.X` / `from ai.envelope` 로 통일
- tests/policy/conftest.py: 로컬 pytest 용 sys.path.insert(app/) 추가
(MacBook 에서 repo-root 기준 실행 시 app/ 를 package root 로 취급)
CI: pytest tests/policy/ -q → 98 passed (로컬, 동일 결과)
프로덕션: docker exec fastapi python -c "from policy.loader import load_policy" → OK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
asyncpg exec_driver_sql 이 prepared statement 로 multiple commands 를
허용하지 않아 배포 시 PostgresSyntaxError: cannot insert multiple commands
into a prepared statement 로 init_db() 실패.
153 를 단일 ALTER TABLE (10 ADD COLUMN) 로 축소하고 2 partial index 를
154/155 로 분리:
- 153_analyze_events_shadow.sql: ALTER TABLE ADD COLUMN (단일 statement)
- 154_analyze_events_shadow_idx_ts.sql: idx_analyze_events_shadow_ts
- 155_analyze_events_policy_violation_idx.sql: idx_analyze_events_policy_violation
배포 test: GPU fastapi 컨테이너 재빌드 후 init_db 가 153/154/155 세 파일을
순차 적용 (asyncpg prepared statement 1 파일 1 문).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ALTER TABLE analyze_events ADD COLUMN IF NOT EXISTS 로 10개 shadow 컬럼:
subject_domain, risk_flags[], high_impact_task, escalated_to_26b,
escalation_reasons[], confidence, policy_violation, policy_violation_ids[],
shadow_would_route_to, policy_version.
+ 2 partial index:
- idx_analyze_events_shadow_ts (shadow_would_route_to IS NOT NULL)
- idx_analyze_events_policy_violation (policy_violation=true)
전부 nullable, 기본값 NULL. 아무도 쓰지 않음 — PR-B 의 DBShadowLogger 가
writer 추가 예정.
번호 153: 152 는 `feat(category): law` 가 점유 (e88640d).
BEGIN/COMMIT 없음 (CLAUDE.md: _run_migrations 단일 outer 트랜잭션).
answerability / new_facts_count 는 PR-B 의 migration 154+ 가 소유.
plan: ~/.claude/plans/wise-gliding-hippo.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>