Commit Graph

356 Commits

Author SHA1 Message Date
Hyungi Ahn d81cbfed85 fix(study): offscreen buffer canvas + 데스크톱 trackpad pinch 차단
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) 에 적용.
2026-04-27 10:50:20 +09:00
Hyungi Ahn 38e916643d fix(study): RAF redraw throttle + autosave 비동기 + gesture document-level
여전히 발생하는 입력 누락 / 지우개 누르면 확대 재시도.

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 와 충돌 안 함.
2026-04-27 10:38:05 +09:00
Hyungi Ahn 1a560b5fde fix(study): 필기감 + 연속 stroke + 버튼 줌 차단 종합
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 (빠른 필기 샘플 간격 좁힘)
- 단일 점 (탭) 은 작은 원으로 폴백
2026-04-27 10:30:12 +09:00
Hyungi Ahn 20d4457a75 fix(study): user-select / long-press 메뉴 차단
증상 (사용자 사진 8856): 펜으로 쓰는데 "복사하기 / Google 으로 검색" 같은
iOS 텍스트 선택 메뉴가 뜸. Safari 가 펜 입력을 텍스트 선택으로 해석.

Fix:
- 캔버스 + 컨테이너 + 페이지 root 에 user-select / -webkit-user-select /
  -webkit-touch-callout / -webkit-tap-highlight-color 적용
- canvas 에 oncontextmenu preventDefault — long-press 후 메뉴 차단
2026-04-27 10:23:01 +09:00
Hyungi Ahn aad21f4daa fix(study): blockGesture TS 어노테이션 제거 (plain JS 페이지) 2026-04-27 10:20:28 +09:00
Hyungi Ahn 1a8667bcec fix(study): iOS Safari 핀치줌 차단 (페이지 줌 발생 방지)
증상 (사용자 사진 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
2026-04-27 10:19:42 +09:00
Hyungi Ahn 6d8d56e7cb fix(study): 캔버스 디버그 오버레이 제거 (좌표 표시 거슬림) 2026-04-27 10:15:58 +09:00
Hyungi Ahn 3c41a4cab1 fix(study): Notability 수준 필기감 + 연속 stroke race 방어
필기감:
- 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 로 해결.
2026-04-27 10:12:18 +09:00
Hyungi Ahn df81cd033a fix(study): Pencil 만 인식 + 더블탭 줌 차단
- isPenLike: 'touch' 제거. pen/mouse 만 허용 → 손가락 stroke/지우개 차단
- 페이지/툴바 영역에 touch-action: manipulation → 버튼 빠른 두 번 탭 시
  iOS Safari 더블탭 줌 차단. 지우개/펜 토글 시 화면 확대되던 현상 fix.
2026-04-27 10:00:34 +09:00
Hyungi Ahn 66c6fb6189 fix(study): stroke 사라짐 핵심 버그 + 디버그 표식 제거
원인: \$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) 복귀.
2026-04-27 09:56:37 +09:00
Hyungi Ahn cf7c82141b fix(study): debug — 좌상단 빨간 사각형 + 빨간 굵은 stroke 강제
stroke 가 안 보이는 원인 격리. iPad 화면에서:
- 좌상단 빨간 50x50 사각형 보임 + 빨간 stroke 보임 → 토큰 색 문제
- 사각형 보임 + stroke 안 보임 → drawStroke / strokeStyle 문제
- 사각형도 안 보임 → redraw 미호출 또는 canvas 자체 가려짐
2026-04-27 09:50:24 +09:00
Hyungi Ahn 85659ce928 fix(study): perfect-freehand 미사용으로 단순 ctx.stroke() 전환 + 좌표 scale 보정
증상: 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 가림 등) 추적.
2026-04-27 09:00:39 +09:00
Hyungi Ahn 77790d6dc1 fix(study): 캔버스 풀스크린 + 좌측 floating panel + 좌표 디버그
증상: 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 방어)
2026-04-27 08:50:39 +09:00
Hyungi Ahn df9da33acb fix(study): stroke 렌더링 + 부분 지우개 모드
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 버튼으로 분리
2026-04-27 08:43:59 +09:00
Hyungi Ahn a4f470effb fix(study): canvas stroke 색을 --text 토큰으로 + simulatePressure true
문제: dark mode 에서 stroke #111 이 --bg #0f1117 와 거의 같아 안 보임 +
      Apple Pencil pressure 0 케이스 방어 부재.

