feat(kebab-parse-image): P6-3 caption adapter — vision LM via trait #34

Merged
altair823 merged 3 commits from feat/p6-3-caption-adapter into main 2026-05-02 06:22:19 +00:00
Owner

요약

P6-3 — caption_image / apply_caption 어댑터. 비전 LM 으로 이미지에 한 문장 객관 설명 생성. &dyn LanguageModel 트레이트 위에서 동작 — Tesseract/Apple Vision/PaddleOCR 등 OCR 엔진 분리 (P6-2) 와 똑같이 "교환 가능한 어댑터" 패턴.

contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md §3.4 (ImageRefBlock.caption), §3.7a (ModelCaption), §9.1 (caption = model-generated / low trust).

Spec deviations — 2건

tasks/HOTFIXES.md 2026-05-02 항목.

  1. GenerateRequest.images: Vec<String> 추가 — spec p6-3 시그니처가 &dyn LanguageModel 인데 frozen p4-1 trait 에 vision 입력이 없었음. #[serde(default)] 로 pre-P6 wire 호환. kebab-llm-local::OllamaLanguageModelimages: [base64, ...] 와이어 필드로 라우팅, #[serde(skip_serializing_if = is_empty)] 로 텍스트 전용 경로 wire shape 가 pre-P6-3 와 byte-identical.
  2. Cargo feature caption 드롭 — runtime config.image.caption.enabled = false (default) 한 게이트로 통합. caption 모듈 추가 deps 가 base64 + image + kebab-llm trait 뿐이라 cargo feature 의 binary 절감 가치 미미.

