feat(kebab-app): P6-4 image ingest wiring — kebab ingest 가 PNG/JPEG 처리 #36

Merged
altair823 merged 3 commits from feat/p6-4-image-ingest-wiring into main 2026-05-02 08:22:27 +00:00
Owner

요약

P6-4 spec (PR #35) 의 implementation. P6-1/P6-2/P6-3 의 라이브러리가 그동안 kebab ingest CLI 에서 보이지 않던 미완 구간을 완성. 이제 kebab ingest 가 markdown 외에 이미지 자산을 end-to-end 색인하고, kebab search / kebab ask 가 OCR 텍스트 + caption 으로 이미지 매칭/인용.

contract: tasks/p6/p6-4-image-ingest-wiring.md (spec PR #35 머지본).

동작 변경

  • kebab-app::ingest_with_configMediaType::Image(_) 분기를 가진다.
  • image.ocr.enabled / image.caption.enabled 플래그에 따라 OllamaVisionOcr / OllamaLanguageModel 을 ingest 세션당 1회 빌드, trait object 로 자산 루프 공유.
  • 이미지 자산: ImageExtractor::extract → 옵션 apply_ocr → 옵션 apply_caption → 기존 MdHeadingV1Chunker (image-only 분기는 P1-5 부터 존재) → 기존 persist + embed.
  • kebab-chunk::md_heading_v1::render_block_textBlock::ImageRef 분기가 (β) plain concat 정책으로 갱신 — [alt, ocr.joined, caption.text]\\n\\n 로 join, 빈 부분 drop.

Lenient 실패 정책 (spec 의 (b))

  • ImageExtractor::extract Err → errors+=1, 자산 미저장.
  • apply_ocr Err → block.ocr None 유지, ProvenanceKind::Warning (agent=\"kb-app\", note=\"ocr_failed: ...\"), IngestItem.warnings 에 노트 1건. errors 미증가.
  • apply_caption Err → 동일. errors 미증가.
  • 사용자가 부분 실패를 인지하려면 kebab inspect doc <id> 또는 --debug 트레이스. IngestReporterrors 카운터로는 보이지 않음 (의도).

변경 파일

파일 변경
crates/kebab-app/Cargo.toml kebab-parse-image deps + dev-deps (wiremock, tokio, image) 추가
crates/kebab-app/src/lib.rs OCR/caption adapter ingest-session-당-1회 빌드, dispatch 분기, ingest_one_image_asset 신규, ImagePipeline 묶음 구조체, lang_hint_from_doc 헬퍼
crates/kebab-chunk/src/md_heading_v1.rs render_block_text(Block::ImageRef) (β) 정책으로 교체 + unit 테스트 5케이스 추가
crates/kebab-app/tests/image_pipeline.rs 신규 — wiremock 통합 테스트 5건
docs/SMOKE.md image fixture step + [image.*] config 예시 + 시간 추정
tasks/p6/p6-4-image-ingest-wiring.md status planned → completed

테스트

cargo test --workspace --no-fail-fast -j 1 — 전부 pass. 핵심 신규:

  • cargo test -p kebab-app --test image_pipeline — 5 pass:
    • OCR-only happy path
    • OCR + caption 동시 활성 (chunk text 에 세 부분 합성)
    • OCR 503 시 Lenient 동작 (errors=0, Warning, block.ocr=None)
    • 양쪽 비활성 (filename-only chunk, EXIF/dims 보존)
    • 결정성 — 재 ingest 시 Updated + 동일 doc_id
  • cargo test -p kebab-chunk image_ref — 2 pass (P1-5 회귀 + (β) plain concat unit)

cargo clippy --workspace --all-targets -- -D warnings — pass.

실 Ollama 통합 검증

config.toml[image.ocr] enabled=true + [image.caption] enabled=true 켜고 192.168.0.47 Ollama / gemma4:e4b:

$ kebab --config /tmp/kebab-smoke/config.toml ingest
scanned 2  new 2  updated 0  skipped 0  errors 0  (18395 ms)

$ kebab inspect doc <image_doc_id>
parser_version: image-meta-v1
alt: \"hello.png\"
ocr.joined: \"Hello World 2026\"
caption.text: \"The image displays the text \\\"Hello World 2026\\\" in a large, black, sans-serif font.\"

$ kebab search --mode vector \"Hello World\" --k 3
 1. 0.9100  hello.png
 2. 0.8958  intro.md  >  안녕

$ kebab --json ask \"Hello World 텍스트가 어디에 있나?\" --mode hybrid
grounded: true
citations: [{marker: \"[1]\", doc_path: \"hello.png\"}]
answer: \"'Hello World 2026' 텍스트가 [#1]에 있습니다.\"

의존성 경계

  • kebab-appkebab-parse-image 추가 — spec Allowed dep.
  • 새 forbidden 침범 없음. UI / eval 미참조 유지.
  • 본 task 가 신설하는 image-specific 비즈니스 로직 0 줄 — 전부 kebab-parse-image 에 위임.

P6 phase epic 완료

P6-1 (extractor + EXIF) / P6-2 (OCR) / P6-3 (caption) / P6-4 (ingest wiring) 4 컴포넌트 모두 완료. image ingestion 파이프라인 종결. 다음 후보 — P7 (PDF) 또는 P8 (audio).

Test plan

  • cargo test --workspace --no-fail-fast -j 1
  • cargo clippy --workspace --all-targets -- -D warnings
  • kebab ingest against TempDir (markdown + PNG) → scanned 2 / new 2 / errors 0
  • kebab search --mode vector \"Hello World\" → image chunk top-1
  • kebab ask → grounded=true + citation marker 가 image 자산
  • kebab inspect doc <image_doc_id> → block.ocr / block.caption 둘 다 채워짐
  • docs/SMOKE.md image step + 시간 추정 추가
  • tasks/p6/p6-4-image-ingest-wiring.md status planned → completed

🤖 Generated with Claude Code

## 요약 P6-4 spec ([PR #35](http://gitea.altair823.xyz/altair823-org/kebab/pulls/35)) 의 implementation. P6-1/P6-2/P6-3 의 라이브러리가 그동안 `kebab ingest` CLI 에서 보이지 않던 미완 구간을 완성. 이제 `kebab ingest` 가 markdown 외에 이미지 자산을 end-to-end 색인하고, `kebab search` / `kebab ask` 가 OCR 텍스트 + caption 으로 이미지 매칭/인용. contract: [`tasks/p6/p6-4-image-ingest-wiring.md`](tasks/p6/p6-4-image-ingest-wiring.md) (spec PR #35 머지본). ## 동작 변경 - `kebab-app::ingest_with_config` 가 `MediaType::Image(_)` 분기를 가진다. - `image.ocr.enabled` / `image.caption.enabled` 플래그에 따라 `OllamaVisionOcr` / `OllamaLanguageModel` 을 ingest 세션당 1회 빌드, trait object 로 자산 루프 공유. - 이미지 자산: `ImageExtractor::extract` → 옵션 `apply_ocr` → 옵션 `apply_caption` → 기존 `MdHeadingV1Chunker` (image-only 분기는 P1-5 부터 존재) → 기존 persist + embed. - `kebab-chunk::md_heading_v1::render_block_text` 의 `Block::ImageRef` 분기가 (β) plain concat 정책으로 갱신 — `[alt, ocr.joined, caption.text]` 를 `\\n\\n` 로 join, 빈 부분 drop. ## Lenient 실패 정책 (spec 의 (b)) - `ImageExtractor::extract` Err → `errors+=1`, 자산 미저장. - `apply_ocr` Err → `block.ocr` None 유지, `ProvenanceKind::Warning` (`agent=\"kb-app\"`, `note=\"ocr_failed: ...\"`), `IngestItem.warnings` 에 노트 1건. `errors` 미증가. - `apply_caption` Err → 동일. `errors` 미증가. - 사용자가 부분 실패를 인지하려면 `kebab inspect doc <id>` 또는 `--debug` 트레이스. `IngestReport` 의 `errors` 카운터로는 보이지 않음 (의도). ## 변경 파일 | 파일 | 변경 | |---|---| | [crates/kebab-app/Cargo.toml](crates/kebab-app/Cargo.toml) | `kebab-parse-image` deps + dev-deps (wiremock, tokio, image) 추가 | | [crates/kebab-app/src/lib.rs](crates/kebab-app/src/lib.rs) | OCR/caption adapter ingest-session-당-1회 빌드, dispatch 분기, `ingest_one_image_asset` 신규, `ImagePipeline` 묶음 구조체, `lang_hint_from_doc` 헬퍼 | | [crates/kebab-chunk/src/md_heading_v1.rs](crates/kebab-chunk/src/md_heading_v1.rs) | `render_block_text(Block::ImageRef)` (β) 정책으로 교체 + unit 테스트 5케이스 추가 | | [crates/kebab-app/tests/image_pipeline.rs](crates/kebab-app/tests/image_pipeline.rs) | 신규 — wiremock 통합 테스트 5건 | | [docs/SMOKE.md](docs/SMOKE.md) | image fixture step + `[image.*]` config 예시 + 시간 추정 | | [tasks/p6/p6-4-image-ingest-wiring.md](tasks/p6/p6-4-image-ingest-wiring.md) | status `planned → completed` | ## 테스트 `cargo test --workspace --no-fail-fast -j 1` — 전부 pass. 핵심 신규: - `cargo test -p kebab-app --test image_pipeline` — 5 pass: - OCR-only happy path - OCR + caption 동시 활성 (chunk text 에 세 부분 합성) - OCR 503 시 Lenient 동작 (errors=0, Warning, block.ocr=None) - 양쪽 비활성 (filename-only chunk, EXIF/dims 보존) - 결정성 — 재 ingest 시 Updated + 동일 doc_id - `cargo test -p kebab-chunk image_ref` — 2 pass (P1-5 회귀 + (β) plain concat unit) `cargo clippy --workspace --all-targets -- -D warnings` — pass. ## 실 Ollama 통합 검증 `config.toml` 에 `[image.ocr] enabled=true` + `[image.caption] enabled=true` 켜고 `192.168.0.47` Ollama / `gemma4:e4b`: ``` $ kebab --config /tmp/kebab-smoke/config.toml ingest scanned 2 new 2 updated 0 skipped 0 errors 0 (18395 ms) $ kebab inspect doc <image_doc_id> parser_version: image-meta-v1 alt: \"hello.png\" ocr.joined: \"Hello World 2026\" caption.text: \"The image displays the text \\\"Hello World 2026\\\" in a large, black, sans-serif font.\" $ kebab search --mode vector \"Hello World\" --k 3 1. 0.9100 hello.png 2. 0.8958 intro.md > 안녕 $ kebab --json ask \"Hello World 텍스트가 어디에 있나?\" --mode hybrid grounded: true citations: [{marker: \"[1]\", doc_path: \"hello.png\"}] answer: \"'Hello World 2026' 텍스트가 [#1]에 있습니다.\" ``` ## 의존성 경계 - `kebab-app` 이 `kebab-parse-image` 추가 — spec Allowed dep. - 새 forbidden 침범 없음. UI / eval 미참조 유지. - 본 task 가 신설하는 image-specific 비즈니스 로직 0 줄 — 전부 `kebab-parse-image` 에 위임. ## P6 phase epic 완료 P6-1 (extractor + EXIF) / P6-2 (OCR) / P6-3 (caption) / P6-4 (ingest wiring) 4 컴포넌트 모두 완료. image ingestion 파이프라인 종결. 다음 후보 — P7 (PDF) 또는 P8 (audio). ## Test plan - [x] `cargo test --workspace --no-fail-fast -j 1` - [x] `cargo clippy --workspace --all-targets -- -D warnings` - [x] `kebab ingest` against TempDir (markdown + PNG) → `scanned 2 / new 2 / errors 0` - [x] `kebab search --mode vector \"Hello World\"` → image chunk top-1 - [x] `kebab ask` → grounded=true + citation marker 가 image 자산 - [x] `kebab inspect doc <image_doc_id>` → block.ocr / block.caption 둘 다 채워짐 - [x] docs/SMOKE.md image step + 시간 추정 추가 - [x] tasks/p6/p6-4-image-ingest-wiring.md status planned → completed 🤖 Generated with [Claude Code](https://claude.com/claude-code)
altair823 added 1 commit 2026-05-02 07:38:40 +00:00
P6-1/P6-2/P6-3 의 라이브러리 (`ImageExtractor`, `OllamaVisionOcr`,
`apply_caption`) 가 그동안 CLI 에서 보이지 않던 미완 구간을 완성.
이제 `kebab ingest` 가 markdown 외에 이미지 자산을 end-to-end 로
색인하고, `kebab search` / `kebab ask` 가 OCR 텍스트 + caption 으로
이미지를 매칭/인용한다.

## kebab-app

- `[dependencies]` 에 `kebab-parse-image` 추가.
- `ingest_with_config` 진입 시 `image.ocr.enabled` / `image.caption.enabled`
  플래그에 따라 `OllamaVisionOcr` / `OllamaLanguageModel` 을 **ingest
  세션당 1회** 빌드. 자산 루프에서 trait object 로 공유.
  reqwest::blocking::Client 의 내부 Arc 덕분에 알로케이션 비용은
  자산 수와 무관.
- 두 어댑터 + ImageExtractor 를 한 묶음으로 `ImagePipeline` 구조체에
  담아 `ingest_one_asset` 매개변수 폭증 차단 (clippy::too_many_arguments
  대응).
- `ingest_one_asset` 의 markdown-only 가드를 `match media_type` 으로
  교체 — Markdown 은 기존 경로, Image(_) 는 새 `ingest_one_image_asset`
  로 분기, PDF/Audio/Other 는 종전대로 skipped.
- 신규 `ingest_one_image_asset`:
  - bytes 읽기 → `ImageExtractor::extract` (실패 시 caller 가 errors+=1)
  - `apply_ocr` (Lenient — 실패 시 ProvenanceKind::Warning 이벤트 +
    `IngestItem.warnings` 에 \"ocr_failed: ...\", `block.ocr` 는 None
    유지)
  - `apply_caption` (동일 Lenient 정책)
  - 기존 `MdHeadingV1Chunker` 호출 — 청커는 이미 `Block::ImageRef` 를
    단일 청크로 emit
  - 기존 persist + embed 시퀀스 그대로 (markdown 과 byte-identical)
- `lang_hint_from_doc` — `Lang(\"und\")` 또는 빈 문자열을 None 으로
  매핑 (image-pipeline 어댑터의 build_prompt 가 \"und\" 를 silent drop
  하지 않도록 caller 측에서 미리).

## kebab-chunk

- `render_block_text` 의 `Block::ImageRef` 분기를 P6-4 (β) plain
  concat 정책으로 교체 — `[alt, ocr.joined, caption.text]` 를 `\\n\\n`
  로 join, 빈 부분은 drop. alt 가 비면 `src` 의 basename 으로 fallback
  (P6-1 contract 의 defensive guard).
- 신규 unit 테스트 `image_ref_p6_4_plain_concat_drops_empty_parts` —
  alt-only / alt+ocr / alt+caption / alt+ocr+caption / 빈 alt → src
  fallback 다섯 케이스 모두 검증.
- 기존 `image_ref_emits_own_chunk_zero_tokens` 그대로 통과 — 청커의
  per-block dispatch 는 변경 없음, text 렌더링만 갱신.

## 통합 테스트 (kebab-app/tests/image_pipeline.rs)

wiremock 으로 Ollama 를 stub. 5건:

1. OCR-only happy path — 1 PNG + ocr.enabled → 1 doc + 1 chunk emit,
   `block.ocr.joined` 가 mock 의 \"Hello World 2026\".
2. OCR + caption 동시 활성 — 두 필드 모두 채워지고 chunk text 에
   alt + ocr + caption 세 부분 모두 포함.
3. Lenient 실패 검증 — OCR 503 시 자산은 indexed (kind=New),
   `errors=0`, ProvenanceKind::Warning attributed to \"kb-app\",
   `IngestItem.warnings` 에 \"ocr_failed:\" 노트.
4. 양쪽 비활성 — `image.ocr.enabled=false && image.caption.enabled=false`
   여도 자산은 chunk 1개로 indexed (chunk text=filename), EXIF +
   dimensions 그대로 채워짐.
5. 결정성 (re-ingest) — 동일 PNG 두 번 ingest 시 두 번째는
   `Updated` + 동일 `doc_id`.

## SMOKE.md

`kebab search --mode lexical \"Hello World\"` 단계를 명령 시퀀스에
추가. `[image.ocr]` / `[image.caption]` config 절 예시 + ingest 시간
추정 (자산당 ~5-10초) 추가. \"책은 P7 PDF 라인으로\" 가이드를 검증
체크리스트 와 \"알려진 동작\" 양쪽에 박음.

## 실 Ollama 통합 검증

192.168.0.47 + gemma4:e4b 기준:

```
$ kebab --config /tmp/kebab-smoke/config.toml ingest
scanned 2  new 2  updated 0  skipped 0  errors 0  (18395 ms)

$ kebab inspect doc <image_doc_id>
parser_version: image-meta-v1
blocks: [{
  alt: \"hello.png\",
  ocr: \"Hello World 2026\",
  caption: \"The image displays the text \\\"Hello World 2026\\\" in a large, black, sans-serif font.\"
}]

$ kebab --json ask \"Hello World 텍스트가 어디에 있나?\" --mode hybrid
grounded: true
citations: [{marker: \"[1]\", doc_path: \"hello.png\"}]
```

## 검증

- `cargo test --workspace --no-fail-fast -j 1` — 전부 pass
- `cargo clippy --workspace --all-targets -- -D warnings` — pass
- `cargo test -p kebab-chunk image_ref` — 2 pass (P1-5 회귀 + P6-4
  신규 unit)
- `cargo test -p kebab-app --test image_pipeline` — 5 pass

## 의존성 경계

- `kebab-app` 이 `kebab-parse-image` 추가 — spec Allowed dep 그대로.
- 새 forbidden 침범 없음 (기존 `kebab-tui` / `kebab-desktop` /
  `kebab-eval` 미참조 유지).
- 본 task 가 신설하는 image-specific 비즈니스 로직 0줄 — 모두
  `kebab-parse-image` 에 위임.

`tasks/p6/p6-4-image-ingest-wiring.md` status: planned → completed.

contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
sections: §3.4 ImageRefBlock, §6.1 ingest pipeline, §7.2
Extractor/Chunker traits, §9.1 image extraction policy.
claude-reviewer-01 requested changes 2026-05-02 07:40:55 +00:00
claude-reviewer-01 left a comment
Member

회차 1 — 큰 그림은 spec 의 의도를 정확히 따라갑니다. ImagePipeline 묶음 구조체로 매개변수 폭증 회피, ingest session 당 1회 어댑터 빌드, Lenient 실패 정책 + ProvenanceKind::Warning 이벤트, P6-1 의 "image-only document = single ImageRef" 분기 그대로 활용, 통합 테스트 5건이 spec 의 Implementation PR DoD 항목을 모두 커버. 실 Ollama 로 "Hello World 텍스트가 어디에 있나?" → grounded=true + citation hello.png 까지 검증한 점도 좋습니다.

머지 전에 정리할 actionable 항목 5건:

구조적 결함:

  • ingest_one_asset 의 doc-comment 가 새 ImagePipeline struct 위로 잘못 합쳐짐 (빈 줄 누락 → rustdoc 가 두 doc 을 struct 의 것으로 합침). fn 의 doc 이 통째로 사라진 상태.
  • if let Some(Block::ImageRef(block)) 의 else 분기가 silent skip — P6-1 contract 가 미래에 깨질 때 "OCR 안 돌고 끝" 이라는 해석 불가능한 동작이 됨. tracing::warn 권장.

중복 / spec 약속 위반:

  • OCR 실패 분기 + caption 실패 분기가 거의 동일 25줄 boilerplate — 헬퍼 함수 추출 권장.
  • spec Risks 의 "Determinism stress: must not introduce a second now() call between extract and apply_ocr/caption" 약속 위반 — 현재 두 분기가 각자 OffsetDateTime::now_utc() 호출. fn 진입 시 하나 캐시.

일관성:

  • image 경로의 IngestItem.warnings 형식 (\"ocr_failed: <err>\") 이 markdown 경로의 (\"<WarningKind>: <note>\") 와 다름. WarningKind 에 OcrFailed / CaptionFailed 변형 추가하거나 prefix 형식 통일.

칭찬 — ImagePipeline 구조체 도입 + lang_hint_from_doc 헬퍼로 "매개변수 10개" 안티패턴을 의식적으로 회피한 점이 좋습니다. clippy::too_many_arguments allow 는 두 함수에 정확히 최소 적용. embedder + vector_store 까지 한 묶음으로 더 줄일 여지는 있지만 본 PR scope 외로 둬도 OK.

회차 1 — 큰 그림은 spec 의 의도를 정확히 따라갑니다. `ImagePipeline` 묶음 구조체로 매개변수 폭증 회피, ingest session 당 1회 어댑터 빌드, Lenient 실패 정책 + `ProvenanceKind::Warning` 이벤트, P6-1 의 \"image-only document = single ImageRef\" 분기 그대로 활용, 통합 테스트 5건이 spec 의 Implementation PR DoD 항목을 모두 커버. 실 Ollama 로 \"Hello World 텍스트가 어디에 있나?\" → grounded=true + citation hello.png 까지 검증한 점도 좋습니다. 머지 전에 정리할 actionable 항목 5건: **구조적 결함**: - `ingest_one_asset` 의 doc-comment 가 새 `ImagePipeline` struct 위로 잘못 합쳐짐 (빈 줄 누락 → rustdoc 가 두 doc 을 struct 의 것으로 합침). fn 의 doc 이 통째로 사라진 상태. - `if let Some(Block::ImageRef(block))` 의 else 분기가 silent skip — P6-1 contract 가 미래에 깨질 때 \"OCR 안 돌고 끝\" 이라는 해석 불가능한 동작이 됨. tracing::warn 권장. **중복 / spec 약속 위반**: - OCR 실패 분기 + caption 실패 분기가 거의 동일 25줄 boilerplate — 헬퍼 함수 추출 권장. - spec Risks 의 \"Determinism stress: must not introduce a second `now()` call between extract and apply_ocr/caption\" 약속 위반 — 현재 두 분기가 각자 `OffsetDateTime::now_utc()` 호출. fn 진입 시 하나 캐시. **일관성**: - image 경로의 `IngestItem.warnings` 형식 (`\"ocr_failed: <err>\"`) 이 markdown 경로의 (`\"<WarningKind>: <note>\"`) 와 다름. WarningKind 에 `OcrFailed` / `CaptionFailed` 변형 추가하거나 prefix 형식 통일. 칭찬 — `ImagePipeline` 구조체 도입 + `lang_hint_from_doc` 헬퍼로 \"매개변수 10개\" 안티패턴을 의식적으로 회피한 점이 좋습니다. clippy::too_many_arguments allow 는 두 함수에 정확히 최소 적용. embedder + vector_store 까지 한 묶음으로 더 줄일 여지는 있지만 본 PR scope 외로 둬도 OK.
@@ -441,0 +474,4 @@
/// P6-4: borrowed bundle of the three image-pipeline components built
/// once per ingest invocation. Threaded through `ingest_one_asset` so
/// the dispatch does not need ten separate parameters.
struct ImagePipeline<'a> {

doc-comment 가 잘못 합쳐졌습니다. line 470-473 의 4 줄 ("Process a single asset: read bytes, parse, normalize, chunk, persist, embed...") 는 원래 fn ingest_one_asset 의 doc 인데, 그 사이에 새 ImagePipeline struct 를 끼워 넣으면서 빈 줄 없이 line 474-476 의 struct doc 과 이어졌습니다. 결과적으로 rustdoc 은 두 doc-comment 를 합쳐서 ImagePipeline 의 문서로 인식하고, ingest_one_asset 자체는 doc 이 없는 상태가 됩니다.

수정:

type SqliteStoreAlias = kebab_store_sqlite::SqliteStore;

/// P6-4: borrowed bundle of the three image-pipeline components built
/// once per ingest invocation. Threaded through `ingest_one_asset` so
/// the dispatch does not need ten separate parameters.
struct ImagePipeline<'a> { ... }

/// Process a single asset: read bytes, parse, normalize, chunk,
/// persist, embed. Per-asset failures bubble up to the caller for
/// labelling as `IngestItemKind::Error` — they do NOT abort the
/// whole run.
#[allow(clippy::too_many_arguments)]
fn ingest_one_asset(...)

원래 doc 을 fn 위치로 되돌리고 struct 의 P6-4 doc 만 struct 위에 두는 형태.

doc-comment 가 잘못 합쳐졌습니다. line 470-473 의 4 줄 ("Process a single asset: read bytes, parse, normalize, chunk, persist, embed...") 는 원래 `fn ingest_one_asset` 의 doc 인데, 그 사이에 새 `ImagePipeline` struct 를 끼워 넣으면서 빈 줄 없이 line 474-476 의 struct doc 과 이어졌습니다. 결과적으로 rustdoc 은 두 doc-comment 를 합쳐서 `ImagePipeline` 의 문서로 인식하고, `ingest_one_asset` 자체는 doc 이 없는 상태가 됩니다. 수정: ```rust type SqliteStoreAlias = kebab_store_sqlite::SqliteStore; /// P6-4: borrowed bundle of the three image-pipeline components built /// once per ingest invocation. Threaded through `ingest_one_asset` so /// the dispatch does not need ten separate parameters. struct ImagePipeline<'a> { ... } /// Process a single asset: read bytes, parse, normalize, chunk, /// persist, embed. Per-asset failures bubble up to the caller for /// labelling as `IngestItemKind::Error` — they do NOT abort the /// whole run. #[allow(clippy::too_many_arguments)] fn ingest_one_asset(...) ``` 원래 doc 을 fn 위치로 되돌리고 struct 의 P6-4 doc 만 struct 위에 두는 형태.
@@ -615,0 +747,4 @@
if !canonical.blocks.is_empty() {
// P6-1 contract: image documents always have exactly one
// `Block::ImageRef`. Defensive match keeps us forward-compatible.
if let Some(Block::ImageRef(block)) = canonical.blocks.first_mut() {

if let Some(Block::ImageRef(block)) = canonical.blocks.first_mut() 가 None 이거나 다른 Block variant 면 silent skip — Provenance event 도 warning 도 안 남기고 OCR/caption 단계가 통째로 무력화됩니다.

P6-1 contract 가 "image document 는 항상 단일 ImageRef block" 이라 현재 production 에선 unreachable 이지만, 미래 task 가 multi-block image document 를 도입하면 이 guard 가 silent failure 로 바뀝니다. 최소한 else 분기에서 tracing::warn! 또는 panic-with-context 를 권장합니다:

match canonical.blocks.first_mut() {
    Some(Block::ImageRef(block)) => { /* OCR + caption */ }
    other => {
        tracing::warn!(
            target: "kebab-app",
            path = %asset.workspace_path.0,
            blocks = canonical.blocks.len(),
            first_kind = ?other.map(|b| std::mem::discriminant(b)),
            "image document missing ImageRef block — OCR/caption skipped"
        );
    }
}

Provenance Warning 까지 추가하면 운영에서 잡기 더 쉽습니다.

`if let Some(Block::ImageRef(block)) = canonical.blocks.first_mut()` 가 None 이거나 다른 `Block` variant 면 silent skip — Provenance event 도 warning 도 안 남기고 OCR/caption 단계가 통째로 무력화됩니다. P6-1 contract 가 "image document 는 항상 단일 ImageRef block" 이라 현재 production 에선 unreachable 이지만, 미래 task 가 multi-block image document 를 도입하면 이 guard 가 silent failure 로 바뀝니다. 최소한 `else` 분기에서 `tracing::warn!` 또는 panic-with-context 를 권장합니다: ```rust match canonical.blocks.first_mut() { Some(Block::ImageRef(block)) => { /* OCR + caption */ } other => { tracing::warn!( target: "kebab-app", path = %asset.workspace_path.0, blocks = canonical.blocks.len(), first_kind = ?other.map(|b| std::mem::discriminant(b)), "image document missing ImageRef block — OCR/caption skipped" ); } } ``` Provenance Warning 까지 추가하면 운영에서 잡기 더 쉽습니다.
@@ -615,0 +762,4 @@
path = %asset.workspace_path.0,
"{}",
note
);

OCR 실패 분기와 caption 실패 분기 (line 754-773 + 776-799) 가 거의 동일 boilerplate 입니다 — variable 이름 (ocr_failed: vs caption_failed:) 만 다르고, 나머지 (tracing::warn / push ProvenanceEvent / push warning_notes) 는 동일.

중복 ~25줄을 헬퍼로 뽑으면 두 호출이 한 줄로 줄고, 미래에 "Warning event 에 다른 필드 추가" 같은 변경이 한 곳만 손보면 끝납니다:

fn record_image_analysis_failure(
    asset: &RawAsset,
    canonical: &mut CanonicalDocument,
    warning_notes: &mut Vec<String>,
    stage: &str,                        // "ocr_failed" / "caption_failed"
    err: anyhow::Error,
) {
    let note = format!("{stage}: {err:#}");
    tracing::warn!(
        target: "kebab-app",
        path = %asset.workspace_path.0,
        "{}",
        note
    );
    canonical.provenance.events.push(ProvenanceEvent {
        at: OffsetDateTime::now_utc(),
        agent: "kb-app".to_string(),
        kind: ProvenanceKind::Warning,
        note: Some(note.clone()),
    });
    warning_notes.push(note);
}

호출부:

if let Err(e) = apply_ocr(...) {
    record_image_analysis_failure(asset, &mut canonical, &mut warning_notes, "ocr_failed", e);
}
OCR 실패 분기와 caption 실패 분기 (line 754-773 + 776-799) 가 거의 동일 boilerplate 입니다 — variable 이름 (`ocr_failed:` vs `caption_failed:`) 만 다르고, 나머지 (tracing::warn / push ProvenanceEvent / push warning_notes) 는 동일. 중복 ~25줄을 헬퍼로 뽑으면 두 호출이 한 줄로 줄고, 미래에 "Warning event 에 다른 필드 추가" 같은 변경이 한 곳만 손보면 끝납니다: ```rust fn record_image_analysis_failure( asset: &RawAsset, canonical: &mut CanonicalDocument, warning_notes: &mut Vec<String>, stage: &str, // "ocr_failed" / "caption_failed" err: anyhow::Error, ) { let note = format!("{stage}: {err:#}"); tracing::warn!( target: "kebab-app", path = %asset.workspace_path.0, "{}", note ); canonical.provenance.events.push(ProvenanceEvent { at: OffsetDateTime::now_utc(), agent: "kb-app".to_string(), kind: ProvenanceKind::Warning, note: Some(note.clone()), }); warning_notes.push(note); } ``` 호출부: ```rust if let Err(e) = apply_ocr(...) { record_image_analysis_failure(asset, &mut canonical, &mut warning_notes, "ocr_failed", e); } ```
@@ -615,0 +764,4 @@
note
);
canonical.provenance.events.push(kebab_core::ProvenanceEvent {
at: time::OffsetDateTime::now_utc(),

OCR 실패 분기와 caption 실패 분기가 각각 자기 time::OffsetDateTime::now_utc() 를 호출합니다 (line 767, 792). spec p6-4 의 Risks/notes 섹션 "Determinism stress" 항목이 명시한 대로, P6-1 의 ImageExtractor::extract 가 이미 Discovered + Parsed 두 이벤트에 단일 now() 를 공유하므로, OCR/caption Warning 이벤트도 같은 처리를 해야 "한 자산 안의 Provenance timestamp 들이 자연스럽게 같이 묶임" 이 됩니다.

fn 진입 시 한 번 let now = OffsetDateTime::now_utc(); 캐시 후 두 분기에서 재사용 권장 (위에서 제안한 헬퍼 함수와 결합하면 자연스럽게 한 곳).

spec 본문이 명시적으로 "this task's wiring must not introduce a second now() call between extract and apply_ocr/caption" 라고 했는데 두 번 호출되고 있어 spec 약속과 어긋납니다.

OCR 실패 분기와 caption 실패 분기가 각각 자기 `time::OffsetDateTime::now_utc()` 를 호출합니다 (line 767, 792). spec p6-4 의 Risks/notes 섹션 "Determinism stress" 항목이 명시한 대로, P6-1 의 `ImageExtractor::extract` 가 이미 `Discovered` + `Parsed` 두 이벤트에 단일 now() 를 공유하므로, OCR/caption Warning 이벤트도 같은 처리를 해야 "한 자산 안의 Provenance timestamp 들이 자연스럽게 같이 묶임" 이 됩니다. fn 진입 시 한 번 `let now = OffsetDateTime::now_utc();` 캐시 후 두 분기에서 재사용 권장 (위에서 제안한 헬퍼 함수와 결합하면 자연스럽게 한 곳). spec 본문이 명시적으로 "this task's wiring must not introduce a second `now()` call between extract and apply_ocr/caption" 라고 했는데 두 번 호출되고 있어 spec 약속과 어긋납니다.
@@ -615,0 +769,4 @@
kind: kebab_core::ProvenanceKind::Warning,
note: Some(note.clone()),
});
warning_notes.push(note);

이미지 경로의 IngestItem.warnings 형식 ("ocr_failed: <err>", "caption_failed: <err>") 이 markdown 경로의 형식 (format!("{:?}: {}", w.kind, w.note) — 예: "MalformedFrontmatter: missing closing fence") 과 다릅니다. 같은 wire 필드를 두 갈래의 다른 컨벤션이 채우면 downstream consumer (예: kebab inspect doc --json 의 reader) 가 "warnings 안의 prefix 가 무엇을 뜻하는지" 조건문을 두 번 작성해야 합니다.

두 가지 정리 방향:

  1. (선호) image 측을 markdown 컨벤션에 맞춤 — format!("{:?}: {}", warning_kind, note) 같은 pseudo-WarningKind 변형, 또는 이미지용 WarningKind 변형을 kebab-parse-types::WarningKind 에 추가해 정식 사유 코드화.
  2. markdown 컨벤션을 image-style 로 단순화 — format!("{prefix}: {note}"). 다만 이 쪽은 기존 frozen contract 변경이라 위험.

1번이 자연스러우며, WarningKind::OcrFailed / WarningKind::CaptionFailed 두 enum 추가는 P6-4 hotfix 또는 본 PR scope 안에서도 가능. 어느 쪽이든 "같은 필드는 같은 형식" invariant 회복 권장.

이미지 경로의 `IngestItem.warnings` 형식 (`"ocr_failed: <err>"`, `"caption_failed: <err>"`) 이 markdown 경로의 형식 (`format!("{:?}: {}", w.kind, w.note)` — 예: `"MalformedFrontmatter: missing closing fence"`) 과 다릅니다. 같은 wire 필드를 두 갈래의 다른 컨벤션이 채우면 downstream consumer (예: `kebab inspect doc --json` 의 reader) 가 "warnings 안의 prefix 가 무엇을 뜻하는지" 조건문을 두 번 작성해야 합니다. 두 가지 정리 방향: 1. (선호) image 측을 markdown 컨벤션에 맞춤 — `format!("{:?}: {}", warning_kind, note)` 같은 pseudo-WarningKind 변형, 또는 이미지용 `WarningKind` 변형을 `kebab-parse-types::WarningKind` 에 추가해 정식 사유 코드화. 2. markdown 컨벤션을 image-style 로 단순화 — `format!("{prefix}: {note}")`. 다만 이 쪽은 기존 frozen contract 변경이라 위험. 1번이 자연스러우며, `WarningKind::OcrFailed` / `WarningKind::CaptionFailed` 두 enum 추가는 P6-4 hotfix 또는 본 PR scope 안에서도 가능. 어느 쪽이든 "같은 필드는 같은 형식" invariant 회복 권장.
altair823 added 1 commit 2026-05-02 07:42:47 +00:00
- src/lib.rs:
  • `ingest_one_asset` 의 doc-comment 가 새 `ImagePipeline` struct 와
    합쳐지던 (rustdoc 가 두 doc 을 struct 의 것으로 합치던) 문제
    해소 — 두 doc-comment 위치 교환 + 빈 줄 분리.
  • `if let Some(Block::ImageRef(...)) = blocks.first_mut()` 의
    silent-skip 분기를 `match` 의 `other` arm 으로 명시 — 미래에
    P6-1 contract 가 깨지면 `tracing::warn!` + Provenance Warning +
    `IngestItem.warnings` 에 \"ImageDispatchAnomaly\" 노트로 즉시
    가시화. 운영 디버깅 단서 제공.
  • OCR 실패 분기 + caption 실패 분기의 ~25줄 boilerplate 를
    `record_image_analysis_failure` 헬퍼로 추출 — 두 호출이 한 줄로
    줄고 미래 ProvenanceEvent 필드 변경이 한 곳에서 끝남.
  • 분석 단계 Warning 이벤트가 fn 진입 시 캡처한 단일
    `OffsetDateTime::now_utc()` 를 공유 — spec Risks/notes 의
    \"Determinism stress: must not introduce a second `now()` call
    between extract and apply_ocr/caption\" 약속 회복.
  • 경고 라벨을 markdown 경로의 `WarningKind` 컨벤션 (`{kind}: {note}`)
    에 맞춤 — `\"ocr_failed: ...\"` → `\"OcrFailed: ...\"`,
    `\"caption_failed: ...\"` → `\"CaptionFailed: ...\"`. 같은 wire
    필드 (`IngestItem.warnings`) 가 두 갈래의 다른 형식을 갖던
    inconsistency 해소.
- tests/image_pipeline.rs:
  • 회귀 테스트의 \"ocr_failed\" assertion 을 \"OcrFailed\" 로 갱신.

cargo test -p kebab-app -p kebab-chunk — 전부 pass.
cargo clippy --workspace --all-targets -- -D warnings — pass.
claude-reviewer-01 approved these changes 2026-05-02 07:43:41 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 의 5개 항목 (doc-comment 위치, silent-skip 분기 가시화, OCR/caption boilerplate 추출, 단일 now 공유, Warning 형식 markdown 컨벤션 통일) 모두 정확히 반영되었습니다. 추가 actionable 한 지적이 보이지 않습니다.

전체 회차 요약:

  • 회차 1: 5건 (구조적 결함 2 + 중복/spec 약속 위반 2 + 일관성 1).
  • 회차 2: APPROVE.

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

#36

"Delete branch after merge" 체크 권장. 머지 후 git fetch -p + git branch -d feat/p6-4-image-ingest-wiring 로 정리.

P6 phase epic 완료 — P6-1 (extractor + EXIF) / P6-2 (OCR) / P6-3 (caption) / P6-4 (ingest wiring) 4 컴포넌트 모두 머지 시 image ingestion 파이프라인 종결. 다음 후보 — P7 (PDF) 또는 P8 (audio).

회차 2 — 회차 1 의 5개 항목 (doc-comment 위치, silent-skip 분기 가시화, OCR/caption boilerplate 추출, 단일 now 공유, Warning 형식 markdown 컨벤션 통일) 모두 정확히 반영되었습니다. 추가 actionable 한 지적이 보이지 않습니다. 전체 회차 요약: - 회차 1: 5건 (구조적 결함 2 + 중복/spec 약속 위반 2 + 일관성 1). - 회차 2: APPROVE. P6-4 implementation 머지 가능합니다. 사용자가 Gitea UI 에서 머지해 주세요: http://gitea.altair823.xyz/altair823-org/kebab/pulls/36 \"Delete branch after merge\" 체크 권장. 머지 후 `git fetch -p` + `git branch -d feat/p6-4-image-ingest-wiring` 로 정리. **P6 phase epic 완료** — P6-1 (extractor + EXIF) / P6-2 (OCR) / P6-3 (caption) / P6-4 (ingest wiring) 4 컴포넌트 모두 머지 시 image ingestion 파이프라인 종결. 다음 후보 — P7 (PDF) 또는 P8 (audio).

(칭찬) let now = OffsetDateTime::now_utc(); 한 줄을 fn 진입부에 캐시해서 OCR / caption / Anomaly 세 Warning 이벤트가 모두 같은 timestamp 를 공유하도록 만든 점이 spec 의 "Determinism stress" 약속을 정확히 회복했습니다. kb-normalize::build_canonical_documentnow 공유 패턴과 동일한 톤이라 reader 가 두 모듈 사이에서 같은 invariant 를 인지하기 쉬워졌습니다.

(칭찬) `let now = OffsetDateTime::now_utc();` 한 줄을 fn 진입부에 캐시해서 OCR / caption / Anomaly 세 Warning 이벤트가 모두 같은 timestamp 를 공유하도록 만든 점이 spec 의 "Determinism stress" 약속을 정확히 회복했습니다. `kb-normalize::build_canonical_document` 의 `now` 공유 패턴과 동일한 톤이라 reader 가 두 모듈 사이에서 같은 invariant 를 인지하기 쉬워졌습니다.

(칭찬) silent-skip 이던 다른-Block-variant 분기를 matchother arm 으로 명시하고 tracing::warn! + Provenance Warning + IngestItem.warnings 셋 모두에 "ImageDispatchAnomaly" 흔적을 남기도록 정리한 게 좋습니다. P6-1 contract 가 미래에 깨질 때 운영자가 즉시 단서를 잡습니다.

(칭찬) silent-skip 이던 다른-Block-variant 분기를 `match` 의 `other` arm 으로 명시하고 `tracing::warn!` + Provenance Warning + `IngestItem.warnings` 셋 모두에 "ImageDispatchAnomaly" 흔적을 남기도록 정리한 게 좋습니다. P6-1 contract 가 미래에 깨질 때 운영자가 즉시 단서를 잡습니다.

(칭찬) record_image_analysis_failuretracing::warn! + Provenance Warning + IngestItem.warnings 세 채널에 동시에 기록을 흩뿌리던 두 분기의 boilerplate 를 한 곳에 모았습니다. 매개변수에 now: OffsetDateTime 를 명시적으로 받음으로써 spec Risks 의 "한 자산의 분석-단계 Warning 들은 같은 timestamp 공유" 의도를 호출자가 잊을 수 없게 강제하는 게 좋은 디자인입니다.

(칭찬) `record_image_analysis_failure` 가 `tracing::warn!` + Provenance Warning + `IngestItem.warnings` 세 채널에 동시에 기록을 흩뿌리던 두 분기의 boilerplate 를 한 곳에 모았습니다. 매개변수에 `now: OffsetDateTime` 를 명시적으로 받음으로써 spec Risks 의 "한 자산의 분석-단계 Warning 들은 같은 timestamp 공유" 의도를 호출자가 잊을 수 없게 강제하는 게 좋은 디자인입니다.
altair823 added 1 commit 2026-05-02 08:13:45 +00:00
수동 스모크 검증 (12 PNG + 손상 PNG) 중 발견. `IngestReport.errors`
가 자산 한 장당 2회 증가해서 `scanned = new + updated + skipped +
errors` invariant 가 깨짐:

- `garbage.png` (이미지 아닌 바이트, .png 확장자만) 1장 + 정상 자산
  3장 → 기대 `scanned=4 errors=1`, 실제 `scanned=4 errors=2`.
- 원인: `match item { Err(e) => { error_count += 1; IngestItem {...} }
  }` 에서 1회 증가 후, 직후 `match item.kind { Error => { error_count
  += 1 } }` arm 에서 또 1회 증가.
- markdown 경로의 `ingest_one_asset` Err 가 거의 발생 안 해서 P6-4
  머지 전까지 표면화 안 됐던 기존 결함. 이미지 dispatch 가 garbage
  bytes 를 Err 로 흘려보내며 처음으로 노출.

수정: `Err(e)` 분기의 `error_count.saturating_add(1)` 제거. 단일
증가 지점은 `match item.kind { Error => ... }` arm. 코멘트로 의도
명시.

회귀 테스트 추가 (`tests/image_pipeline.rs`):
- `garbage_png_increments_errors_counter_exactly_once` — 정확히 1
  증가 + `scanned == new + updated + skipped + errors` invariant
  검증.

검증 — release binary + 실 Ollama (192.168.0.47 / gemma4:e4b):

```
$ kebab --json ingest
scanned=4 new=3 updated=0 skipped=0 errors=1
  error    garbage.png       (extract Err — unrecognised format)
  new      intro.md
  new      normal.png        (OCR success)
  new      truncated.png     (OcrFailed warning, asset still indexed)
```

cargo test --workspace --no-fail-fast -j 1 — 전부 pass.
cargo clippy --workspace --all-targets -- -D warnings — pass.
cargo test -p kebab-app --test image_pipeline — 6 pass (5 기존 + 1 회귀).
claude-reviewer-01 approved these changes 2026-05-02 08:14:49 +00:00
claude-reviewer-01 left a comment
Member

회차 3 — 사용자 요청 수동 스모크 검증 (시나리오 1: 12 PNG 동시 ingest, 시나리오 2: 손상 PNG errors) 진행 중 기존 코드의 IngestReport.errors double-count 결함 발견. 같은 PR 안에서 fix + 회귀 테스트.

시나리오 1 (release binary + 실 Ollama):

  • 12 PNG (각각 다른 OCR 텍스트) + 1 markdown → scanned=13 new=13 errors=0 (24.9초, 자산당 약 1.9초)
  • search lexical / vector / hybrid 모두 자산별 정확 매칭

시나리오 2 (release binary):

  • garbage.png (이미지 아닌 27 byte) + truncated.png + normal.png + intro.md
  • 1차: scanned=4 errors=2 비정상 → double-count 의심 → 코드 검증으로 확정 (Err 분기 + Error arm 양쪽에서 증가)
  • Fix: Err 분기의 증가 제거 후 단일 증가 지점만 유지
  • 회귀 테스트 garbage_png_increments_errors_counter_exactly_once 추가 (errors == 1 + scanned 산술 invariant)
  • 2차: scanned=4 new=3 errors=1 정확
  • OcrFailed prefix 도 R1 fix 반영 확인

테스트 6 pass + workspace pass + clippy pass. 머지 가능.

#36

회차 3 — 사용자 요청 수동 스모크 검증 (시나리오 1: 12 PNG 동시 ingest, 시나리오 2: 손상 PNG errors) 진행 중 기존 코드의 IngestReport.errors double-count 결함 발견. 같은 PR 안에서 fix + 회귀 테스트. 시나리오 1 (release binary + 실 Ollama): - 12 PNG (각각 다른 OCR 텍스트) + 1 markdown → scanned=13 new=13 errors=0 (24.9초, 자산당 약 1.9초) - search lexical / vector / hybrid 모두 자산별 정확 매칭 시나리오 2 (release binary): - garbage.png (이미지 아닌 27 byte) + truncated.png + normal.png + intro.md - 1차: scanned=4 errors=2 비정상 → double-count 의심 → 코드 검증으로 확정 (Err 분기 + Error arm 양쪽에서 증가) - Fix: Err 분기의 증가 제거 후 단일 증가 지점만 유지 - 회귀 테스트 garbage_png_increments_errors_counter_exactly_once 추가 (errors == 1 + scanned 산술 invariant) - 2차: scanned=4 new=3 errors=1 정확 - OcrFailed prefix 도 R1 fix 반영 확인 테스트 6 pass + workspace pass + clippy pass. 머지 가능. http://gitea.altair823.xyz/altair823-org/kebab/pulls/36
@@ -242,3 +275,3 @@
"kb-app::ingest: per-file fatal"
);
error_count = error_count.saturating_add(1);
// Note: `error_count += 1` happens below in the

(칭찬) 사용자가 요청한 "다중 이미지 + 손상 PNG" 수동 검증 중 발견된 IngestReport.errors double-count 결함을 같은 PR 안에서 fix + 회귀 테스트화 했습니다. P6-4 본 task scope 외였지만 image dispatch 가 Err 분기를 처음으로 자주 trigger 시키면서 표면화한 기존 결함이라 같은 PR 에서 처리하는 게 정직합니다. 회귀 테스트의 scanned == new + updated + skipped + errors invariant 검증은 향후 카운터 컨벤션이 다시 깨지는 걸 방지합니다.

(칭찬) 사용자가 요청한 "다중 이미지 + 손상 PNG" 수동 검증 중 발견된 `IngestReport.errors` double-count 결함을 같은 PR 안에서 fix + 회귀 테스트화 했습니다. P6-4 본 task scope 외였지만 image dispatch 가 `Err` 분기를 처음으로 자주 trigger 시키면서 표면화한 기존 결함이라 같은 PR 에서 처리하는 게 정직합니다. 회귀 테스트의 `scanned == new + updated + skipped + errors` invariant 검증은 향후 카운터 컨벤션이 다시 깨지는 걸 방지합니다.
@@ -0,0 +305,4 @@
dims.get("h").and_then(|v: &serde_json::Value| v.as_u64()),
Some(50)
);
}

(칭찬) garbage_png_increments_errors_counter_exactly_once 가 단순 "errors == 1" 검증을 넘어 IngestReport 의 산술 invariant (scanned = new + updated + skipped + errors) 까지 박은 점이 좋습니다. 이 invariant 는 카운터 종류가 늘어나도 (예: 미래에 image_ocr_failed 카운터 추가 시) 자연스럽게 회귀를 잡아 줍니다.

(칭찬) `garbage_png_increments_errors_counter_exactly_once` 가 단순 "errors == 1" 검증을 넘어 IngestReport 의 산술 invariant (`scanned = new + updated + skipped + errors`) 까지 박은 점이 좋습니다. 이 invariant 는 카운터 종류가 늘어나도 (예: 미래에 `image_ocr_failed` 카운터 추가 시) 자연스럽게 회귀를 잡아 줍니다.
altair823 merged commit fd89777c83 into main 2026-05-02 08:22:27 +00:00
altair823 deleted branch feat/p6-4-image-ingest-wiring 2026-05-02 08:22:28 +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#36