수정:
- strokeColor 를 마운트 시 --text 토큰 실측 (e4e4e7 등) 으로 갱신
- simulatePressure true 로 변경 — 압력 0 으로 들어와도 속도 기반으로 굵기 보장
- thinning 0.55 → 0.5
2026-04-27 08:38:10 +09:00
Hyungi Ahn 475a542ea3 feat(study): iPad 손글씨 학습 세션 frontend (Phase 1)
PR-2: 자격증/어학 학습 세션 UI. iPad Safari + Apple Pencil 지원.

신규 컴포넌트:
- HandwriteCanvas — perfect-freehand + PointerEvents (압력/tilt) +
  palm rejection (pointerType==='pen') + DPR + touch-action:none +
  stroke 단위 undo/redo + 5초 idle / 5 stroke 자동 저장 +
  localStorage 백업 + PNG snapshot export
- StudyMetaEditor — study_type(certification/language) 토글, 자격증/어학
  분기 메타 입력, 어학 metadata.reading/meaning/unit_type
- SourceTextPanel — 원문 + 어학 메타 read-only 표시
- AssetList — 연결된 audio/video/scan/handwriting 표시 + 재생 + 연결/해제

라우트:
- /study → /study/write 리다이렉트
- /study/write — 도메인 토글 + 빠른 시작 폼 + 세션 목록
- /study/write/[id] — 좌측 메타/원문/asset, 우측 캔버스 (md+ 분할,
  모바일 위/아래)

Layout/Sidebar:
- 상단 nav 에 "공부" 추가 (메모와 뉴스 사이)
- Sidebar 메모/Inbox 섹션에 GraduationCap 아이콘 항목 추가

기타:
- frontend/package.json: perfect-freehand ^1.2.3 (MIT)
- THIRD_PARTY_LICENSES.md 신규 — perfect-freehand MIT 고지

플랜: ~/.claude/plans/scalable-chasing-stonebraker.md (PR-2)
신규 파일 lint:tokens 회귀 0 (기존 잔존 130 그대로).
2026-04-27 08:30:28 +09:00
Hyungi Ahn e8c348ab21 feat(dashboard): Day 4 튜닝 — 임계치 재조정 + deep_summary 안정성 카드
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>
2026-04-27 08:29:53 +09:00
Hyungi Ahn 95bcdb851b fix(ops): backfill 쿼리에 빈 extracted_text 제외 — 무한 retry 방지
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>
2026-04-27 08:25:12 +09:00
Hyungi Ahn 10364fbe1b fix(study): refresh assets attr on create to avoid async lazy load 2026-04-27 08:20:29 +09:00
Hyungi Ahn 2df7b24ac9 fix(study): split migration 164 into 10 single-statement files (asyncpg)
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 실행
2026-04-27 08:18:40 +09:00
Hyungi Ahn 7804f22dce feat(study): study_sessions backend (Phase 1) — 자격증/어학 일반 학습 세션 + assets 연결
iPad 손글씨 필사 / 모바일 암기노트 / 모바일 퀴즈가 같은 데이터를 공유하는
일반 학습 세션 backend. study_type 으로 certification/language 분기.

- migrations/164: study_sessions + study_session_assets DDL + 5 partial indexes
- app/models/study_session.py: StudySession + StudySessionAsset ORM (cascade)
- app/api/study_sessions.py: CRUD + snapshot(PNG) + assets + filter + groups
  - ownership: 모든 endpoint user_id 검증, mismatch 도 404 (정보 누설 방지)
  - 409 중복: UNIQUE(session, document, asset_type, role) 사전 SELECT + IntegrityError 폴백
  - enum 422: study_type / mode / asset_type / role / review_state / order
  - filter: 11개 (study_type, certification, language_code, learning_level,
    subject, topic, review_state, document_id, asset_type, mode, due_before)
  - groups: certification 트리 + language 트리 + has_audio/has_video
  - snapshot: documents.py atomic rename + error_code 패턴 차용
- app/main.py: /api/study-sessions router 등록

