[HAN-612] Pantheon 이미지 첨부 인식 갭 — 부검 보고서
TL;DR
- 사용자가 어제 재배포 후 8/8 passed로 HAN-489 Done 처리했는데, 오늘 같은 사고("봇이 이미지 못 봄")가 다시 나왔다.
- 같은 사고로 보이지만 같은 사고가 아니다. HAN-489 Done의 실체는 pytest only였고, real Slack 첨부 end-to-end는 검증 갭이었다.
- 진짜 root cause는
_looks_like_imagemagic byte 검증이 strict gate로 동작해서, 한 번의 silent drop이 곧 "Gemini 자동 swap 분기도 발동 안 함"으로 이어진 것이다. - 처방은 HAN-612 (Priority High, Backlog). vision은 한 곳에서만 처리하고 결과를 텍스트로 모든 페르소나 prompt에 inject — 페르소나 정체성 swap 문제도 같이 닫힌다.
- 학습 1: "Done"의 정의를 pytest 통과에서 분리하고, real end-to-end를 별도 AC로 강제.
- 학습 2: Slack 페르소나 톤이 메인 권한을 self-cap 시키지 않도록, "랩업" 키워드 =
/wrap-up스킬 호출 강제.
Context — 타임라인
| 시각 | 사건 | 상태 |
|---|---|---|
| 2026-06-07 17:38 KST | 사용자가 #pantheon-rd 스레드에 이미지 첨부 → jini가 못 봄 → #problem-report에 jini → Jarvis 우회 보고 |
첫 신호 |
| 2026-06-07 17:51-18:34 | wansu가 plan 작성 → HAN-489 발의 → PR #237 구현 → 446 passed, 2 skipped |
"구현 완료" |
| 2026-06-08 17:14-17:16 | 재배포 후 HAN-489 image attachment 8/8 passed, 전체 suite 478 passed, regression 없음 → Linear HAN-489 Done | (pseudo) Done |
| 2026-06-08 21:35 KST | jini 봇이 사용자 첨부 PNG 수신 → [slack_image] decode_failed file_id=F0B8KCA1HV5 name=image.png mime=image/png size=57414 → image_attachments=[] → Claude가 텍스트만 응답 |
진짜 첫 실패 |
| 2026-06-09 | 사용자가 동일 스레드에서 "또 이미지 못 읽음" 신호 → jini가 첫 답으로 "Gemini swap이 의도된 동작" 가설 제시 → 사용자 "왜 Gemini가 등장?" 반문 | 진단 분리 시작 |
| 2026-06-09 | jini가 #problem-report 원 스레드 직접 fetch → "HAN-489 Done의 실체 = pytest only / end-to-end 갭" 정리 → HAN-612 발의 (Priority High, relatedTo HAN-489) |
처방 분기 |
진단 — 표면 vs 진짜
표면
"이미지 첨부했더니 봇이 못 봄. HAN-489 Done이라고 어제 들었는데 회귀 아닌가요?"
이 표면에서 Gemini가 등장하는 이유가 같이 의심됐다. 첫 답에서 bridge.py:1713의 자동 swap이 의도된 동작이라는 코드 설명까지 잘 짚었지만 — 그 자체는 사고의 본질이 아니었다.
진짜 root cause — 2개 레이어
Layer A — magic byte strict gate. bridge.py 안 _looks_like_image()는 PNG \x89PNG\r\n\x1a\n / JPEG \xff\xd8\xff / WebP RIFF magic byte를 bytewise 검증한다. 통과 못 하면 attachment를 attachments 리스트에 안 담는다. 어제 21:35 사용자가 첨부한 57KB PNG가 이 게이트를 통과 못 했다. 가능한 원인:
- (a) Slack
url_private_download응답이 PNG bytes가 아니라 HTML 로그인 페이지/redirect를 흘려보낸 케이스 — bot token 인증이 transient하게 실패했을 때 status 200 + body=HTML로 내려옴. - (b) Slack이 어떤 케이스에서 PNG header가 변조된 형태로 내려보낸 케이스.
Layer B — Gemini swap 분기가 attachments=[] 케이스에 발동 안 함. bridge.py:1713은 image_attachments and name != "gemini"에서만 자동 Gemini swap을 건다. Layer A에서 silent drop 되면 Gemini 라우팅 자체가 발동 안 한다. 결과적으로 페르소나(Jini = Claude sonnet)에 텍스트만 도달.
즉 사용자가 본 현상 "또 이미지 못 봄" = Layer A silent drop + Layer B 분기 우회의 합작이다.
HAN-489 "Done"의 함정
HAN-489 / PR #237은 fetch → MIME → magic byte → Gemini 입력 경로를 깔았다. 검증은 두 단계로 떨어졌다:
- pytest unit 8/8 + suite 478 passed: fixture 기반 — magic byte가 통과하도록 만들어진 PNG bytes만 통과. real Slack response를 안 봄.
- real Slack 첨부 end-to-end: 검증 대상에서 빠짐.
이 갭은 회귀가 아니다. 처음부터 닫힌 적이 없는 범위 갭이다. 그래서 HAN-489 Done이 잘못된 게 아니라 — Done의 정의가 jokingly 좁았다.
처방 — HAN-612 3 hop
HAN-612 (Priority High, Backlog, relatedTo HAN-489).
Hop 1. providers/claude.py, providers/codex.py는 무수정. 현재의 del image_attachments 그대로 둔다. 페르소나 정체성 보존.
Hop 2. bridge.py:1713의 swap 분기를 caption prepend 분기로 교체:
_gemini_caption_image(image_attachment) -> strhelper 추가. Gemini Flash로 이미지 1-3문장 캡션.- 페르소나 라우팅은 그대로 — Jini는 Claude 응답, Wansu는 Codex 응답, Raphael은 Claude 응답.
- prompt 앞에
[봇이 본 이미지: "<caption>"]형태 inline 인용 블록을 prepend. 디버깅 가시성을 사용자에게 노출하면서도 페르소나 톤 오염은 최소.
Hop 3. _looks_like_image 게이트를 soft-fail로 완화:
- magic byte fail이면 reject 대신 caption 시도. Gemini가 알아서 거른다.
- 응답
Content-Type이text/html계열이면 Slack 인증 실패 케이스로 끊고 명시적 fallback 메시지.
검증 기준 (real end-to-end)
- Mac 캡처 PNG, iOS 캡처 PNG, JPEG, WebP — 4종 실제 첨부로 Jini / Wansu / Raphael / Nano 모든 페르소나가 이미지 내용을 텍스트로 인지/응답.
- 첨부 메시지 1건당 봇 응답에
[봇이 본 이미지: …]라인 1개 노출 (디버깅 트레이스). - regression: 이미지 없는 메시지 응답에 caption prepend 없음, suite 478+ 통과 유지.
부수 효과
페르소나 정체성 swap이 같이 닫힌다. "이미지 첨부하면 왜 Gemini가 등장?" 사용자 질문 자체가 사라진다.
학습 — 두 가지
학습 1 — "Done"의 정의는 pytest와 분리되어야 한다
pytest 통과 ≠ end-to-end 검증. HAN-489는 이 패턴의 표본 사례다.
- 전조: 어제 17:16의 "8/8 passed, regression 없음, Done" 표현. 여기 real Slack 첨부 검증이 빠졌다는 사실이 어디에도 안 드러나 있었다.
- 처방: AC에
real Slack end-to-end 1회 이상 통과항목을 명시적 분리해서 강제. fixture 통과와 real path 통과를 별개 AC로. - 기존 룰 강화: feedback_ac_verify_before_close에 이 패턴을 추가해 pytest only Done 패턴을 명시적으로 금지.
학습 2 — Slack 페르소나 톤이 메인 권한을 self-cap 시키지 말 것
오늘 사용자가 "이 스레드 랩업해주세요"라고 했을 때, jini는 Slack 메시지 정리만 했고 /wrap-up 스킬을 호출하지 않았다. 이 세션은 사실 메인 Claude Code + jini emulating이라 Write/Edit/Skill 권한이 다 있는데, "Slack mrkdwn 3-6줄"·"짧게" 톤 룰에 잠겨 스킬 호출 분기를 안 탔다.
- 패턴 정의: 페르소나 답변 톤 self-cap. 페르소나 톤이 답변 흐름을 좁히면서, 실제로 가능한 메인 세션 액션까지 잠겨버린다.
- 처방: Slack 페르소나 답변 흐름에서 "랩업/wrap-up" 키워드는
/wrap-up스킬 호출을 강제. 페르소나 답변 후에라도 명시적으로 한 번 트리거.
검증 질문 (사용자에게)
- HAN-612 처방의 caption inject 위치를 "user message 앞 inline 인용 블록"으로 잡는 게 맞을지 — 톤 오염을 최소화하려면 system note 쪽이지만, "봇이 뭘 봤는지" 디버깅 가시성을 위해 inline 인용이 본 사고의 본질과 맞아 보인다.
- 학습 2의 처방을 Pantheon 페르소나 시스템 프롬프트에 가드로 박을지, 아니면 메인 세션
/wrap-up스킬 본문에 "랩업 키워드 = 자동 트리거" 룰을 박을지.
링크
- Linear: HAN-612 (Priority High, Backlog, relatedTo HAN-489)
- 관련 PR: pantheon#237 (HAN-489)
- 원 사고 스레드: #problem-report 2026-06-07 17:42
- 본 부검의 분기 스레드: #northstar 2026-06-09