동작 계약

  • caption_image(llm, bytes, lang_hint, cfg) -> Result<ModelCaption>:
    • cfg.image.caption.enabled = false → Err ("disabled").
    • 이미지 long-edge [128, 1536] 클램프 후 PNG 재인코딩 (PNG passthrough hot path 보존).
    • GenerateRequest { images: vec![b64], temperature: 0.0, seed: 0, max_tokens: 96, stop: [\"\\n\\n\"] } 으로 LM 호출.
    • 토큰 stream 수집 → ModelCaption.text = trim.
    • model_version = \"<provider>/<prompt_template_version>\" (예: \"ollama/caption-v1\") — prompt + 모델 회귀 감사 가능.
    • 빈 stream 도 정상 (caption.text="", caption.is_some() 으로 "시도했음" 신호).
  • apply_caption(llm, bytes, &mut block, lang_hint, cfg, &mut events):
    • enabled = false → 클린 no-op (Ok(()), 이벤트 미기록).
    • 성공 시 block.caption = Some(...) + ProvenanceKind::CaptionApplied 1건 (note 에 model + model_version).
    • LM 실패 시 block.caption = None 유지 + events 미기록 — 부분 상태 누출 차단.
  • 프롬프트:
    • lang_hint = \"ko\"/\"kor\" → 한국어 ("이미지를 한 문장으로 객관적으로 설명한다").
    • 그 외 / 없음 / \"und\" → 영어 ("Describe the image in one objective sentence").
    • 둘 다 "추측 금지 / 마크다운 금지 / 한 문장만 출력" 강제.

kebab-config

신규 ImageCfg.caption: CaptionCfg:

필드 기본값 비고
enabled false 자산당 모델 호출 비용 + 모델 생성 (low trust). opt-in.
max_pixels 768 long-edge cap (CaptionCfg 클램프 [128, 1536]).
prompt_template_version \"caption-v1\" ModelCaption.model_version 에 인코딩되어 회귀 감사 가능.

env: KEBAB_IMAGE_CAPTION_{ENABLED,MAX_PIXELS,PROMPT_TEMPLATE_VERSION}.

의존성 경계 (§8)

  • 추가 deps: kebab-llm (trait 재내보내기 — LanguageModel 만), base64, image. kebab-llm-local 은 dev-dep 에만 — 런타임에서 forbidden 위반 없음.
  • 미참조: kebab-source-fs / kebab-parse-md / kebab-normalize / kebab-chunk / kebab-store-* / kebab-embed* / kebab-search / kebab-rag / UI crate 모두.
  • HEIC / RAW 입력은 P6-1 로부터 동일 정책 (out of scope, image crate 미지원).

테스트

cargo test -p kebab-parse-image --test caption — 9건 통과 + 1건 #[ignore]:

  • feature gate disabled → apply_caption no-op / caption_image Err
  • happy path → block.caption Some + ProvenanceKind::CaptionApplied
  • 빈 토큰 stream → empty text, caption.is_some() 유지
  • CapturingMock 으로 req.images 라우팅 검증 (base64 1개, decode 가능)
  • 한국어 / 영어 프롬프트 분기 (CapturingMock 의 req.system 캡처)
  • LM Err → block.caption None 유지 + events 미기록
  • 결정성 (동일 mock 입력 → 동일 caption)
  • max_pixels = 99999 → 1536 클램프 (4000×3000 PNG 다운스케일 검증)
  • opt-in 통합: KEBAB_MODELS_LLM_ENDPOINT=http://192.168.0.47:11434 KEBAB_MODELS_LLM_MODEL=gemma4:e4b cargo test --test caption caption_integration -- --ignored — 실 Ollama 가 100×50 빨간 PNG 를 "The image is a solid red color." 로 캡션 (4.3초).

전체:

  • cargo test --workspace --no-fail-fast -j 1 pass (모든 crate).
  • cargo clippy --workspace --all-targets -- -D warnings pass.

Test plan

  • cargo test -p kebab-parse-image --test caption (9 + 1 ignored)
  • cargo test -p kebab-parse-image (P6-1/P6-2 회귀 — 31 + 1 ignored)
  • cargo test -p kebab-config (24 — caption defaults / env / pre-P6 호환)
  • cargo test -p kebab-llm-local (streaming / mock — images: vec![] 갱신)
  • cargo test -p kebab-rag (pipeline — images: Vec::new() 갱신)
  • cargo test --workspace --no-fail-fast -j 1
  • cargo clippy --workspace --all-targets -- -D warnings
  • 실 Ollama 통합 (192.168.0.47 / gemma4:e4b)
  • tasks/HOTFIXES.md 2026-05-02 P6-3 항목
  • tasks/p6/p6-3-caption-adapter.md status planned → completed

🤖 Generated with Claude Code

## 요약 P6-3 — `caption_image` / `apply_caption` 어댑터. 비전 LM 으로 이미지에 한 문장 객관 설명 생성. `&dyn LanguageModel` 트레이트 위에서 동작 — Tesseract/Apple Vision/PaddleOCR 등 OCR 엔진 분리 (P6-2) 와 똑같이 \"교환 가능한 어댑터\" 패턴. contract: [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](docs/superpowers/specs/2026-04-27-kebab-final-form-design.md) §3.4 (`ImageRefBlock.caption`), §3.7a (`ModelCaption`), §9.1 (caption = model-generated / low trust). ## Spec deviations — 2건 [`tasks/HOTFIXES.md`](tasks/HOTFIXES.md) 2026-05-02 항목. 1. **`GenerateRequest.images: Vec<String>` 추가** — spec p6-3 시그니처가 `&dyn LanguageModel` 인데 frozen p4-1 trait 에 vision 입력이 없었음. `#[serde(default)]` 로 pre-P6 wire 호환. `kebab-llm-local::OllamaLanguageModel` 가 `images: [base64, ...]` 와이어 필드로 라우팅, `#[serde(skip_serializing_if = is_empty)]` 로 텍스트 전용 경로 wire shape 가 pre-P6-3 와 byte-identical. 2. **Cargo feature `caption` 드롭** — runtime `config.image.caption.enabled = false` (default) 한 게이트로 통합. caption 모듈 추가 deps 가 `base64 + image + kebab-llm trait` 뿐이라 cargo feature 의 binary 절감 가치 미미. ## 동작 계약 - `caption_image(llm, bytes, lang_hint, cfg) -> Result<ModelCaption>`: - `cfg.image.caption.enabled = false` → Err (\"disabled\"). - 이미지 long-edge `[128, 1536]` 클램프 후 PNG 재인코딩 (PNG passthrough hot path 보존). - `GenerateRequest { images: vec![b64], temperature: 0.0, seed: 0, max_tokens: 96, stop: [\"\\n\\n\"] }` 으로 LM 호출. - 토큰 stream 수집 → `ModelCaption.text = trim`. - `model_version = \"<provider>/<prompt_template_version>\"` (예: `\"ollama/caption-v1\"`) — prompt + 모델 회귀 감사 가능. - 빈 stream 도 정상 (caption.text=\"\", caption.is_some() 으로 \"시도했음\" 신호). - `apply_caption(llm, bytes, &mut block, lang_hint, cfg, &mut events)`: - `enabled = false` → 클린 no-op (Ok(()), 이벤트 미기록). - 성공 시 `block.caption = Some(...)` + `ProvenanceKind::CaptionApplied` 1건 (note 에 model + model_version). - LM 실패 시 `block.caption = None` 유지 + events 미기록 — 부분 상태 누출 차단. - 프롬프트: - `lang_hint = \"ko\"/\"kor\"` → 한국어 (\"이미지를 한 문장으로 객관적으로 설명한다\"). - 그 외 / 없음 / `\"und\"` → 영어 (\"Describe the image in one objective sentence\"). - 둘 다 \"추측 금지 / 마크다운 금지 / 한 문장만 출력\" 강제. ## kebab-config 신규 `ImageCfg.caption: CaptionCfg`: | 필드 | 기본값 | 비고 | |---|---|---| | `enabled` | `false` | 자산당 모델 호출 비용 + 모델 생성 (low trust). opt-in. | | `max_pixels` | `768` | long-edge cap (CaptionCfg 클램프 [128, 1536]). | | `prompt_template_version` | `\"caption-v1\"` | `ModelCaption.model_version` 에 인코딩되어 회귀 감사 가능. | env: `KEBAB_IMAGE_CAPTION_{ENABLED,MAX_PIXELS,PROMPT_TEMPLATE_VERSION}`. ## 의존성 경계 (§8) - 추가 deps: `kebab-llm` (trait 재내보내기 — `LanguageModel` 만), `base64`, `image`. `kebab-llm-local` 은 dev-dep 에만 — 런타임에서 forbidden 위반 없음. - 미참조: `kebab-source-fs / kebab-parse-md / kebab-normalize / kebab-chunk / kebab-store-* / kebab-embed* / kebab-search / kebab-rag / UI crate` 모두. - HEIC / RAW 입력은 P6-1 로부터 동일 정책 (out of scope, image crate 미지원). ## 테스트 `cargo test -p kebab-parse-image --test caption` — 9건 통과 + 1건 `#[ignore]`: - feature gate disabled → `apply_caption` no-op / `caption_image` Err - happy path → `block.caption Some + ProvenanceKind::CaptionApplied` - 빈 토큰 stream → empty text, caption.is_some() 유지 - `CapturingMock` 으로 `req.images` 라우팅 검증 (base64 1개, decode 가능) - 한국어 / 영어 프롬프트 분기 (CapturingMock 의 `req.system` 캡처) - LM Err → `block.caption None` 유지 + events 미기록 - 결정성 (동일 mock 입력 → 동일 caption) - `max_pixels = 99999` → 1536 클램프 (4000×3000 PNG 다운스케일 검증) - opt-in 통합: `KEBAB_MODELS_LLM_ENDPOINT=http://192.168.0.47:11434 KEBAB_MODELS_LLM_MODEL=gemma4:e4b cargo test --test caption caption_integration -- --ignored` — 실 Ollama 가 100×50 빨간 PNG 를 \"The image is a solid red color.\" 로 캡션 (4.3초). 전체: - `cargo test --workspace --no-fail-fast -j 1` pass (모든 crate). - `cargo clippy --workspace --all-targets -- -D warnings` pass. ## Test plan - [x] cargo test -p kebab-parse-image --test caption (9 + 1 ignored) - [x] cargo test -p kebab-parse-image (P6-1/P6-2 회귀 — 31 + 1 ignored) - [x] cargo test -p kebab-config (24 — caption defaults / env / pre-P6 호환) - [x] cargo test -p kebab-llm-local (streaming / mock — `images: vec![]` 갱신) - [x] cargo test -p kebab-rag (pipeline — `images: Vec::new()` 갱신) - [x] cargo test --workspace --no-fail-fast -j 1 - [x] cargo clippy --workspace --all-targets -- -D warnings - [x] 실 Ollama 통합 (192.168.0.47 / gemma4:e4b) - [x] tasks/HOTFIXES.md 2026-05-02 P6-3 항목 - [x] tasks/p6/p6-3-caption-adapter.md status planned → completed 🤖 Generated with [Claude Code](https://claude.com/claude-code)
altair823 added 1 commit 2026-05-02 06:06:24 +00:00
- 신규 모듈 `crates/kebab-parse-image/src/caption.rs` 추가:
  • `caption_image(llm, bytes, lang_hint, cfg)` — `&dyn LanguageModel`
    위에서 동작. 비전 LM (예: gemma4:e4b) 이 한 문장 객관 설명
    출력. temperature=0 / seed=0 결정성.
  • `apply_caption(llm, bytes, block, lang_hint, cfg, events)` —
    `block.caption = Some(...)` 으로 채우고 ProvenanceKind::CaptionApplied
    이벤트 1건 추가. `image.caption.enabled = false` 면 클린 no-op
    (Ok(())). LM 실패 시 block.caption None 그대로 + events 미기록.
  • 다운스케일 long-edge `[128, 1536]` 클램프. PNG passthrough hot
    path 보존, 그 외는 단일 디코드 + PNG 재인코딩.
  • 한국어 / 영어 프롬프트 분기 (lang_hint=\"ko\"/\"kor\" → 한국어).
  • `ModelCaption.model_version = \"<provider>/<prompt_template_version>\"`
    (예: \"ollama/caption-v1\") — prompt 또는 모델 회귀 감사 가능.

## kebab-core / kebab-llm-local 변경

- `kebab_core::GenerateRequest` 에 `images: Vec<String>` 필드 추가.
  `#[serde(default)]` 으로 기존 wire 페이로드 / snapshot 호환.
- `kebab-llm-local::OllamaLanguageModel` 가 req.images 를 Ollama
  `images: [base64, ...]` 와이어 필드로 라우팅.
  `#[serde(skip_serializing_if = is_empty)]` 로 비어 있을 때 wire
  shape 가 pre-P6-3 와 byte-identical.

## kebab-config

- 신규 `ImageCfg.caption: CaptionCfg`:
  - `enabled: bool` (default false)
  - `max_pixels: u32` (default 768, 클램프 [128, 1536])
  - `prompt_template_version: String` (default \"caption-v1\")
- `KEBAB_IMAGE_CAPTION_{ENABLED,MAX_PIXELS,PROMPT_TEMPLATE_VERSION}`
  3종 환경변수 추가.

## Spec deviations

`tasks/HOTFIXES.md` 2026-05-02 항목 추가:
- Symptom 1: spec p6-3 시그니처가 `&dyn LanguageModel` 인데 frozen
  trait + GenerateRequest 가 vision 미지원. → trait 확장.
- Symptom 2: spec 의 cargo feature `caption` (default OFF at compile
  time) → runtime gate 1개로 통합. base64/image/kebab-llm 외 추가
  deps 없어 cargo feature 의 binary 절감 가치 미미.

p4-1 / p4-2 / p6-3 spec 의 amends 명시.

## 테스트

`cargo test -p kebab-parse-image --test caption` — 9건 + 1 ignored:
- feature gate (disabled → no-op / Err on direct call)
- happy path (block.caption Some + Provenance CaptionApplied)
- 빈 토큰 stream → empty text + caption.is_some()
- CapturingMock 으로 req.images 라우팅 검증 (base64 1개, decode 가능)
- 한국어 / 영어 프롬프트 분기 (CapturingMock 의 system 캡처)
- LM Err → block.caption None 유지 + events 미기록
- 결정성 (동일 mock 입력 → 동일 caption)
- max_pixels 클램프 (99999 → 1536, 4000×3000 PNG 다운스케일 검증)
- opt-in 통합 (실 192.168.0.47 Ollama / gemma4:e4b → \"The image is
  a solid red color.\" 검증 완료, 4.3초)

`cargo test --workspace --no-fail-fast -j 1` 전체 pass.
`cargo clippy --workspace --all-targets -- -D warnings` pass.

## 의존성 경계

- 추가 deps: `kebab-llm` (trait 만), `base64` (이미 P6-2 에서 추가).
- dev-deps: `kebab-llm/mock` 으로 `MockLanguageModel`,
  `kebab-llm-local` (통합 테스트 전용 — 런타임 deps 에는 없음).
- forbidden 침범 없음: `kebab-source-fs / parse-md / normalize /
  chunk / store-* / embed* / search / rag / UI` 미참조.

contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
sections: §3.4 ImageRefBlock.caption, §3.7a ModelCaption, §9.1
caption (model-generated, low trust).
claude-reviewer-01 requested changes 2026-05-02 06:08:33 +00:00
claude-reviewer-01 left a comment
Member

회차 1 — 큰 그림 견고합니다. LanguageModel trait 확장 (GenerateRequest.images) 으로 vision 라우팅을 한 곳에 박은 것, apply_caption 의 "실패 시 block.caption 미설정 + 이벤트 미기록" 부분 상태 누출 차단, runtime gate 단일화 (cargo feature 드롭) + HOTFIXES 기록, 한국어 / 영어 프롬프트 분기, CapturingMock 으로 req.images 라우팅 직접 검증한 테스트 — 모두 깔끔합니다. 실 Ollama 로 "The image is a solid red color." 캡션 검증까지 마친 것도 좋습니다.

머지 전에 정리하면 좋을 항목 5건:

  • Code duplicationdownscale_to_png (caption) 와 downscale_to_long_edge (ocr) 가 거의 동일 알고리즘. 공용 helper 모듈 (image_prep.rs) 로 추출 권장.
  • API 비대칭caption_image 는 disabled 시 Err, apply_caption 은 Ok(()) no-op. 책임 분리: caption_image 는 raw 연산 (gate 없음), apply_caption 만 gate 검사.
  • apply_caption 의 String clone 2회 — caption move 전에 note 포맷팅으로 0회 가능.
  • model_version deviation 미기록<provider>/<prompt_template_version> 확장이 spec literal 과 다르지만 HOTFIXES.md 에 한 줄 audit 미작성.
  • MIN/MAX 상수 가시성 — P6-2 MAX_DECODE_DIMpub const 인데 P6-3 caption 측 두 상수는 module-private. 일관성 권장.

칭찬 — kebab-llm-local 을 dev-dep 으로만 끌어들여 "runtime forbidden, test-time OK" 를 깔끔하게 분리한 점이 §8 경계를 영리하게 우회했습니다. P+ 에서 PDF / audio caption 어댑터가 같은 패턴을 따라가면 통합 테스트 매트릭스가 일관된 형태로 쌓입니다.

회차 1 — 큰 그림 견고합니다. `LanguageModel` trait 확장 (`GenerateRequest.images`) 으로 vision 라우팅을 한 곳에 박은 것, `apply_caption` 의 \"실패 시 block.caption 미설정 + 이벤트 미기록\" 부분 상태 누출 차단, runtime gate 단일화 (cargo feature 드롭) + HOTFIXES 기록, 한국어 / 영어 프롬프트 분기, `CapturingMock` 으로 `req.images` 라우팅 직접 검증한 테스트 — 모두 깔끔합니다. 실 Ollama 로 \"The image is a solid red color.\" 캡션 검증까지 마친 것도 좋습니다. 머지 전에 정리하면 좋을 항목 5건: - **Code duplication** — `downscale_to_png` (caption) 와 `downscale_to_long_edge` (ocr) 가 거의 동일 알고리즘. 공용 helper 모듈 (`image_prep.rs`) 로 추출 권장. - **API 비대칭** — `caption_image` 는 disabled 시 Err, `apply_caption` 은 Ok(()) no-op. 책임 분리: `caption_image` 는 raw 연산 (gate 없음), `apply_caption` 만 gate 검사. - **`apply_caption` 의 String clone 2회** — caption move 전에 note 포맷팅으로 0회 가능. - **`model_version` deviation 미기록** — `<provider>/<prompt_template_version>` 확장이 spec literal 과 다르지만 HOTFIXES.md 에 한 줄 audit 미작성. - **MIN/MAX 상수 가시성** — P6-2 `MAX_DECODE_DIM` 이 `pub const` 인데 P6-3 caption 측 두 상수는 module-private. 일관성 권장. 칭찬 — `kebab-llm-local` 을 dev-dep 으로만 끌어들여 \"runtime forbidden, test-time OK\" 를 깔끔하게 분리한 점이 §8 경계를 영리하게 우회했습니다. P+ 에서 PDF / audio caption 어댑터가 같은 패턴을 따라가면 통합 테스트 매트릭스가 일관된 형태로 쌓입니다.
@@ -0,0 +41,4 @@
/// `[256, 4096]` because vision LMs charge proportionally to input
/// dimension — captions tolerate aggressive downscale better than
/// OCR.
const MIN_CAPTION_LONG_EDGE: u32 = 128;

(작은 권장) MIN_CAPTION_LONG_EDGE / MAX_CAPTION_LONG_EDGEconst 인데 visibility 가 module-private 입니다. P6-2 의 MAX_DECODE_DIMpub const. 같은 crate 내 다른 모듈이 "caption 의 cap 이 1536 이라" 가정하고 싶을 때 (또는 외부 test/eval 이 정책을 검증할 때) 두 상수도 pub 으로 노출하는 게 일관됩니다.

pub const MIN_CAPTION_LONG_EDGE: u32 = 128;
pub const MAX_CAPTION_LONG_EDGE: u32 = 1536;

P6-2 의 MAX_DECODE_DIM 과 시각적 일관성. tests/caption.rs 의 99_999 → 1536 검증도 literal 1536 대신 상수 참조로 갱신할 수 있습니다.

(작은 권장) `MIN_CAPTION_LONG_EDGE` / `MAX_CAPTION_LONG_EDGE` 가 `const` 인데 visibility 가 module-private 입니다. P6-2 의 `MAX_DECODE_DIM` 은 `pub const`. 같은 crate 내 다른 모듈이 "caption 의 cap 이 1536 이라" 가정하고 싶을 때 (또는 외부 test/eval 이 정책을 검증할 때) 두 상수도 `pub` 으로 노출하는 게 일관됩니다. ```rust pub const MIN_CAPTION_LONG_EDGE: u32 = 128; pub const MAX_CAPTION_LONG_EDGE: u32 = 1536; ``` P6-2 의 `MAX_DECODE_DIM` 과 시각적 일관성. tests/caption.rs 의 `99_999 → 1536` 검증도 literal 1536 대신 상수 참조로 갱신할 수 있습니다.
@@ -0,0 +57,4 @@
/// Direct callers should prefer [`apply_caption`] for end-to-end
/// pipeline integration; this lower-level entry exists so tests can
/// pin the produced `ModelCaption` independent of block mutation.
pub fn caption_image(

caption_imageapply_caption 의 "disabled 처리" 가 비대칭입니다:

  • caption_image(...)enabled = falseErr ("disabled" 메시지).
  • apply_caption(...)enabled = falseOk(()) (no-op).

같은 config 상태에서 두 entry 가 다른 결과를 내는 건 호출자 입장에서 헷갈립니다. 함수의 책임 분리 관점에서 더 깔끔한 형태:

  • caption_image = "이미지를 캡션해 줘" 의 raw 연산. 항상 LM 호출, gate 검사 없음.
  • apply_caption = pipeline 진입점. config 게이트 + 결과 반영 + provenance.

이렇게 하면 disabled 인데 caption_image 를 직접 부르는 caller (현재로선 테스트뿐) 가 "왜 Err 반환?" 에 헷갈릴 일이 없고, doc-comment 의 "this lower-level entry exists so tests can pin the produced ModelCaption" 도 더 정직해집니다 — 테스트는 enabled flag 없이 caption 동작 자체를 검증할 수 있어야 자연스러움.

`caption_image` 와 `apply_caption` 의 "disabled 처리" 가 비대칭입니다: - `caption_image(...)` → `enabled = false` 면 **Err** ("disabled" 메시지). - `apply_caption(...)` → `enabled = false` 면 **Ok(())** (no-op). 같은 config 상태에서 두 entry 가 다른 결과를 내는 건 호출자 입장에서 헷갈립니다. 함수의 책임 분리 관점에서 더 깔끔한 형태: - `caption_image` = "이미지를 캡션해 줘" 의 raw 연산. **항상 LM 호출**, gate 검사 없음. - `apply_caption` = pipeline 진입점. config 게이트 + 결과 반영 + provenance. 이렇게 하면 disabled 인데 caption_image 를 직접 부르는 caller (현재로선 테스트뿐) 가 "왜 Err 반환?" 에 헷갈릴 일이 없고, doc-comment 의 "this lower-level entry exists so tests can pin the produced ModelCaption" 도 더 정직해집니다 — 테스트는 enabled flag 없이 caption 동작 자체를 검증할 수 있어야 자연스러움.
@@ -0,0 +135,4 @@
let model_ref = llm.model_ref();
let prompt_v = &cfg.image.caption.prompt_template_version;
let model_version = format!(

model_version = "<provider>/<prompt_template_version>" (예: "ollama/caption-v1") 는 spec 의 literal model_version: llm.model_ref().provider 와 다릅니다. spec 자체가 "if a vision model exposes a stable revision, prefer that" 로 유연하게 열어 둔 부분이라 합리적 deviation 이지만, HOTFIXES.md 의 P6-3 항목에서 이 쪽 결정은 명시되지 않았습니다.

둘 중 하나로 정리:

  1. (선호) HOTFIXES.md 의 P6-3 항목에 한 줄 추가 — "model_versionprovider 단독에서 <provider>/<prompt_template_version> 으로 확장. prompt 회귀와 모델 회귀를 별도 축으로 추적 가능."
  2. spec literal 로 후퇴 (provider 만), prompt_template_version 은 별도 필드 (예: provenance note) 에 박기.

현재 구현이 분명히 더 유용해서 (1번) 으로 가는 게 맞아 보이지만, 결정을 audit log 에 박아 두면 P+ 에서 다른 어댑터 (PaddleOCR / Apple Vision 등) 가 model_version 을 어떻게 채울지 컨벤션이 한 줄로 잡힙니다.

`model_version = "<provider>/<prompt_template_version>"` (예: `"ollama/caption-v1"`) 는 spec 의 literal `model_version: llm.model_ref().provider` 와 다릅니다. spec 자체가 "if a vision model exposes a stable revision, prefer that" 로 유연하게 열어 둔 부분이라 합리적 deviation 이지만, HOTFIXES.md 의 P6-3 항목에서 이 쪽 결정은 명시되지 않았습니다. 둘 중 하나로 정리: 1. (선호) HOTFIXES.md 의 P6-3 항목에 한 줄 추가 — "`model_version` 을 `provider` 단독에서 `<provider>/<prompt_template_version>` 으로 확장. prompt 회귀와 모델 회귀를 별도 축으로 추적 가능." 2. spec literal 로 후퇴 (`provider` 만), prompt_template_version 은 별도 필드 (예: provenance note) 에 박기. 현재 구현이 분명히 더 유용해서 (1번) 으로 가는 게 맞아 보이지만, 결정을 audit log 에 박아 두면 P+ 에서 다른 어댑터 (PaddleOCR / Apple Vision 등) 가 model_version 을 어떻게 채울지 컨벤션이 한 줄로 잡힙니다.
@@ -0,0 +181,4 @@
}
let caption = caption_image(llm, image_bytes, lang_hint, cfg)?;
let model_label = caption.model.clone();
let model_version_label = caption.model_version.clone();

caption.model.clone() + caption.model_version.clone() 두 번의 String 알로케이션이 발생합니다 (ProvenanceEvent.note 포맷팅 용). caption 을 block.caption 으로 move 하기 전에 note 를 먼저 빌드하면 clone 0회 가능:

let note = format!(
    "model={} model_version={}",
    caption.model, caption.model_version
);
block.caption = Some(caption);
events.push(ProvenanceEvent {
    at: OffsetDateTime::now_utc(),
    agent: "kb-parse-image".to_string(),
    kind: ProvenanceKind::CaptionApplied,
    note: Some(note),
});

사소하지만 hot ingest path (수천 장 이미지 캡션) 에선 미세 차이가 남습니다.

`caption.model.clone()` + `caption.model_version.clone()` 두 번의 String 알로케이션이 발생합니다 (ProvenanceEvent.note 포맷팅 용). caption 을 block.caption 으로 move 하기 전에 note 를 먼저 빌드하면 clone 0회 가능: ```rust let note = format!( "model={} model_version={}", caption.model, caption.model_version ); block.caption = Some(caption); events.push(ProvenanceEvent { at: OffsetDateTime::now_utc(), agent: "kb-parse-image".to_string(), kind: ProvenanceKind::CaptionApplied, note: Some(note), }); ``` 사소하지만 hot ingest path (수천 장 이미지 캡션) 에선 미세 차이가 남습니다.
@@ -0,0 +220,4 @@
/// PNG. Mirrors the OCR pipeline's pattern but with the caption-side
/// long-edge bounds. PNG sources within the cap pass through without
/// re-encode.
fn downscale_to_png(bytes: &[u8], max_long_edge: u32) -> Result<Vec<u8>> {

downscale_to_png (caption.rs) 와 P6-2 의 downscale_to_long_edge (ocr.rs) 가 거의 동일 알고리즘입니다 — 헤더 sniff → PNG passthrough hot path → 단일 디코드 → resize → PNG re-encode. 차이는 단 두 가지: (a) caption 은 (w, h) 를 버리고 Vec<u8> 만 반환, (b) 클램프 범위 상수 (caption: [128, 1536], OCR: [256, 4096]).

중복은 두 모듈 사이에 cross-module helper 를 만들 만합니다. 예: crates/kebab-parse-image/src/image_prep.rs 같은 private 모듈에:

pub(crate) fn downscale_to_png(
    bytes: &[u8],
    max_long_edge: u32,
) -> Result<(Vec<u8>, u32, u32)> { /* OCR 의 본체 그대로 */ }

그러면 ocr.rs 도 caption.rs 도 같은 함수를 호출하고, 1px 후행 클램프 / PNG passthrough / 에러 메시지 패턴이 한 곳에서 관리됩니다. 향후 PDF / video thumbnail 등 같은 다운스케일이 필요한 모듈이 합류해도 같은 helper 를 재사용 가능.

본 PR scope 가 P6-3 라 강제는 아니지���, 머지 전에 정리하면 P6-3 와 P6-2 에서 발견될 다운스케일 회귀 (예: 1px 클램프 미적용) 가 한 번에 해결됩니다.

`downscale_to_png` (caption.rs) 와 P6-2 의 `downscale_to_long_edge` (ocr.rs) 가 거의 동일 알고리즘입니다 — 헤더 sniff → PNG passthrough hot path → 단일 디코드 → resize → PNG re-encode. 차이는 단 두 가지: (a) caption 은 `(w, h)` 를 버리고 `Vec<u8>` 만 반환, (b) 클램프 범위 상수 (caption: [128, 1536], OCR: [256, 4096]). 중복은 두 모듈 사이에 cross-module helper 를 만들 만합니다. 예: `crates/kebab-parse-image/src/image_prep.rs` 같은 private 모듈에: ```rust pub(crate) fn downscale_to_png( bytes: &[u8], max_long_edge: u32, ) -> Result<(Vec<u8>, u32, u32)> { /* OCR 의 본체 그대로 */ } ``` 그러면 ocr.rs 도 caption.rs 도 같은 함수를 호출하고, 1px 후행 클램프 / PNG passthrough / 에러 메시지 패턴이 한 곳에서 관리됩니다. 향후 PDF / video thumbnail 등 같은 다운스케일이 필요한 모듈이 합류해도 같은 helper 를 재사용 가능. 본 PR scope 가 P6-3 라 강제는 아니지���, 머지 전에 정리하면 P6-3 와 P6-2 에서 발견될 다운스케일 회귀 (예: 1px 클램프 미적용) 가 한 번에 해결됩니다.
altair823 added 1 commit 2026-05-02 06:11:59 +00:00
- 새 모듈 `crates/kebab-parse-image/src/image_prep.rs` — OCR + caption
  + 향후 PDF/video 가 공유할 단일 다운스케일 헬퍼 (`downscale_to_png`)
  추출. 기존 ocr.rs / caption.rs 의 거의 동일 알고리즘 두 벌을 한
  곳으로 통합. 1px 후행 클램프 / PNG passthrough hot path / 에러
  메시지 패턴이 한 곳에서 관리됨.
- src/ocr.rs: `downscale_to_long_edge` 제거 → `image_prep::downscale_to_png`
  호출. `image::ImageReader / ImageFormat / Cursor` import 도 정리.
- src/caption.rs:
  • `caption_image` / `apply_caption` 의 disabled 처리 비대칭 해소.
    `caption_image` 는 raw 연산 (gate 없음), `apply_caption` 만
    `cfg.image.caption.enabled` 게이트 검사. 호출자가 같은 함수에서
    같은 의미를 얻음.
  • `apply_caption` 의 caption.model / model_version `String::clone`
    2회 → 0회. caption move 전에 ProvenanceEvent.note 를 먼저 빌드.
  • 다운스케일 로직 통째로 image_prep 위임.
  • `MIN_CAPTION_LONG_EDGE` / `MAX_CAPTION_LONG_EDGE` 를 `pub const`
    로 노출 (P6-2 의 `MAX_DECODE_DIM` 가시성 컨벤션과 일관).
- tests/caption.rs:
  • `caption_image_errors_when_feature_disabled` 를
    `caption_image_runs_regardless_of_enabled_flag` 로 교체 — 새
    책임 분리 의미 검증.
  • `caption_image_clamps_oversized_max_pixels` 가 literal 1536 대신
    `kebab_parse_image::caption::MAX_CAPTION_LONG_EDGE` 상수 참조.
- tasks/HOTFIXES.md: `model_version` 형태 deviation 한 단락 추가
  (spec literal `provider` → `<provider>/<prompt_template_version>`
  확장 + 사유).

cargo test -p kebab-parse-image — 42 pass + 2 ignored
  (13 unit + 12 P6-1 + 8 P6-2 + 9 P6-3).
cargo clippy --workspace --all-targets -- -D warnings — pass.
claude-reviewer-01 requested changes 2026-05-02 06:13:07 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 의 다섯 항목이 정확히 반영되었습니다 (image_prep.rs 추출 + 양 모듈 위임, caption_image 게이트 제거, clone 0회, 상수 pub, HOTFIXES 의 model_version deviation 단락). 책임 분리 + 코드 중복 해소가 깔끔합니다.

남은 actionable 1건 + cosmetic 1건:

  • image_prep::downscale_to_png 가 회차 1 에서 신규 추출된 핵심 helper 인데 자체 unit 테스트가 비어 있음 — PNG passthrough / 클램프 / aspect ratio / 손상 입력 4축 회귀 테스트 추가 권장.
  • 모듈 doc 의 "vision models" 표현을 "vision pipelines" / "image-to-LM channel" 정도로 일반화 — 미래 PDF / video 호출자 시야 포함.

칭찬 — apply_caption 의 clone 정리 (caption move 전에 note 미리 빌드) 는 ingest hot path 에서 자산당 두 알로케이션을 절약하는 자연스러운 정리였고, MIN/MAX_CAPTION_LONG_EDGEpub const 로 노출하면서 테스트가 literal 1536 대신 상수 참조로 갱신된 점도 좋습니다 (P6-2 측 MAX_DECODE_DIM 와 컨벤션 동일).

회차 2 — 회차 1 의 다섯 항목이 정확히 반영되었습니다 (`image_prep.rs` 추출 + 양 모듈 위임, `caption_image` 게이트 제거, clone 0회, 상수 pub, HOTFIXES 의 model_version deviation 단락). 책임 분리 + 코드 중복 해소가 깔끔합니다. 남은 actionable 1건 + cosmetic 1건: - `image_prep::downscale_to_png` 가 회차 1 에서 신규 추출된 핵심 helper 인데 자체 unit 테스트가 비어 있음 — PNG passthrough / 클램프 / aspect ratio / 손상 입력 4축 회귀 테스트 추가 권장. - 모듈 doc 의 \"vision models\" 표현을 \"vision pipelines\" / \"image-to-LM channel\" 정도로 일반화 — 미래 PDF / video 호출자 시야 포함. 칭찬 — `apply_caption` 의 clone 정리 (caption move 전에 note 미리 빌드) 는 ingest hot path 에서 자산당 두 알로케이션을 절약하는 자연스러운 정리였고, `MIN/MAX_CAPTION_LONG_EDGE` 를 `pub const` 로 노출하면서 테스트가 literal 1536 대신 상수 참조로 갱신된 점도 좋습니다 (P6-2 측 `MAX_DECODE_DIM` 와 컨벤션 동일).
@@ -0,0 +8,4 @@
//! messages in one place — future modules (PDF page thumbnails,
//! video keyframes, …) plug in without re-deriving the algorithm.
use std::io::Cursor;

(작은 doc 권장) 모듈 doc 의 "send to vision models" 표현이 caption / OCR 만 시야에 둔 톤입니다. doc-comment 자체가 "future modules (PDF page thumbnails, video keyframes, …) plug in" 까지 약속하고 있으니 지금부터 "vision pipelines" / "image-to-LM channel" 정도로 일반화해 두면 미래 호출자가 doc 만 보고 호출 의도를 파악합니다. 사소합니다.

(작은 doc 권장) 모듈 doc 의 "send to vision models" 표현이 caption / OCR 만 시야에 둔 톤입니다. doc-comment 자체가 "future modules (PDF page thumbnails, video keyframes, …) plug in" 까지 약속하고 있으니 지금부터 "vision pipelines" / "image-to-LM channel" 정도로 일반화해 두면 미래 호출자가 doc 만 보고 호출 의도를 파악합니다. 사소합니다.
@@ -0,0 +25,4 @@
/// the same `decode → optionally resize → re-encode` tail.
pub(crate) fn downscale_to_png(
bytes: &[u8],
max_long_edge: u32,

회차 1 에서 추출된 공용 helper 인데, 자체 회귀 테스트가 비어 있습니다. caption / ocr integration test 가 간접 검증을 하긴 하지만, helper 시그니처가 변경되거나 1px 후행 클램프가 무심코 사라져도 두 호출처 모두 그린 머지가 가능 (예: 다운스케일이 1px 초과해도 caption 측 wire 는 그대로 동작).

간단한 unit 테스트 4건 추가 권장:

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;
    use image::{ImageBuffer, Rgb};

    fn png(w: u32, h: u32) -> Vec<u8> {
        let img: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_pixel(w, h, Rgb([0,0,255]));
        let mut buf = Cursor::new(Vec::new());
        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
        buf.into_inner()
    }

    #[test]
    fn png_within_cap_passes_through_zero_decode() {
        let bytes = png(100, 50);
        let (out, w, h) = downscale_to_png(&bytes, 1024).unwrap();
        assert_eq!((w, h), (100, 50));
        assert_eq!(out, bytes, "PNG passthrough must return source bytes verbatim");
    }

    #[test]
    fn long_edge_clamped_to_max() {
        let bytes = png(4001, 3000);
        let (_out, w, h) = downscale_to_png(&bytes, 1601).unwrap();
        assert!(w.max(h) <= 1601, "long edge {} > max", w.max(h));
    }

    #[test]
    fn aspect_ratio_preserved_within_rounding() {
        let bytes = png(4000, 3000);
        let (_out, w, h) = downscale_to_png(&bytes, 1024).unwrap();
        let ratio = w as f32 / h as f32;
        assert!((ratio - 4.0/3.0).abs() < 0.02, "aspect drift: {ratio}");
    }

    #[test]
    fn corrupt_bytes_return_err() {
        let r = downscale_to_png(&[0x89, 0x50, 0x4E, 0x47], 1024);
        assert!(r.is_err());
    }
}

공용 helper 가 워크스페이스의 다음 다운스케일 사용처 (PDF / video) 에도 같은 invariant 를 보장한다는 신호가 됩니다.

회차 1 에서 추출된 공용 helper 인데, 자체 회귀 테스트가 비어 있습니다. caption / ocr integration test 가 간접 검증을 하긴 하지만, helper 시그니처가 변경되거나 1px 후행 클램프가 무심코 사라져도 두 호출처 모두 그린 머지가 가능 (예: 다운스케일이 1px 초과해도 caption 측 wire 는 그대로 동작). 간단한 unit 테스트 4건 추가 권장: ```rust #[cfg(test)] mod tests { use super::*; use std::io::Cursor; use image::{ImageBuffer, Rgb}; fn png(w: u32, h: u32) -> Vec<u8> { let img: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_pixel(w, h, Rgb([0,0,255])); let mut buf = Cursor::new(Vec::new()); img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); buf.into_inner() } #[test] fn png_within_cap_passes_through_zero_decode() { let bytes = png(100, 50); let (out, w, h) = downscale_to_png(&bytes, 1024).unwrap(); assert_eq!((w, h), (100, 50)); assert_eq!(out, bytes, "PNG passthrough must return source bytes verbatim"); } #[test] fn long_edge_clamped_to_max() { let bytes = png(4001, 3000); let (_out, w, h) = downscale_to_png(&bytes, 1601).unwrap(); assert!(w.max(h) <= 1601, "long edge {} > max", w.max(h)); } #[test] fn aspect_ratio_preserved_within_rounding() { let bytes = png(4000, 3000); let (_out, w, h) = downscale_to_png(&bytes, 1024).unwrap(); let ratio = w as f32 / h as f32; assert!((ratio - 4.0/3.0).abs() < 0.02, "aspect drift: {ratio}"); } #[test] fn corrupt_bytes_return_err() { let r = downscale_to_png(&[0x89, 0x50, 0x4E, 0x47], 1024); assert!(r.is_err()); } } ``` 공용 helper 가 워크스페이스의 다음 다운스케일 사용처 (PDF / video) 에도 같은 invariant 를 보장한다는 신호가 됩니다.
altair823 added 1 commit 2026-05-02 06:14:31 +00:00
- src/image_prep.rs:
  • 신규 unit 테스트 6건 — PNG passthrough (zero-decode + 바이트
    동일성), JPEG → PNG 재인코딩, 1px 후행 클램프 (max=1601 / long=4001
    irrational scale), aspect ratio (4:3 보존, 2% 이내), 손상 PNG
    Err, 인식 불가 바이트 Err.
  • 모듈 doc-comment 의 \"send to vision models\" 표현을 \"image-to-LM
    pipeline / channel\" 으로 일반화. 미래 PDF / video keyframe 등
    호출자가 doc 만 보고 호출 의도 파악 가능.

cargo test -p kebab-parse-image — 48 pass + 2 ignored
  (19 unit (+6 image_prep) + 12 P6-1 + 8 P6-2 + 9 P6-3).
cargo clippy -p kebab-parse-image --all-targets -- -D warnings — pass.
claude-reviewer-01 approved these changes 2026-05-02 06:15:36 +00:00
claude-reviewer-01 left a comment
Member

회차 3 — 회차 2 의 두 항목이 정확히 반영되었습니다 (image_prep unit 테스트 6건 + 모듈 doc 일반화). actionable 한 추가 지적이 보이지 않습니다.

전체 회차 요약:

  • 회차 1: code duplication / API 비대칭 / clone / model_version deviation / 상수 가시성 (5건).
  • 회차 2: image_prep 회귀 테스트 / doc 일반화 (2건).
  • 회차 3: APPROVE.

48 hermetic tests + 2 ignored (실 Ollama 통합) 통과, cargo clippy --workspace --all-targets -- -D warnings 통과. GenerateRequest.images 확장은 wire shape 호환 (skip_serializing_if + serde default) 으로 pre-P6-3 페이로드 / snapshot 영향 없음.

머지 가능합니다. 사용자가 Gitea UI 에서 머지해 주세요:

#34

"Delete branch after merge" 체크 권장. 로컬 브랜치는 머지 후 git fetch -p + git branch -d feat/p6-3-caption-adapter 로 정리.

P6 phase epic 완료 — P6-1 (extractor) / P6-2 (OCR) / P6-3 (caption) 세 컴포넌트 모두 머지되면 image ingestion 파이프라인이 contract 단위로 종결됩니다. 다음 task 후보: P7 (PDF) 또는 P8 (audio) — 둘 다 P5 이후 병렬 가능 phase.

회차 3 — 회차 2 의 두 항목이 정확히 반영되었습니다 (`image_prep` unit 테스트 6건 + 모듈 doc 일반화). actionable 한 추가 지적이 보이지 않습니다. 전체 회차 요약: - 회차 1: code duplication / API 비대칭 / clone / model_version deviation / 상수 가시성 (5건). - 회차 2: image_prep 회귀 테스트 / doc 일반화 (2건). - 회차 3: APPROVE. 48 hermetic tests + 2 ignored (실 Ollama 통합) 통과, `cargo clippy --workspace --all-targets -- -D warnings` 통과. `GenerateRequest.images` 확장은 wire shape 호환 (skip_serializing_if + serde default) 으로 pre-P6-3 페이로드 / snapshot 영향 없음. 머지 가능합니다. 사용자가 Gitea UI 에서 머지해 주세요: http://gitea.altair823.xyz/altair823-org/kebab/pulls/34 \"Delete branch after merge\" 체크 권장. 로컬 브랜치는 머지 후 `git fetch -p` + `git branch -d feat/p6-3-caption-adapter` 로 정리. P6 phase epic 완료 — P6-1 (extractor) / P6-2 (OCR) / P6-3 (caption) 세 컴포넌트 모두 머지되면 image ingestion 파이프라인이 contract 단위로 종결됩니다. 다음 task 후보: P7 (PDF) 또는 P8 (audio) — 둘 다 P5 이후 병렬 가능 phase.
@@ -0,0 +57,4 @@
///
/// Honours the `[MIN_CAPTION_LONG_EDGE, MAX_CAPTION_LONG_EDGE]` clamp
/// on `config.image.caption.max_pixels` so a hostile config cannot
/// blow up prompt cost.

(칭찬) caption_image (raw 연산) / apply_caption (gate + provenance) 의 책임 분리가 코드 양 자체를 줄였고, 더 중요하게는 "같은 config 에서 두 함수가 다른 결과" 라는 인지 부담을 제거했습니다. P+ 에서 caption 외 다른 vision adapter (예: alt-text generator) 가 비슷한 책임 분리를 따라가는 패턴이 됩니다.

(칭찬) `caption_image` (raw 연산) / `apply_caption` (gate + provenance) 의 책임 분리가 코드 양 자체를 줄였고, 더 중요하게는 "같은 config 에서 두 함수가 다른 결과" 라는 인지 부담을 제거했습니다. P+ 에서 caption 외 다른 vision adapter (예: alt-text generator) 가 비슷한 책임 분리를 따라가는 패턴이 됩니다.
@@ -0,0 +8,4 @@
//! source bytes when they already satisfy both constraints.
//! Centralising this here keeps the 1px-rounding fix, the PNG
//! passthrough hot path, and the error messages in one place —
//! future image-to-LM channels (PDF page thumbnails, video

(칭찬) 모듈 doc 의 "image-to-LM pipeline / channel" 일반화가 좋습니다. 향후 PDF page thumbnail / video keyframe 등 같은 다운스케일을 필요로 하는 모듈이 합류할 때, 새 호출자가 doc 만 보고 "이 helper 가 OCR/caption 전용이 아니구나" 를 즉시 파악할 수 있게 됐습니다.

(칭찬) 모듈 doc 의 "image-to-LM pipeline / channel" 일반화가 좋습니다. 향후 PDF page thumbnail / video keyframe 등 같은 다운스케일을 필요로 하는 모듈이 합류할 때, 새 호출자가 doc 만 보고 "이 helper 가 OCR/caption 전용이 아니구나" 를 즉시 파악할 수 있게 됐습니다.

(칭찬) long_edge_clamped_strictly_to_max_for_irrational_scalemax=1601, long=4001 이라는 정확히 까다로운 코너 케이스를 박았습니다 — round-to-nearest 가 long-axis 를 1px 초과시키는 정확한 시나리오. 회차 2 의 1px 클램프 후행 처리가 무심코 사라지면 이 테스트가 즉시 빨간색이 됩니다. 회귀 신호로 가장 정확한 fixture 선택입니다.

(칭찬) `long_edge_clamped_strictly_to_max_for_irrational_scale` 가 `max=1601, long=4001` 이라는 정확히 까다로운 코너 케이스를 박았습니다 — round-to-nearest 가 long-axis 를 1px 초과시키는 정확한 시나리오. 회차 2 의 1px 클램프 후행 처리가 무심코 사라지면 이 테스트가 즉시 빨간색이 됩니다. 회귀 신호로 가장 정확한 fixture 선택입니다.
altair823 merged commit 21020e0029 into main 2026-05-02 06:22:19 +00:00
altair823 deleted branch feat/p6-3-caption-adapter 2026-05-02 06:22:21 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#34