plan: ~/.claude/plans/scalable-chasing-stonebraker.md
Phase 1 미사용 필드 (review_state/quiz/ocr/ai_summary/prompt) 는 NULL 허용,
자동 로직은 Phase 2~4 별도 PR 에서 활성.
2026-04-27 08:15:28 +09:00
Hyungi Ahn c6335c9a1e fix(classify): law_monitor skip 분기 복원 + tier_backfill law 제외
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>
2026-04-27 07:35:27 +09:00
Hyungi Ahn 8427ac886c feat(memo): sync content ↔ memo_task_state on create/update + backfill script
본문에 `- [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)
2026-04-24 15:40:18 +09:00
Hyungi Ahn a95294ff42 feat(ops): 야간 auto tier 백필 스케줄러 (PR-B 레거시 해소)
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>
2026-04-24 15:28:28 +09:00
Hyungi Ahn 814882a0fe feat(ops): tier triage 레거시 백필 스크립트
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>
2026-04-24 13:30:31 +09:00
Hyungi Ahn 320c02fe29 fix(memo): bump migration number 161 → 163 (collision with PR-B 161/162)
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 레코드는 그대로 유효).
2026-04-24 12:59:39 +09:00
Hyungi Ahn 9d344c87ea feat(memo): auto-hide completed tasks after 10s with toggle
체크박스 체크 후 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 누수 방지
2026-04-24 12:56:55 +09:00
Hyungi Ahn ebc37961e0 fix(memo): prevent title overwrite on checkbox patch
체크박스 토글 같은 {content}-only PATCH 에서 body.title==None 을 무조건
_auto_title(content)로 재생성해 제목이 체크박스 라인으로 덮어씌워지는 버그.
Pydantic model_fields_set 으로 title 전송 여부를 구분해 PATCH semantics 정상화.
2026-04-24 12:56:51 +09:00
Hyungi Ahn e2b32fe9b7 fix(ai): B-1 R2 risk_flag_requires_26b 를 hard escalate 로 승격
실측 발견 (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>
2026-04-24 12:33:12 +09:00
Hyungi Ahn 93a687a51d fix(ai): B-1 deep_summary 잘린 응답 field-level regex fallback
_parse_outermost_json 도 열린 문자열 중간에 응답 끊기면 실패.
실전 MLX 응답이 entities_confirmed 내부 문자열에서 종료되는 패턴이라
detail/tldr/bullets/inconsistencies 전부 손실되던 이슈.

_regex_extract_fields helper 추가: "key":"value" 쌍 개별 매칭으로
앞쪽 완결된 필드만이라도 건진다. detail 이 응답 앞부분에 있어 잘림
지점보다 앞이면 성공.

순서:
  1. _parse_outermost_json (brace balance)
  2. parse_json_response (기존 regex)
  3. _regex_extract_fields (field-level fallback)

entities_confirmed 제거 같은 프롬프트 수정은 PR-A 영역이라 건드리지
않고, PR-B 워커에서 방어. 근본 해결은 p3c_deep_summary 에서 불필요
필드 제거 또는 max_tokens 튜닝을 policy 소유자가 결정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:27:04 +09:00
Hyungi Ahn 154cb1c8bd fix(ai): B-1 deep_summary JSON parser 강건화 (최외곽 JSON 추출)
실측 버그 (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>
2026-04-24 11:25:01 +09:00
Hyungi Ahn 165b00f917 fix(ai): B-1 subject_domain 매칭 + RoutingDecision.escalate_to_26b 존중
실측 발견 (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>
2026-04-24 11:18:59 +09:00
Hyungi Ahn f872e4666f fix(ai): B-1 envelope.from_stage PR-A enum 값으로 정정
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>
2026-04-24 11:04:47 +09:00
Hyungi Ahn 04f9eb6582 feat(ui): B-3 정보창 tier 자동 표시 + 대시보드 3종 카드
정보창 (AnalysisPanel):
- doc prop 추가. doc.ai_tldr / ai_bullets / ai_detail_summary / ai_inconsistencies
  있으면 버튼 없이 자동 렌더 (Section A).
- tier 배지 (triage=흰 / deep=파랑) + tldr + bullets + detail 계층 카드.
- inconsistencies kind 별 아이콘: version_drift=Calendar / procedure_conflict=
  GitBranch / source_conflict=Quote / missing_basis=HelpCircle. warning 톤.
- 기존 "고급 분석" 버튼 (/documents/{id}/analyze 4층 응답) 은 Section B 로 유지.

AIClassificationEditor:
- 제목 옆 tier 배지 ("깊이" accent / "짧음" neutral) — ai_analysis_tier 값 기준.

대시보드 (B-3 3종 카드):
- "에스컬레이션 비율 (24h)": escalated_to_26b / triage_total. 20% 초과 적색,
  1% 미만 회색 (false negative 신호). reason 상위 4개 뱃지.
- "triage JSON 건강도 (24h)": error_code='triage_json_invalid' / triage_total.
  5% 초과 적색 (프롬프트/모델 이슈).
- "Backlog Suppression (24h)": suppressed_reason IS NOT NULL / triage_total.
  10% 초과 주황 (임계치 재조정 신호).

Backend:
- dashboard.py 에 TierHealthStack 모델 + analyze_events 24h 집계 쿼리.
- escalation_by_reason (unnest(escalation_reasons)) + escalation_by_domain
  (subject_domain) 서브 집계.

Frontend types:
- stores/system.ts DashboardSummary 에 tier_health 옵셔널 필드 추가.

UI 는 PR-A shadow 기간에도 tier_health.triage_total > 0 조건으로 조건부 표시 —
데이터가 없으면 카드 자체가 숨겨져 첫 삽입 시 UX 충격 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:38:53 +09:00
Hyungi Ahn 34f79f84f2 feat(search): B-2 evidence LLM → 4B triage 전환 + answerability 컬럼
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>
2026-04-24 10:33:32 +09:00
Hyungi Ahn 6fdc48e5b6 feat(ai): B-1 summary tier 분할 — triage(4B) + deep_summary(26B)
PR-A policy 레이어를 재사용하여 classify_worker 에 tier triage 경로를 추가.
Legacy ai_summary / ai_domain / ai_suggestion 은 유지 (회귀 0), tldr/bullets/
detail/inconsistencies 는 별도 필드로 분리.

Migrations (156~160):
- 156 documents: ai_tldr, ai_bullets, ai_detail_summary, ai_inconsistencies,
  ai_analysis_tier 5컬럼
- 157 process_stage 에 'deep_summary' ADD VALUE 단독 (Postgres 동일 트랜잭션
  제약 회피)
- 158 processing_queue.payload JSONB (envelope 전달)
- 159 analyze_events 에 tier + suppressed_reason
- 160 suppressed_reason partial index

Models/ORM:
- Document: 5컬럼 Mapped 추가
- ProcessingQueue: deep_summary enum 확장 + payload 필드, enqueue_stage 에
  payload 옵션
- AnalyzeEvent: PR-A shadow 6컬럼 + PR-B tier/suppressed_reason

Workers:
- classify_worker: 기존 legacy 경로 뒤에 _run_tier_triage 추가.
  - _match_subject_domain(doc, text): source_channel + 본문 keywords + ai_domain
    prefix 로 PR-A policy 의 subject_domain 이름 결정 (category 매칭 금지).
  - R1 TriageOutput pydantic + JSON 깨짐 fallback (triage_json_invalid).
  - R2 _check_backlog_guard(): 30분 window ratio > threshold OR pending 초과면
    soft escalate suppress. hard escalate 는 통과.
  - R3 _slice_text_ranges(): 260k 초과 시 head 120k + mid 20k + tail 120k 3조각.
  - escalate 시 EscalationEnvelope 구성 + {envelope, subject_domain} payload 로
    deep_summary enqueue.
- deep_summary_worker (신규): queue payload 에서 envelope + subject_domain 읽기 →
  render_26b("p3c_deep_summary", subject_domain) + MLX 호출 (llm_gate Semaphore(1)
  경유) → ai_detail_summary + ai_inconsistencies 저장 + ai_analysis_tier='deep'.
  _filter_inconsistencies 로 허용 kind (version_drift / procedure_conflict /
  source_conflict / missing_basis) 만 통과 — 구매/계약 kind drop.
- queue_consumer: workers dict 에 deep_summary 추가 + BATCH_SIZE=1. next_stages
  는 건드리지 않음 — classify → embed/chunk 는 그대로, deep_summary 는 독립 체인.

Telemetry:
- record_analyze_event: subject_domain / risk_flags / escalation_reasons /
  confidence / policy_version / shadow_would_route_to / tier / escalated_to_26b /
  suppressed_reason 파라미터 확장. classify/deep worker 가 mode="summary_triage"
  또는 "summary_deep" 로 기록.

API:
- DocumentResponse 에 ai_tldr / ai_bullets / ai_detail_summary /
  ai_inconsistencies / ai_analysis_tier 5필드 노출.

Prompts:
- classify.txt 에 DEPRECATED 주석만 추가 (파일 유지 — rollback 경로 보존).
- PR-A 의 app/prompts/policy/p3a_short_summary.txt (4B) 와 p3c_deep_summary.txt
  (26B) 를 그대로 사용. 내 소유의 summary_triage.txt / summary_deep.txt 는 중복
  이라 별도 커밋에서 제거하지 않고 바로 생성 전 삭제.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:22:40 +09:00
Hyungi Ahn 18d684b501 ops(infra): STT Mac mini 이전 + classifier 섹션 복원 (gemma4:e4b)
- 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>
2026-04-24 10:08:00 +09:00
Hyungi Ahn 490bef1136 feat(ai): B-0 3-tier routing — triage/primary/fallback 슬롯 + AIClient
- 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>
2026-04-24 10:05:24 +09:00
Hyungi Ahn 628d886cba fix(policy): mount domain_policy.yaml into fastapi + multi-path loader
배포 검증 중 발견: 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>
2026-04-24 09:45:10 +09:00
Hyungi Ahn 99672292d3 fix(policy): use container-compatible imports (drop app. prefix)
프로덕션 컨테이너는 /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>
2026-04-24 09:42:24 +09:00
Hyungi Ahn c9e8dd0ba1 fix(db): split migration 153 for asyncpg single-statement limit
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>
2026-04-24 09:38:40 +09:00
Hyungi Ahn ba97766d45 feat(policy): INV-1~6 테스트 + loader/audit/envelope/shadow 검증
tests/policy/ 7개 테스트 파일 + conftest + __init__. 98 tests passed.

커버:
- test_policy_loader_schema.py (9) — yaml 로드, cross-reference,
  unknown flag reject, invalid UI category reject, synthesis_directive
  500 chars 초과 reject
- test_self_declare_add_only.py (4) — INV-1 invariant 엄격 검증
- test_routing_decisions.py (27) — INV-2~6 + low_confidence +
  도메인 × 시나리오 parametrize (9 도메인 x 기본 시나리오)
- test_audit_patterns.py (11) — detection_patterns 양성/음성,
  도메인 미스매치, 빈 텍스트 엣지
- test_envelope_contract.py (6) — JSON round-trip, invalid
  from_stage reject, tuple 강제
- test_prompt_render.py (16) — 모든 템플릿 렌더, placeholder 치환,
  policy_version deterministic/yaml-sensitive hash
- test_shadow_logger_inmem.py (5) — record/clear/multiple/extra/
  Protocol 호환

conftest.py: autouse _clear_policy_cache fixture — lru_cache 로 인한
테스트 간 오염 방지. policy fixture 는 repo root domain_policy.yaml 로드.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:49 +09:00
Hyungi Ahn 301867d0ee feat(db): migration 153 — analyze_events shadow 컬럼
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>
2026-04-24 09:34:49 +09:00
Hyungi Ahn 23b8a555c2 feat(prompts): policy templates (p1~p6, 9 files)
app/prompts/policy/*.txt — 4B/26B 정책 템플릿. {forbidden_block} /
{subject_description} / {confidence_threshold} / {context_cap} placeholder
포함. 금지 규칙 하드코딩 0 건.

4B (7): p1_triage, p2_nas_rule, p3a_short_summary, p3b_entities,
p4a_advice_trigger, p4b_retrieval, p6_night_sweep
26B (2): p3c_deep_summary, p4b_synthesis

각 템플릿 공통 구조:
- [System] 역할 선언 + subject_description
- forbidden_block (yaml 에서 도메인별 렌더)
- 작업 규칙
- 출력 형식 (JSON only, escalate_to_26b 포함)
- 에스컬레이션 기준
- [User] 실행시 치환 placeholder (이중 중괄호)

render 호출은 PR-A 에서 아무도 하지 않음 — 자산 배치만. PR-B escalation_service
가 실제 worker 에서 render.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn b9cc7f0ae1 feat(policy): shadow Protocol + InMemoryShadowLogger
ShadowLogger (runtime_checkable Protocol) — PR-B 가 DBShadowLogger 구현
시 준수해야 할 인터페이스. record_would_route(*, doc_id, decision,
actual_model_used, prompt_version, policy_version, extra=None) → None.

InMemoryShadowLogger — 테스트 전용 in-memory 구현. records/count/clear
inspection helpers. Protocol 호환 (isinstance 통과).

PR-B 책임: app/services/policy_shadow_writer.py::DBShadowLogger(ShadowLogger)
구현 — analyze_events 에 INSERT. DB write 실패 시 WARN 로그만, 본 파이프라인
중단 금지 (shadow 기간 제품 영향 0).

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn f51583f9d6 feat(policy): prompt_render + policy_version hash
app/policy/prompt_render.py:
- render_4b(task, subject) / render_26b(task, subject) — template + yaml
  excerpt 주입. {forbidden_block} / {subject_description} /
  {confidence_threshold} / {context_cap} placeholder 치환.
- policy_version(task) → sha256(yaml_bytes + template_bytes)[:12].
  deterministic — yaml 이나 template 이 바뀌면 hash 변경, analyze_events.
  policy_version 컬럼으로 drift 추적.
- KNOWN_4B_TASKS / KNOWN_26B_TASKS — 잘못된 task 호출 ValueError.
- 미정의 subject_domain 은 fallback_domain.description 사용.

app/services/prompt_versions.py:
- compute_policy_version(task) helper 추가. app.policy 지연 import 로
  worker 경로에 정책 dependency 유입 방지 (런타임 격리).
- 기존 ASK_PROMPT_VERSION / ANALYZE_PROMPT_VERSION 상수 미변경.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn 5057c48ad3 feat(policy): audit — forbidden pattern detection
check_4b_output_violations(text, subject_domain) → list[str]. Python re.search
기반 (Postgres regex 아님). forbidden_for_4b 에서 해당 subject 에 적용되는
rule 만 선택 후 detection_patterns 순회.

컴파일된 패턴 lru_cache 로 반복 호출 비용 감소. escalate_to_26b=False 인
event 에만 호출하여 policy_violation=true 기록 + under_escalation 재처리
후보로 포획.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn 3314b44918 feat(policy): decide_routing + INV-1~6 invariants
RoutingDecision frozen dataclass. 6 deterministic invariants (code-level HARD):

- INV-1 self_declare_add_only: deterministic=True & self=False → high_impact 유지
- INV-2 risk_flag_requires_26b: any flag.requires_26b=True → 강제 escalate
- INV-3 context_cap: content_chars > 120000 → long_context escalate
- INV-4 multi_doc: evidence_doc_count >= 3 → multi_doc escalate + multi_doc_dependency flag
- INV-5 risk_flags UNION merge: default + self_declared + derived 전부 합집합
- INV-6 fallback_domain: 미정의 subject → fallback_domain 적용 (routing None 방지)

reason 상수 노출 (REASON_HIGH_IMPACT / REASON_RISK_FLAG / REASON_LOW_CONFIDENCE /
REASON_LONG_CONTEXT / REASON_MULTI_DOC / REASON_FALLBACK_DOMAIN) — 테스트 +
PR-B escalation_service 재사용.

synthesis_directives 는 수집된 risk_flags 의 directive 만 자동 집계 (정렬 고정).

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn b401085518 feat(ai): EscalationEnvelope contract (4B→26B handoff)
frozen dataclass with from_stage / escalation_reasons / risk_flags /
distilled_context / original_pointers / synthesis_directives / user_intent /
draft_hint. JSON round-trip (to_json/from_json). to_system_injection() 으로
26B system prompt 에 주입할 텍스트 블록 생성 (risk_flags + directives +
distilled_context 순).

from_stage 는 whitelist 검증 (triage/classify/summarize_short/advice_trigger/
night_sweep/ask_pre/unknown). tuple 타입 강제 (mutability 방지).

PR-B 의 escalation_service 가 이 계약을 사용. PR-A 는 계약만 정의.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn d23ea48223 feat(policy): pydantic schema + yaml loader
app/policy/schema.py — DomainPolicy, SubjectDomain, FallbackDomain,
RiskFlag, ForbiddenRule, Escalation, Observability (pydantic v2, frozen).
suggested_ui_category 는 실측 doc_category enum (document|library|news|memo|
audio|video|law) 만 허용. synthesis_directive 500 chars 제한. cross-reference
validator — default_risk_flags 가 미정의 flag 참조 시 ValidationError.

app/policy/loader.py — load_policy(path) + functools.lru_cache.
env POLICY_PATH override, read_policy_bytes() helper (policy_version hash 용).

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00