feat(kebab-parse-image): P6-2 OCR adapter — Ollama-vision default #33
Reference in New Issue
Block a user
Delete Branch "feat/p6-2-ocr-adapter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
P6-2 —
OcrEnginetrait +OllamaVisionOcr기본 어댑터 +apply_ocr헬퍼.kebab-config에ImageCfg.ocr블록 추가.contract:
docs/superpowers/specs/2026-04-27-kebab-final-form-design.md§3.4 (ImageRefBlock.ocr), §3.7a (OcrText/OcrRegion), §9.1 (OCR vs caption provenance).Spec deviation — Tesseract → Ollama-vision
원본 spec 은 Tesseract 를 default OCR 엔진으로 지정했지만, 본 dev / CI 환경은
libtesseract-dev시스템 패키지 설치를 의도적으로 피합니다 (single-developer local-first KB). 같은 호스트에 이미 P4-2 가 Ollama 비전 엔드포인트를 wire 해 둔 상황을 살려서 default 를OllamaVisionOcr로 교체.OcrEnginetrait 추상화는 spec 그대로 보존 — Tesseract / Apple Vision / PaddleOCR 어댑터는 같은 trait 으로 추후 feature-gate 추가 가능. 전체 deviation 기록:tasks/HOTFIXES.md2026-05-02 항목.동작 계약
OcrEnginetrait:engine_name()/engine_version()/recognize(bytes, lang_hint) → OcrText.OllamaVisionOcr:<endpoint>/api/generate비스트리밍 호출 (stream: false),images: [base64]필드로 이미지 전달.config.image.ocr.languages화이트리스트 명시.OcrText.joined로, prepared image 전체 영역 단일OcrRegion(bbox=(0,0,w,h), confidence=1.0)으로 wrap.joined=\"\",regions=[](Err 아님).engine = \"ollama-vision\",engine_version = \"ollama/<model>\".config.models.llm.endpoint로 fallback (같은 Ollama 호스트가 LLM + OCR 동시 서비스).config.image.ocr.max_pixels초과 시 PNG 로 재인코딩 (image::imageops::resizeTriangle filter, aspect ratio 보존).max_pixels는 256~4096 으로 클램프 (구성 오류 방어).apply_ocr:block.ocr = Some(text)+ProvenanceKind::OcrApplied이벤트 1건 (agent=\"kb-parse-image\", note 에 engine / version / region count).block.ocr는None그대로 +events미기록 — 부분 상태 누출 금지.kebab-config
신규
ImageCfg.ocr: OcrCfg:enabledfalseengine\"ollama-vision\"model\"gemma4:e4b\"endpoint\"\"models.llm.endpointfallback.languages[\"eng\", \"kor\"]max_pixels1600#[serde(default)]로 pre-P6 TOML 호환.KEBAB_IMAGE_OCR_{ENABLED,ENGINE,MODEL,ENDPOINT,LANGUAGES,MAX_PIXELS}환경변수 5종 (LANGUAGES는 콤마 구분).Trust note
원본 spec 은
OcrText를 "observed text (high trust)" 로 분류해ModelCaption과 구분했는데, vision LM 기반 default 는 hallucinate 가능 — 경계가 흐려집니다.OcrText.engine필드를 통해 consumer 가 엔진 정체로 신뢰 분기 가능 (Tesseract/Apple Vision 어댑터가 추가되면 다른 engine string 으로 emit).의존성
kebab-config,serde,reqwest 0.12 (blocking + json + rustls-tls),base64 0.22. 워크스페이스의kebab-llm-local과 동일한 reqwest feature 세트 → TLS 백엔드 / tokio 런타임 공유.wiremock,tokio (rt-multi-thread),ab_glyph 0.2(통합 테스트 fixture 용).kebab-llm*/kebab-source-fs/kebab-store-*/kebab-search/kebab-rag/ UI crate 모두 미참조.테스트
cargo test -p kebab-parse-image— 28건 (8 unit + 12 P6-1 integration + 8 P6-2 integration) pass + 1 ignored.apply_ocr→ block.ocr Some + Provenance OcrApplied 1건apply_ocr실패 → block.ocr None 유지 + events 미기록from_partsmax_pixels 클램프src/ocr.rs): truncate, build_prompt (언어 / 힌트 / und 처리)KEBAB_OCR_INTEGRATION=1 KEBAB_IMAGE_OCR_ENDPOINT=http://192.168.0.47:11434 cargo test -p kebab-parse-image --test ocr ocr_integration -- --ignored. 실제gemma4:e4b로 "Hello World 2026" 전사 확인 (4.5초).cargo test -p kebab-config— 20건 (+3 신규: defaults, env override, pre-P6 TOML 호환) pass.cargo clippy --workspace --all-targets -- -D warningspass.Test plan
🤖 Generated with Claude Code
- 새 모듈 `crates/kebab-parse-image/src/ocr.rs` 추가. spec 의 `OcrEngine` trait 그대로 + `OllamaVisionOcr` default 구현 + `apply_ocr` 헬퍼. - `OllamaVisionOcr`: `<endpoint>/api/generate` 비스트리밍 호출, `images: [base64]` 필드로 이미지 전달, 프롬프트는 언어 힌트 + 화이트리스트 언어 목록 포함. 응답 prose 를 `OcrText.joined` 로, prepared image 전체 영역 단일 region (confidence 1.0) 으로 wrap. 기본 모델 `gemma4:e4b`. endpoint 비어 있으면 `models.llm.endpoint` 로 fallback. - 이미지 전처리: long-edge `config.image.ocr.max_pixels` (기본 1600, 256~4096 클램프) 초과 시 PNG 로 재인코딩 (image::imageops::resize, Triangle filter). PNG 입력이 max 이내면 zero-copy passthrough. - `apply_ocr` 는 OCR 성공 시 block.ocr 를 Some 으로 채우고 ProvenanceKind::OcrApplied 이벤트 추가. 실패 시 block.ocr 는 None 그대로 + provenance 미기록 (부분 상태 누출 금지). - `kebab-config`: 새 `ImageCfg.ocr: OcrCfg` 블록 (enabled/engine/model /endpoint/languages/max_pixels). `#[serde(default)]` 로 pre-P6 TOML 호환. `KEBAB_IMAGE_OCR_*` 환경변수 5종 추가. ## Spec deviation 원래 P6-2 spec 은 Tesseract 를 default OCR 엔진으로 지정했으나, dev / CI 호스트에서 `libtesseract-dev` 시스템 패키지 설치를 피하려고 Ollama-vision 으로 default 를 교체. `OcrEngine` trait 추상화는 spec 그대로 보존 — Tesseract / Apple Vision / PaddleOCR 어댑터는 같은 trait 으로 추후 feature-gate 추가 가능. 자세한 내역은 `tasks/HOTFIXES.md` 2026-05-02 항목 참조. Trust 측면: vision LM 은 hallucinate 가능. `OcrText.engine = "ollama-vision"` 필드로 consumer 가 엔진 별 신뢰 분기 가능. ## 테스트 - 신규 (`tests/ocr.rs`, 8 + 1 ignored): - 200 happy → OcrText 디코딩 (joined / engine / engine_version / region count / bbox / confidence) - 빈 응답 → 빈 regions - 5xx → Err with status + body 포함 - 200 error envelope → Err - apply_ocr → block.ocr Some + Provenance OcrApplied 1건 - apply_ocr error → block.ocr None 유지 + events 미기록 - 4000×3000 PNG → max_pixels=1024 까지 다운스케일, aspect ratio 보존 - from_parts max_pixels 클램프 - opt-in `KEBAB_OCR_INTEGRATION=1` 통합 (실제 192.168.0.47 Ollama `gemma4:e4b` 로 \"Hello World 2026\" 전사 검증 완료) - 신규 (`src/ocr.rs` unit): truncate, build_prompt 언어/힌트 처리 - `kebab-config` 테스트 +3: defaults, env override, pre-P6 TOML 호환 전체: `cargo test -p kebab-parse-image` 28 pass + 1 ignored, `cargo test -p kebab-config` 20 pass, `cargo clippy --workspace --all-targets -- -D warnings` pass. contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md sections: §3.4 ImageRefBlock.ocr, §3.7a OcrText / OcrRegion, §9.1 OCR vs caption provenance.회차 1 — 큰 그림은 깔끔합니다.
OcrEnginetrait 추상화 보존, Ollama-vision default 로의 deviation 을 코드 / 모듈 doc / HOTFIXES.md 세 곳에 일관되게 기록한 점,apply_ocr의 "실패 시 block.ocr 미설정 + provenance 미기록" 부분 상태 누출 방지, wiremock 으로 5xx / 200 에러 envelope / 빈 응답 / 다운스케일까지 분기 모두 커버한 테스트 — 모두 견고합니다. 실제 Ollama 연결 검증까지 마친 것도 안심됩니다.머지 전에 정리하면 좋을 actionable 항목:
OcrCfg.endpoint의 빈 문자열 sentinel —Option<String>으로.OllamaGenerateResponse._otherHashMap 가 dead — 통째로 제거 +serde_json::Valueimport 정리.downscale_to_long_edge가 다운스케일 케이스에서 동일 bytes 를 두 번 디코드 — 한 번 읽고 재사용.OllamaGenerateRequest.images: Vec<&'a str>항상 1원소 —[&'a str; 1]로 알로케이션 회피.from_parts_clamps_max_pixels_into_legal_range가 패닉만 검증 — accessor 추가하거나 제거.hello_world_png의expect메시지 misleading + 통합 테스트가 폰트 부재 시 즉시 panic —Result반환으로 graceful skip.부수:
reqwest/base64등 직접 선언된 deps 는 P6-3 가 같은 dep 을 쓸 텐데[workspace.dependencies]로 끌어올리는 follow-up 권장 (본 PR scope 아님).칭찬 —
OcrEnginetrait 의 doc-comment 가 "observed text vs. generated" trust 분기 의도를 모듈 doc 부터 wire 필드 (engine) 까지 일관되게 짚어 두어, Tesseract / Apple Vision 어댑터가 추후 합류해도 trust 정책이 caller 입장에서 자연스럽게 분기됩니다.@@ -101,0 +139,4 @@/// HTTP endpoint for the OCR engine. Empty string means "fall back/// to `models.llm.endpoint`" — convenient when the same Ollama/// host serves both LLM and vision.pub endpoint: String,OcrCfg.endpoint: String에서 빈 문자열""을 "models.llm.endpoint로 fallback" 시그널로 사용합니다. 의미가 "실제로 빈 endpoint" 와 "명시되지 않음" 을 구분 못 하는 string-typed boolean 입니다.Option<String>으로 바꾸면 의도가 타입에 새겨집니다:TOML 측에선
endpoint키 생략 →None, 명시 →Some(...).OllamaVisionOcr::new의 fallback 로직도match cfg.image.ocr.endpoint.as_deref() { Some(s) if !s.is_empty() => s, _ => &cfg.models.llm.endpoint }로 더 정직해집니다.사소하지만 P6-3 의
caption.endpoint도 같은 길을 갈 텐데, 첫 케이스에서 컨벤션을 잡는 게 비용이 작습니다.@@ -24,0 +28,4 @@# rustls-tls) so both crates share the same TLS backend and the# transitive tokio runtime is brought in once.reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }base64 = "0.22"(칭찬 + 작은 권장)
reqwest/base64/wiremock/tokio/ab_glyph의 feature flag 와 버전을kebab-llm-local과 의식적으로 맞춘 게 좋습니다 — TLS 백엔드 / 런타임이 워크스페이스에서 일관되게 한 번만 빌드됩니다.같은 버전이 두 crate 에서 두 번 선언되어 있으니
Cargo.toml(workspace) 의[workspace.dependencies]로 끌어 올리는 follow-up 을 권장합니다. 본 PR 의 scope 는 아니지만, P6-3 caption adapter 도 같은 reqwest / base64 를 쓸 가능성이 커서 그때까지 묻어 두면 세 crate 분산이 됩니다.@@ -0,0 +300,4 @@/// Bypasses encode work when the source already fits — we simply pass/// the bytes through. PNG re-encode is only paid when downscaling is/// actually needed.fn downscale_to_long_edge(bytes: &[u8], max_long_edge: u32) -> Result<(Vec<u8>, u32, u32)> {downscale_to_long_edge가 다운스케일이 필요한 케이스에서 동일 bytes 를 두 번 읽습니다 — 첫 번째ImageReader로 dims 를 알아내고 (소비), 그다음 새ImageReader를 만들어decode()합니다. 이미지가 큰 경우 (4000×3000) base64 + decode 가 메모리에 두 번 들어옵니다.ImageReader::new(...).with_guessed_format()?.decode()한 번으로DynamicImage를 얻은 다음.dimensions()로 (w, h) 를 빼고 같은 객체에서.resize_exact()까지 처리하면 한 번의 디코드로 끝납니다:사이드 이펙트: PNG passthrough 단축 경로를 잃습니다 (현재는 PNG 이고 size OK 면
bytes.to_vec()로 끝). passthrough 가 hot path 라면 첫 단계에서 format 만 sniff (with_guessed_format만 호출, dims 는 안 봄) 한 다음 분기시키는 절충안도 있습니다. 둘 중 어느 쪽이든 "같은 bytes 를 두 번 디코드" 만 정리되면 충분합니다.@@ -0,0 +361,4 @@struct OllamaGenerateRequest<'a> {model: &'a str,prompt: &'a str,images: Vec<&'a str>,images: Vec<&'a str>가 항상 길이 1 입니다. wire 형태도"images": [<one>]단일 원소 배열로 고정 —Vec알로케이션 +vec![&b64]매크로 호출이 함수 호출당 1회 발생합니다.[&'a str; 1]로 박으면 알로케이션 없이 동일 JSON 출력이 나옵니다:향후 multi-image batch 가 필요해지면 그때
Vec으로 되돌리면 됩니다 (트레이트 시그니처가 단일 이미지를 받기 때문에 batch 는 별개 RFC).@@ -0,0 +379,4 @@#[serde(default)]error: Option<String>,#[serde(flatten)]_other: std::collections::HashMap<String, Value>,_other: HashMap<String, Value>+#[serde(flatten)]가 어떤 호출자에서도 읽히지 않는 dead 필드입니다.OllamaGenerateResponse의 다른 필드들이 ���두#[serde(default)]인데, serde 는 알 수 없는 키를 기본적으로 silently drop 하므로 capture-all 이 굳이 필요하지 않습니다. 통째로 제거해 wire type 을 더 가볍게:그리고
serde_json::Valueimport 도 같이 정리할 수 있습니다.@@ -46,0 +64,4 @@/// `/usr/share/fonts/truetype/dejavu/`) — common across most Linux dev/// boxes. Falls back to a tiny built-in glyph map if the font is/// missing so the helper compiles even without DejaVu installed.pub fn hello_world_png() -> Vec<u8> {hello_world_png가/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf를 강제로 읽고 panic 메시지로 "DejaVu Sans Bold required for OCR integration fixture" 를 띄웁니다. 그런데 실제로 이 함수는#[ignore]가 붙은 통합 테스트 한 곳에서만 호출되고, 일반 hermetic 테스트는 절대 부르지 않습니다.패닉 메시지가 "전반적으로 DejaVu 가 필요" 처럼 들려서 misleading 합니다. 두 가지 중 하나를 권장:
Result<Vec<u8>, _>를 반환하게 바꾸고, 통합 테스트가 폰트 부재 시 우아하게 skip:현재 통합 테스트가 dev 박스에서 동작 검증된 상태라 1번이 더 안전한 선택입니다 (다른 dev 박스의 폰트 부재가 즉시 panic 으로 가는 걸 방지).
@@ -0,0 +320,4 @@// ── from_parts construction ──────────────────────────────────────────────#[test]fn from_parts_clamps_max_pixels_into_legal_range() {from_parts_clamps_max_pixels_into_legal_range테스트는 "패닉 안 났음" 만 검증합니다 — 코멘트도 "can't read the private field directly" 라 인정. 두 가지 정리 방향이 있습니다:OllamaVisionOcr에pub fn max_pixels(&self) -> u32같은 inspector accessor 를 추가해 실제 클램프 결과 (MIN_LONG_EDGE/MAX_LONG_EDGE와 동일) 를 검증.ocr_downscales_large_image_before_sending가 max_pixels=1024 로 간접 검증하고 있고, 이 추가 테스트는 회귀 신호를 거의 못 줌.어느 쪽이든 "패닉 안 났음 만 보는" 현 상태는 머지 전에 정리하면 좋겠습니다.
- crates/kebab-config/src/lib.rs: • `OcrCfg.endpoint: String` (\"\" sentinel) → `Option<String>` 으로 교체. `#[serde(default)]` 적용. `KEBAB_IMAGE_OCR_ENDPOINT=\"\"` (빈 값) 도 None 으로 매핑하는 분기 추가. • 신규 회귀 테스트 `image_ocr_endpoint_empty_env_value_is_none`. - crates/kebab-parse-image/src/ocr.rs: • `OllamaVisionOcr::new` 의 endpoint fallback 로직을 새 `Option<String>` 스키마에 맞춰 정리 (`as_deref` + match). • `OllamaGenerateResponse` 의 dead `_other: HashMap<String, Value>` 필드 제거. `serde_json::Value` import 도 같이 정리. • `OllamaGenerateRequest.images: Vec<&'a str>` → `[&'a str; 1]` (호출당 vec! 알로케이션 제거, multi-image 는 OcrEngine trait 가 단일 이미지를 받으므로 OOS). • `downscale_to_long_edge` 단일-디코드로 리팩터. PNG passthrough hot path 보존 (header sniff 만으로 분기), 그 외 모든 경로는 decode 1회 + (필요 시) resize + PNG re-encode 1회로 통일. • `pub fn max_pixels(&self) -> u32` accessor 추가 — clamp 결과 검증 용 (단순 inspector). - crates/kebab-parse-image/tests/ocr.rs: • `cfg_for_endpoint` / 통합 테스트가 `Some(endpoint)` 형태로 갱신. • `from_parts_clamps_max_pixels_into_legal_range` 가 새 accessor 로 실제 클램프 결과 (256 / 4096 / 1024) 를 검증하도록 강화. • 통합 테스트가 폰트 부재 시 panic 대신 skip 하도록 분기. - crates/kebab-parse-image/tests/common/mod.rs: • `hello_world_png` 가 `anyhow::Result<Vec<u8>>` 반환하도록 변경. expect(\"DejaVu Sans Bold required\") 메시지를 \"only the opt-in OCR integration fixture needs this font\" 로 의도 명확화. cargo test -p kebab-parse-image — 28 pass + 1 ignored. cargo test -p kebab-config — 21 pass (+1 회귀). cargo clippy --workspace --all-targets -- -D warnings — pass. Reviewer-suggested workspace.dependencies 통합 (reqwest / base64) 은 P6-3 와 함께 처리할 수 있도록 follow-up 으로 두고 본 PR scope 에서 제외 (회차 1 본문에서 명시).회차 2 — 회차 1 지적 모두 정확히 반영되었습니다 (
OcrCfg.endpointOption 으로 교체,_otherHashMap 제거, downscale 단일-디코드 + PNG passthrough 보존,images: [&'a str; 1],max_pixelsaccessor + 클램프 회귀 테스트,hello_world_pngResult 반환). pre-P6 TOML 호환 + 빈 env value=None 분기 회귀 테스트도 같이 추가된 게 좋습니다.남은 actionable 항목 4건:
from_parts가 endpoint/model 빈 문자열 가드 없음 —new()와 invariant 갈림.max_pixelsclamp 가 silent — 사용자가 "왜 항상 4096?" 디버깅하기 어려움. tracing::warn 권장.#[ignore]+ env var) — 둘 중 하나로 통일.칭찬 —
downscale_to_long_edge의 단일-디코드 리팩터가 PNG passthrough hot path 를 보존하면서도 의도가 doc-comment 에 명시적으로 박혔습니다 ("PNG sources that already fit the cap are passthrough (zero decodes)"). 이 패턴은 P6-3 caption adapter 가 prompt cost 를 위해 같은 다운스케일을 할 때 그대로 재사용 가능합니다.@@ -0,0 +147,4 @@if model.is_empty() {anyhow::bail!("OllamaVisionOcr: image.ocr.model is empty");}let max_pixels = ocr.max_pixels.clamp(MIN_LONG_EDGE, MAX_LONG_EDGE);max_pixels.clamp(MIN_LONG_EDGE, MAX_LONG_EDGE)가 silent 입니다. 사용자가image.ocr.max_pixels = 99999를 지정하면 4096 으로 조용히 떨어지는데 — 디버깅 시 "왜 내 OCR 이 항상 4096 px 로 떨어지지?" 시나리오가 발생합니다. clamp 가 실제로 발동했을 때만 한 줄 경고:from_parts도 같은 패턴 적용. tracing 은 이미 의존성이라 비용 무시 가능.@@ -0,0 +163,4 @@/// Build directly from explicit fields. Useful for tests that need/// to point at a wiremock host without going through `Config`.pub fn from_parts(from_parts가 endpoint / model 의 빈 문자열 가드를 갖고 있지 않습니다.new()는 빈 endpoint / 빈 model 을 모두bail!로 거절하지만from_parts는 어떤 입력이든 통과합니다. 두 생성자가 invariant 가 다르면 "테스트는 통과하지만 실서비스 코드는 panic" 같은 swarm 이 생기기 쉽습니다.같은 가드를 추가하거나,
from_partsdoc-comment 에 "test-seam — 입력 검증을 caller 가 책임진다" 를 명시해 주세요. 둘 다 짧지만 첫 번째가 더 안전합니다.@@ -0,0 +340,4 @@} else {let scale = max_long_edge as f32 / long as f32;let new_w = ((w as f32) * scale).round().max(1.0) as u32;let new_h = ((h as f32) * scale).round().max(1.0) as u32;downscale 의
(w as f32 * scale).round()두 축이 독립적으로 round 되어 long-edge 가max_long_edge를 1px 초과할 수 있는 코너 케이스가 있습니다.scale = max_long_edge / long이 무리수 (예: max=1601, long=4001) 인 경우 long-axis 도 +1 px 로 round-up 가능. wire 측에선 1px 차이가 의미 없지만, doc-comment 가 "long edge is at most max_long_edge" 라고 단정하므로 실제 동작과 살짝 어긋납니다.간단한 후행 클램프 한 줄로 invariant 정확히 보장:
또는 doc-comment 를 "approximately at most" 로 완화. 둘 중 어느 쪽이든.
@@ -0,0 +359,4 @@#[tokio::test]#[ignore = "hits a real Ollama daemon; gated behind KEBAB_OCR_INTEGRATION=1"]async fn ocr_integration_real_ollama_transcribes_text() {if std::env::var("KEBAB_OCR_INTEGRATION").ok().as_deref() != Some("1") {통합 테스트가 두 겹의 게이트를 가집니다 —
#[ignore](cargo 가 default 로 skip) + 함수 내부의KEBAB_OCR_INTEGRATION != "1"조기 return.#[ignore]만으로 일반cargo test는 이미 skip 되고,--ignored로 실행할 때는 사용자가 명시적으로 의도를 표현한 거니 env var 추가 검증이 "수단의 중복" 처럼 보입니다.둘 중 하나로 통일을 권장:
--ignored가 곧 "실행 의도" 의 단일 신호.#[ignore]제거하고 env var 만으로 게이팅 —cargo test실행 시점에 env 검사로 자동 skip.둘 다 동작은 같지만, 1번이 다른 워크스페이스 통합 테스트 (
kebab-llm-local) 의#[ignore]패턴과 일관됩니다.- src/ocr.rs: • `OllamaVisionOcr::new` 와 `from_parts` 의 입력 검증을 공통 `fn build` 으로 통합. 두 생성자가 빈 endpoint / 빈 model / `max_pixels` 클램프 동일 invariant 를 공유 — \"테스트는 통과하지만 프로덕션은 panic\" 분기 차단. • `max_pixels` clamp 가 실제로 발동 시 `tracing::warn!` 로 사유 기록 (사용자가 \"왜 항상 4096?\" 디버깅 가능). • `downscale_to_long_edge` 의 long-axis 가 `f32` 라운딩으로 1px 초과하는 코너 케이스 (예: max=1601, long=4001) 후행 클램프로 엄격히 묶음. doc-comment 의 \"long edge is at most max_long_edge\" 가 실제 동작과 정확히 일치. - tests/ocr.rs: • 통합 테스트의 이중 게이트 (`#[ignore]` + `KEBAB_OCR_INTEGRATION=1`) 제거. `--ignored` 만으로 실행 의도 단일 신호화 — `kebab-llm-local` 의 통합 테스트 컨벤션과 일관됨. endpoint / model 의 env 오버라이드는 유지. cargo test -p kebab-parse-image — 28 pass + 1 ignored. cargo test -p kebab-config — 21 pass. cargo clippy --workspace --all-targets -- -D warnings — pass.회차 3 — 회차 2 의 모든 지적이 정확히 반영되었습니다 (
fn build공용 검증,tracing::warn!clamp 사유, downscale long-axis 1px 후행 클램프, 통합 테스트 게이트 단일화). 동작 / 안전성 / 일관성 측면에서 더 손볼 곳이 보이지 않습니다.남은 actionable 2건 (cosmetic / 회귀 신호 보강):
fn build의 가드 (endpoint.is_empty(),model.trim().is_empty(),max_pixels클램프) 가 회귀 테스트로 커버되지 않음 — bail 메시지 / 클램프 범위가 미래에 무심코 바뀌어도 그린 머지가 가능. 간단한 unit 테스트 3건 추가 권장.lib.rs의 모듈-레벨 doc 이 OCR 모듈을 한 줄로만 소개. trust 정책 ("OcrText.engine 으로 신뢰 분기") 이 워크스페이스의 핵심이라 lib.rs 부터 한 줄 노출되면 좋겠습니다.칭찬 —
fn build로new/from_parts를 통합한 건 "두 생성자 invariant 갈림" 코드스멜의 정직한 처리였습니다. 향후 Tesseract / Apple Vision 어댑터가 같은 crate 에 합류해도 같은 패턴 (pub fn new(cfg)+pub fn from_parts(...)+fn build(...)) 으로 입력 검증이 한 곳에 있게 됩니다.@@ -9,3 +12,3 @@//!//! Per design §3.4 (Block::ImageRef + ImageRefBlock), §3.7a (OcrText ///! ModelCaption stubs), §9.1 (image extraction policy), §9 (versioning).//! ModelCaption stubs), §9.1 (image extraction policy / OCR vs caption(작은 권장)
lib.rs의 모듈-레벨 doc-comment 가 OCR 어댑터를 소개할 때 "P6-2 adds the [ocr] module" 한 줄로만 짧게 끝납니다.OcrText의 trust 정책 (관찰된 텍스트 vs 모델 생성) 이 본 워크스페이스의 핵심 분류라서lib.rs부터 한 줄 노출되면 lib 사용자가 ocr 모듈 doc 까지 안 들어가도 의도를 빠르게 잡습니다:사소한 doc 변경이지만 P6-3 caption 모듈이
lib.rsdoc 에 합류할 때 같은 톤을 유지하는 단서가 됩니다.@@ -0,0 +192,4 @@languages,max_pixels,})}회차 2 에서 추가된
fn build가드 (endpoint.is_empty()→ bail,model.trim().is_empty()→ bail,max_pixelsclamp + warn) 가 unit/integration 테스트로 커버되지 않습니다. 회귀 신호가 비어 있으면 누군가 미래에bail!메시지나 클램프 범위를 무심코 바꿔도 그린 테스트로 머지됩니다.간단한 회귀 테스트 3건 추가 권장:
첫 두 개는
src/ocr.rs의#[cfg(test)] mod tests안에, 세 번째는 통합 테스트 (tests/ocr.rs) 어느 쪽이든 OK.- src/ocr.rs: • `OllamaVisionOcr` 에 `#[derive(Debug)]` 추가 (test 의 expect_err 바운드 충족용; reqwest::blocking::Client 도 Debug 구현). • 신규 unit 테스트 3건 (`build_rejects_empty_endpoint`, `build_rejects_empty_model_after_trim`, `build_clamps_max_pixels_outside_legal_range`) — 회차 2 에서 추가된 `fn build` 가드의 회귀 신호. - src/lib.rs: • 모듈-레벨 doc-comment 에 OCR 트러스트 정책 한 줄 추가 (\"LLM-driven default can hallucinate; OcrText.engine carries source identity\"). lib 사용자가 ocr 모듈 doc 까지 안 들어가도 의도 캐치 가능. cargo test -p kebab-parse-image — 31 pass + 1 ignored (11 unit + 12 P6-1 integration + 8 P6-2 integration). cargo clippy -p kebab-parse-image --all-targets -- -D warnings — pass.회차 4 — 회차 3 항목 둘 다 정확히 반영되었습니다 (
build가드 회귀 테스트 3건 추가, lib doc-comment 의 trust note). actionable 한 추가 지적이 보이지 않습니다.전체 회차 요약:
OcrCfg.endpointOption 화,_other정리,images: [_; 1], downscale 단일-디코드,from_parts클램프 검증,hello_world_pngResult 화 (5건).fn build공용 가드,tracing::warn!clamp 사유, downscale long-axis 1px 후행 클램프, 통합 테스트 게이트 단일화 (4건).build가드 회귀 테스트 3건, lib doc trust note 1줄 (cosmetic 2건).머지 가능합니다. 사용자가 Gitea UI 에서 머지해 주세요:
#33
"Delete branch after merge" 체크 권장. 로컬 브랜치는 머지 후
git fetch -p+git branch -d feat/p6-2-ocr-adapter로 정리.다음 task: P6-3 caption adapter — 본 PR 의
OllamaVisionOcr가 사용한 패턴 (HTTP+base64+downscale) 을 caption 측에서도 재사용할 가능성이 큽니다. workspace.dependencies 의 reqwest / base64 통합 follow-up 도 P6-3 시작 전후로 묶기 좋습니다.@@ -9,0 +11,4 @@//! [`ImageRefBlock`] in place. Trust note — the LLM-driven default//! can hallucinate; `OcrText.engine` carries the source identity so//! consumers can branch trust by engine (Tesseract / Apple Vision//! adapters, when added, will write a different `engine` string).(칭찬) Trust note 가 lib doc-comment 까지 한 줄 노출됐습니다 —
cargo doc -p kebab-parse-image --open시 사용자가 모듈 트리를 들어가지 않아도 OCR 출력의 신뢰 분류를 즉시 인지하게 됩니다. 워크스페이스의 "observed vs generated" 분류가 P6-3 caption / 미래 PDF OCR 까지 일관되게 흘러가는 첫 도큐먼트 단서입니다.(칭찬)
build_clamps_max_pixels_outside_legal_range가MIN_LONG_EDGE/MAX_LONG_EDGE상수를 직접 참조해 검증값을 박았습니다 (literal 256 / 4096 대신). 향후 누군가 상수를 조정하면 테스트가 자동으로 새 경계와 함께 움직여서 "테스트가 옛 값을 박제하고 있어 잡지 못함" 함정을 회피했습니다. P6-3 caption adapter 의MAX_PIXELS도 같은 패턴으로 갈 만합니다.