회차 1 nit 반영. helper 가 본 PR 안 caller 0 — 모든 사용처가
struct literal 패턴. CLAUDE.md "Don't add abstractions beyond
what the task requires" 룰. 미래 caller 가 필요 시 본인이 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec PR #59 의 §3.8 multi-turn behaviour 구현. RAG facade 가 prior
turns 받아 prompt 에 prepend, retrieval query expansion 적용,
Answer 에 conversation_id / turn_index 채움.
신규 (kebab-core):
- Answer 에 conversation_id (Option<String>) / turn_index (Option<u32>)
field 추가. serde skip_serializing_if 로 single-shot 의 wire
output 변경 0 (기존 외부 wrapper 영향 없음).
- Turn struct (question + answer + citations + created_at).
- RefusalReason::LlmStreamAborted variant.
신규 (kebab-rag):
- AskOpts 에 history (Vec<Turn>) / conversation_id / turn_index 3 field.
- AskOpts::single_shot(mode) helper.
- RagPipeline::ask_with_history(query, history, conversation_id,
turn_index, opts) — combined opts 로 ask 호출.
- expand_query_with_history: history.last() 의 answer 첫 200 자
concat 해 SearchQuery.text 확장 (spec §3.8 의 \"cheap concat\";
LLM-based standalone-question rewriting 은 P+).
- serialize_history + remaining_history_budget_chars: spec 의 priority
enforcement — system+question 필수, retrieved chunks 가 차지한
뒤 남은 char budget 안에서 newest 우선, oldest drop.
- ask 본문: history 가 비어있지 않으면 [이전 대화] 블록을 user
prompt 위에 prepend. Answer 생성 site 3 곳 (정상 / NoChunks /
ScoreGate refuse) 모두 conversation_id / turn_index 채움.
신규 (kebab-store-sqlite):
- refusal_reason_label 가 LlmStreamAborted → 'llm_stream_aborted'.
기존 caller 변경 (single-shot 동작 동일):
- kebab-cli main.rs Cmd::Ask: AskOpts 에 history=Vec::new(),
conversation_id=None, turn_index=None 명시 (CLI multi-turn 은
p9-fb-18 의 --session/--repl 가 채움).
- kebab-tui src/ask.rs spawn site 동일 (multi-turn UI 는 p9-fb-16).
- kebab-eval runner.rs golden eval 동일 (single-shot per query).
- kebab-app tests/ask_smoke.rs / kebab-tui tests/ask.rs / kebab-rag
tests/pipeline.rs / kebab-eval metrics.rs Answer literal 갱신.
Test:
- 9 신규 lib unit (expand_query 4 / serialize_history 3 / remaining_budget 2).
- 기존 12 PASS 회귀 0.
Plan 갱신:
- p9-fb-15 status planned → in_progress. 머지 후 한 줄 commit
으로 completed flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 actionable 2건 반영 + 1건 (CLI Ctrl-C integration test)
은 본 PR 에서 별도 task 로 미룸 (signal handler subprocess test 의
flaky 위험 + facade 3 PASS + tui lib 3 PASS 가 안정 surface).
- cancel.rs::install_sigint_cancel: SIGNAL_COUNT 위에 process-lifetime
invariant 코멘트 — multi-install 차단 (ctrlc::set_handler) 덕분에
reset 불필요. 미래 다중 caller 가 같은 cancel token 공유하려면
install 함수 분리 필요.
- ingest_cancel.rs::cancel_mid_loop: redundant `report.new == 1 || 0
|| 2` 제거, race timing 의도 코멘트로 대체 (0=listener 승, 1=first
only, 2=extra slipped in 모두 valid; 3 = cancel never propagated
= 유일한 fail).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ctrl-C / Esc 가 ingest 를 즉시 중단. 현재 in-flight asset 마무리 후
이후 asset 미실행, IngestEvent::Aborted { partial_counts } 발신,
Ok(IngestReport) 정상 반환 (Err 아님). 부분 commit 보존, 다음 ingest
가 idempotent 재개.
신규 facade: kebab-app::ingest_with_config_cancellable(.., progress,
cancel: Option<Arc<AtomicBool>>). 기존 _progress 가 cancel=None
forwarding wrapper. asset loop 시작 boundary 마다 atomic load —
true 면 break + Aborted emit + 정상 종료. Lock 없음.
CLI: ctrlc crate 신규 dep. SIGINT handler 가 첫 신호에 cancel.store(true)
+ stderr hint, 두 번째 신호에 std::process::exit(130) (canonical SIGINT
exit code). install_sigint_cancel() helper 가 Arc<AtomicBool> 반환,
Cmd::Ingest 가 facade 에 전달.
TUI: IngestState 에 cancel: Arc<AtomicBool> field 추가 (회차 1 review
결과의 reshape 정확). start_ingest 가 둘 다 만들어 worker 에 clone
move. cancel_running_ingest(&app) helper — Esc / Ctrl-C 가
ingest 진행 중일 때만 cancel 우선, 그 외에는 quit.
Test:
- 3 facade integration (cancel-before / cancel-mid / no-cancel
default).
- 3 tui lib unit (cancel_running_ingest no-state / in-flight /
terminated).
Plan 갱신: p9-fb-04 status planned → in_progress. 머지 후 한 줄
commit 으로 completed flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 의 설계 결함 지적 반영. 원래 IngestState 에 cancel_tx:
Sender<()> 만 두고 receiver 는 start_ingest 안에서 즉시 drop —
실제 send() 호출 시 항상 Err(SendError) 인 dead channel 이 됨.
\"slot 만 정의\" 의도였으나 실용 가치 0 + CLAUDE.md 의 backward-
compat shim 금지 룰 위반.
수정:
- IngestState 에서 cancel_tx field 제거.
- start_ingest 의 cancel channel allocation 제거.
- doc comment 갱신 — p9-fb-04 가 (cancel_tx, cancel_rx) pair 동시
추가 + receiver 를 worker thread 로 move 하는 형태로 reshape 한다고
명시.
- test fresh_state helper 도 cancel_tx 인자 제거.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library 의 `r` 키가 `kebab_app::ingest_with_config_progress` 를
spawned thread 에서 호출. run loop 가 매 frame 마다 progress channel
drain → 화면 하단 status bar 1 줄 갱신. blocking 하지 않음.
신규:
- crates/kebab-tui/src/app.rs: `IngestState` struct (rx + counts +
current_path + started_at + terminal_at + aborted + thread +
cancel_tx) + `App.ingest_state` slot + `TERMINAL_LINE_HOLD_SECS`.
- crates/kebab-tui/src/ingest_progress.rs: `start_ingest` (worker
spawn + channel allocation), `drain_progress` (try_recv loop),
`apply_event` (per-kind counter accumulation + Completed/Aborted
marking), `status_line` (사람-친화 텍스트), `ready_to_clear`
(3 초 hold).
- 키 cheatsheet: Library footer 에 `r=ingest` 추가.
Run loop:
- 매 tick `drain_progress` + `ready_to_clear` 체크 → terminal 후
3 초 경과 시 slot drop + worker 스레드 join + Library refresh
큐.
- Layout: ingest_state Some 일 때 footer 위에 status line 1 줄
추가 (있을 때만, 평시 영향 0).
- status line: scanning 중 / 진행 (idx/total %, current path,
elapsed) / 완료 (✓) / abort (✗) 4 모드.
Cancel wiring (p9-fb-04) 의 `IngestState.cancel_tx: Sender<()>`
slot 은 정의만 — 본 PR 에서 sender 보유, send 호출 X.
Test:
- 10 lib unit (apply_event 분기 5 / status_line 4 / ready_to_clear 2).
- 기존 15 tui test 회귀 0.
Plan 갱신:
- p9-fb-03: status `planned` → `in_progress`. 머지 후 한 줄
commit 으로 `completed` flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 actionable 모두 동작 변경 없음, 의도 명시.
- progress.rs handle_human: doc-comment 한 단락 — `let _ = writeln!`
의 IO error swallow 와 `bar.as_ref()` None 분기 silent skip 의
두 best-effort 의도 + §2.4a ordering invariant (ScanStarted 가
bar 를 lazy 초기화) 명시.
- main.rs Cmd::Ingest: `let _ = display_handle.join();` 위에 한 줄
trailing comment — Result<Result<(), anyhow::Error>, Box<dyn Any>>
를 모두 discard 하는 이유 (display thread 의 에러 / panic 이
ingest exit code 에 영향 없어야 함).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`kebab ingest` 가 진행 상황을 사용자에게 보여주는 두 surface 추가:
- **사람 모드 (TTY)**: indicatif `ProgressBar` on stderr — scan 중에는
spinner, ScanCompleted 후 bar 로 전환, 매 asset 마다 message 갱신.
- **사람 모드 (non-TTY, CI/pipe)**: indicatif draw target 을 hidden
으로 두고 stderr 에 한 줄씩 (`ingest: scanning`, `ingest: 1/N path`,
`ingest: complete (...)`).
- **`--json` 모드**: stderr 비우고 stdout 에 line-delimited
`ingest_progress.v1` JSON 을 emit. 마지막 줄은 기존
`ingest_report.v1` 그대로 (외부 wrapper backward-compat).
구현:
- 신규 `crates/kebab-cli/src/progress.rs` — `ProgressMode::{Json,
Human { tty }}`, `ProgressDisplay` (background thread 가 channel
drain + 모드별 render), `now_rfc3339` helper. mode 가 무엇이든 ts
는 wire emit 시점에 stamp.
- `crates/kebab-cli/src/wire.rs` 에 `wire_ingest_progress` 추가.
serde tag (`kind`) 위에 `schema_version` + `ts` 두 필드 더해 spec
§2.4a wire shape 완성.
- `Cmd::Ingest` 핸들러: mpsc channel 만들고 background thread 가
display 돌리는 동안 main 이 `ingest_with_config_progress` 호출.
ingest 반환 시 Sender drop → display thread 정상 종료. join 후
최종 ingest_report 출력.
- 새 dep: `indicatif` 0.17 (TTY 전용 진행 바, non-TTY/--json 에서는
hidden draw target).
Test:
- 3 lib unit (mode resolution + RFC 3339 round-trip).
- 3 integration (--json line-delimited / non-TTY stderr text /
ts+kind 검증). 16 PASS 전체 회귀 0.
Plan 갱신:
- p9-fb-01: status `in_progress` → `completed` (PR #52 머지 후속).
- p9-fb-02: status `planned` → `in_progress`. 머지 후 별도 한 줄
commit 으로 `completed` flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 nit 반영. 'suppressing further sends would require caller
cooperation' 의 두 번째 절이 의미 불명확 — 단순 'event discarded
(best-effort send per ingest_progress contract)' 로 단축. 사실 +
영향만 기록, caller 가 어떻게 cooperate 하는지 추측은 제거.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- (critical) embeddings.rs: truncate_embedding_records 위치 이동.
mark_embedding_records_committed 함수 위에 끼워 넣었더니 위쪽
mark_committed 의 14 줄짜리 doc comment (`WHERE status='pending'`
의 design rationale 등) 가 truncate 의 doc 으로 흡수되고
mark_committed 자체는 doc 없이 남는 버그. impl block 끝 (mark_committed
의 닫는 } 다음) 으로 옮겨 plan 의 원래 의도와도 일치.
- (nit) tests/reset_cli.rs: removed_paths 의 idempotency 검증 보강.
data dir 은 reported, cache dir 은 omit (생성 안 했으니)
되어야 함을 strict 하게 assert. state dir 은 logging init 의
side-effect 로 자동 생성되어 둘 다 가능하므로 허용.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mutually-exclusive scope flags (--all / --data-only / --vector-only /
--config-only via clap ArgGroup) plus --yes for non-interactive use.
Aborts with exit 2 when stdin is non-interactive and --yes is missing
— silent destruction is forbidden. Self-contained 20-line confirm
prompt (no new dep; std::io::IsTerminal).
Integration tests exercise the bin in a fresh subprocess against
tempdir-rooted XDG env to keep the assertions independent of the host
config:
- --data-only --yes wipes data + cache + state, preserves config.
- non-TTY without --yes exits 2 with the documented hint.
- --json emits reset_report.v1 schema with snake_case scope.
- conflicting --all + --data-only rejected by clap before any wipe.
Plan deviation (task 4): the data-only test used to write a stub
config.toml containing only `schema_version = 1`, but Config parsing
requires every section. Switched to a marker file in the cfg dir +
the documented Config::load(None)→defaults fallback.
p9-fb-06 task 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Provides the wipe core for `kebab reset`. Mutually-exclusive ResetScope
variants (All / DataOnly / VectorOnly / ConfigOnly), pure path
enumeration for the confirm UI preview, byte-size estimator, and an
execute helper that removes paths off-disk + truncates
embedding_records when scope is VectorOnly.
Plan deviation from the original spec (task 2):
- Original `truncate_embeddings` helper opened SqliteStore via path and
ran a separate COUNT query through `lock_conn` (private). Both APIs
are unavailable from outside the crate, so the helper now opens the
store via `SqliteStore::open(&Config)` and lets
`truncate_embedding_records` (task 1) report the deleted count
directly.
- Skipped the XDG-env-overriding unit test from the original plan to
avoid race conditions with sibling tests; the equivalent integration
coverage moves up to the CLI tests in task 4 where each invocation
runs in a fresh process.
- Added an FS-touching unit test (`estimate_size_sums_file_lengths`)
to cover the read-side of `estimate_size_bytes` against a tempdir.
p9-fb-06 task 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wipes every row from embedding_records and returns the deleted row
count. Used by the upcoming `kebab reset --vector-only` to keep SQLite
consistent after the on-disk Lance store is removed.
Plan deviation from the original spec (task 1):
- Original test plan opened SqliteStore with a raw path; the actual
signature is `SqliteStore::open(&Config)`, so the integration test
builds a Config with `storage.data_dir` pointed at a tempdir.
- Original return type was Result<()>; bumped to Result<u64> so the
caller (kebab-app::reset) can surface the truncated count in the
reset_report.v1 wire payload without a separate COUNT query.
p9-fb-06 task 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
도그푸딩 시 사용자 결정 (2026-05-02): 텍스트 LLM 기본을 gemma4 계열로
통일. OCR/caption 어댑터 (P6-2/P6-3) 가 이미 gemma4:e4b 사용 중 —
사용자가 한 family 만 pull 하면 ingest + ask 모두 작동.
같이 발견된 ~ expansion 불일치:
- kebab-source-fs::connector 는 expand_tilde 사용 (walk 정상)
- kebab-app::ingest_one_image_asset / ingest_one_pdf_asset 은 직접
PathBuf::from → ~ 미확장 → ExtractContext 에 ~/KnowledgeBase
그대로 전달
- kebab-tui::search::handle_key_search 의 editor jump 도 동일 →
의미 없는 경로 spawn
Fix:
- Config::defaults().models.llm.model = \"gemma4:e4b\". OCR/caption
family 통일 코멘트 추가.
- kebab-app 의 image / pdf 분기 두 곳 모두 expand_tilde 호출.
- kebab-tui::search jump 가 kebab_config::expand_path(.., \"\") 사용
(expand_path 는 ~ / ${XDG_DATA_HOME} / {data_dir} 모두 처리하는
정식 helper).
Caveat: kebab-app::expand_tilde 와 kebab-config::expand_path 가 별도
정의. 통합은 P+ task.
Docs (sync rule):
- README 사전 요구 절: gemma4:e4b 기본 + 더 큰 variant override 안내.
- docs/ARCHITECTURE 핵심 결정 표: LLM default qwen2.5:7b-instruct →
gemma4:e4b.
- docs/SMOKE: ollama pull 예시 + KEBAB_MODELS_LLM_MODEL env 예시
qwen2.5:32b → gemma4:26b.
- HOTFIXES: 새 entry (\"Config defaults: LLM = gemma4:e4b + workspace.root
tilde expansion\").
- Memory: project_llm_default.md 신설, MEMORY.md 인덱스 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blocks / embeddings 섹션의 count 라인이 collapse 검사 *밖* 에서 push
되어 collapsed 상태에서 부분만 사라지던 일관성 깨짐. fix: count 를
section header 에 inline 으로 (`▾ blocks (N)`, `▾ embeddings (N)`),
body 만 collapse 검사 안. 새 helper `push_section_header_with_count`
가 둘 다 통일.
회귀 테스트 보강:
- doc_view_collapse_hides_section_body: collapsed 상태에서 \"blocks (2)\"
inline count 표시 + \"Heading L1\" body 숨김 검증.
- chunk_view_renders_text_and_block_ids: \"embeddings (2)\" inline
count 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Esc 후 재질문 시 detached prior worker + 새 worker 동시 in-flight 가능
했음. Ollama endpoint 에 두 요청 동시 발사 → 응답 시간 두 배 + stream
혼동. spawn_ask_worker 진입 시 `s.thread.is_some()` 검사 추가, 이전
worker 가 still alive 면 Enter 무시. input bar 의 busy 텍스트 가 세
상태 (streaming / awaiting prior / idle) 분리 표시 — 사용자가 Enter
가 왜 안 먹히는지 즉시 확인.
회귀 테스트 `enter_with_detached_prior_thread_is_blocked` 추가 — never-
ending 더미 thread 를 hand-install 후 Enter no-op 검증, 종료 시 thread
take() 로 leak 명시 (test process 종료 시 OS 가 reap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. **Citation::Page 분기 fix** — `args.push(format!(\"# page {page}\"))` 가
vim/code/cursor 에 \"두 번째 파일\" 로 해석돼 의도 외 동작 (split / new
buffer). 마지막 push 제거, path 만 열고 `tracing::debug!` 한 줄.
PDF 페이지 jump 는 사용자 PDF reader 책임 — `KEBAB_EDITOR_JUMP_FORMAT`
env hook 은 P+ enhancement.
2. **j/k/g 의 SHIFT modifier 차단** — `is_typing_mod` 가 SHIFT 를 typing
으로 취급하던 부분이 J/K/G 를 selection 키로 흡수해 \"JSON\" / \"PostgreSQL\"
/ \"Go\" 같은 대문자 검색어 깨짐. arrow 키 (Down/Up) 는 modifier 무관 유지,
문자 키 (j/k/g) 는 `KeyModifiers::NONE` 만. SHIFT-J / SHIFT-G 회귀 테스트
2건 추가.
3. **`format_hit_lines` 의 unused `_width` 인자 제거** — ratatui 자동
truncate 신뢰 (Library 의 한국어 column 정렬은 별도 path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- p9-2/3/4 미머지 시점에 / ? Enter 키로 focus 가 Search/Ask/Inspect 로
옮겨가면 헤더만 바뀌고 본문은 Library 그대로 + 키 매핑도 Library 라
사용자에게 거짓말. footer hint 가 \"Search pane not yet implemented
(lands with p9-2) — q to return\" 로 전환된다. 새 stub 핸들러
`handle_key_unimplemented_pane` 가 q / Esc 만 받아 Library 로 복귀,
나머지 키는 no-op (이전 구현은 handle_key_library 로 위임해서 focus
와 다른 pane state 가 mutate 되던 절뚝거림 차단).
- `format_doc_row` 의 `{title:<title_w$}` 가 std::fmt 의 named-arg
width specifier — 미래 reader 가 같은 패턴 보고 헷갈리지 않도록
doc 링크 한 줄 코멘트 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
새 crate `kebab-tui` 가 §8 facade rule 따라 `kebab-app` 만 import.
Ratatui 0.28 + crossterm 0.28 기반 shell 이 다음을 제공:
- `App` 구조체: config + focus + library + 3 Option sub-state slot
(search/ask/inspect — p9-2/3/4 가 자기 모듈에서 채우는 parallel-safety
contract). p9-1 외에 App 정의 손대지 않음.
- `Pane` enum (Library/Search/Ask/Inspect/Jobs).
- `KeyOutcome` (Continue/Quit/SwitchPane/Refresh).
- `LibraryState` + 내부 inner: docs / list_state / filter / filter_edit /
needs_refresh / loading / pending_g.
- `render_library` (Frame, area, &App) — heading/body, filter overlay
toggleable, Korean/wide-char 너비는 unicode-width 로 계산.
- `handle_key_library`: j/k/Down/Up 이동, gg/G 끝, f 필터 overlay,
/=>Search ?=>Ask Enter=>Inspect, q/Esc 종료. error overlay 가 켜
있으면 어떤 키든 dismiss.
- 필터 overlay: tags_any (CSV) + lang 두 필드, Tab cycle, Enter
apply→Refresh, Esc cancel.
- `ErrorOverlay`: anyhow chain 캡쳐 후 popup 렌더 (Clear + 빨간 border).
- 터미널 lifecycle: `TuiTerminal` 가 enter raw mode + alt screen,
Drop 이 종료 시 (panic 포함) restore — 사용자 쉘 깨지지 않게.
- 비동기 없음: facade 호출은 main thread 동기. v1 의 brief hang 수용.
CLI: `kebab tui` 서브커맨드 추가, --config 받아 App::new + run.
테스트 10건 (`tests/library.rs`, TestBackend 사용):
- 빈 library / 3-doc render / q,Esc quit / / Search 전환 / ? Ask 전환
- Enter 빈 list 무동작 / Enter Inspect 전환 / j 이동 (3-step clamp) /
f 필터 overlay → 입력 → Enter Refresh.
Test seam: `App::populate_library_for_testing` (#[doc(hidden)]) 가
`pub(crate)` inner 를 우회. spec parallel-safety contract 그대로 유지.
Spec deviation (HOTFIXES `2026-05-02 P9-1`):
- `render_library` 의 `<B: Backend>` generic 제거 — ratatui 0.28 의 Frame
이 backend-agnostic.
- `populate_library_for_testing` 추가 (test seam, 공식 API 아님).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`delete_by_chunk_ids` 의 SQL IN(...) 입력에 대한 hex invariant 를
`debug_assert!` 로 명시. `id_for_chunk` 가 항상 hex 를 emit 하지만
`ChunkId(pub String)` 가 hand-construct 가능해 미래 contributor 가
tainted 문자열을 넣을 가능성 차단. dev / test build 에서 즉시
panic 으로 잡힘 (release 는 그대로 SQL 진행 — 운영 경로는 hex 가
강제되므로 false positive 없음).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-3 의 storage UNIQUE bug fix 가 SQLite 측 (documents → blocks /
chunks / embedding_records) 만 sweep 했음. LanceDB 의 vector 는 별도
store 라 옛 chunk_id 를 가진 row 가 디스크에 잔존. 검색에는 영향 없지만
디스크는 무한 누적. HOTFIXES `2026-05-02 P7-3` caveat 의 "P+ task" 약속을
같은 후속 PR 안에서 닫음.
변경:
- `VectorStore::delete_by_chunk_ids(&[ChunkId])` trait method 추가 (default
no-op 제공 — 테스트 fake / 기존 impl 이 그대로 컴파일).
- `LanceVectorStore::delete_by_chunk_ids` 가 connection 의 모든
`chunk_embeddings_*` 테이블을 순회 + `Table::delete("chunk_id IN (...)")`
를 batch=200 단위로 실행. 다중 모델 워크스페이스 (마이그레이션 중간 등)
에서도 안전.
- `SqliteStore::stale_chunk_ids_at(workspace_path, new_asset_id)` 가
read-only SELECT 로 옛 chunk_id 들 반환. CASCADE 가 흐르기 *전* 에
caller 가 호출.
- `kebab-app::purge_vector_orphans_for_workspace_path` 가 위 두 단계를
orchestrate. 세 ingest path (markdown / image / pdf) 의
`put_asset_with_bytes` 호출 직전에 한 줄로 호출.
Smoke 검증 (release binary, fastembed enabled):
- whitepaper.pdf 첫 ingest → chunk_ids = {f616…, 4e0f…}, vector store 에
그 두 ID 의 row 존재.
- byte 변경 후 re-ingest → 새 doc_id (3741…) + 새 chunk_ids
(ed0c…, e13c…). vector search "REWRITTEN chapter two" → 새 chunk_ids 만
hit. 옛 query "Edited page two body" 시도해도 옛 chunk_ids 는 vector
store 에 더 이상 없음 (의미적으로 가장 가까운 새 chunks 가 hit).
HOTFIXES `2026-05-02 P7-3` 의 \"vector store cleanup\" 항목이 \"deferred\" →
\"closed by follow-up PR\" 로 갱신. SMOKE.md 의 알려진 동작 (\"옛 vector
잔존\") 도 \"두 store 정합\" 으로 갱신.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-3 통합 테스트가 노출한 storage 레이어 버그 fix.
`assets.workspace_path` 의 UNIQUE 제약과 `upsert_asset_row` 의
`ON CONFLICT(asset_id)` 만 처리하던 gap 사이 — byte 가 변경된 자산
re-ingest 시 새 asset_id 가 같은 workspace_path 에서 secondary UNIQUE
충돌. md / image / pdf 모두 영향.
Fix:
- 새 helper `purge_orphan_at_workspace_path` 가 같은 `workspace_path`
의 *다른* `asset_id` 를 발견하면 documents → assets 순서로 sweep.
documents 의 ON DELETE RESTRICT 회피 + CASCADE 로 blocks / chunks /
embedding_records 정리. copied 모드면 storage_path 의 byte 파일도
best-effort 삭제.
- `put_asset_with_bytes` 의 두 분기 (copy / reference) + `DocumentStore
::put_asset` 모두 호출.
- 회귀 테스트 `put_asset_with_bytes_sweeps_workspace_path_orphan` (이전
의 "UPSERT 실패시 orphan 청소" 테스트가 더 이상 doable 하지 않으므로
대체).
- `re_ingest_edited_pdf_produces_new_doc_id` integration `#[ignore]` 해제 →
9 통합 테스트 모두 default 로 통과.
Vector store orphan 은 별도 P+ task — LanceDB 가 SQLite cascade 와 무관하게
운영되므로 stale chunk_id vector 가 디스크에 남음. 검색에는 영향 없음 (search 가
SQLite join 통해 surface).
Smoke 검증 (release binary, markdown 2 + image 1 + PDF 2):
- doctor pass
- 첫 ingest: 5 new
- list docs: 5 docs all media types
- search lexical "pdf-page-v1 chunker" → whitepaper.pdf hit
- search hybrid → cross-media 결과
- inspect doc PDF: parser_version=pdf-text-v1, blocks 가 SourceSpan::Page
- 동일 byte re-ingest: 5 updated, 0 errors (P1 idempotency)
- byte 수정 후 re-ingest: 1 new (해당 PDF) + 4 updated, 0 errors (storage fix)
- corrupt PDF 추가: errors+=1 + IngestItem.error 메시지 정확, 다른 자산 영향 0
- 정리 후 다시 ingest: errors=0
- RAG ask: PDF 인용 + `citations[].citation` 에 `kind: "page"` + `page: <N>` +
`path: <pdf_path>` 정확히 노출
운영 fixture 보조:
- `crates/kebab-parse-pdf/examples/gen_smoke_pdf.rs` — `cargo run --release
--example gen_smoke_pdf -p kebab-parse-pdf -- <out.pdf> <text-pages>` 로
reportlab/qpdf 없이 in-tree PDF 생성.
- `crates/kebab-parse-image/examples/gen_smoke_png.rs` — 동일 방식의 PNG
fixture 생성.
- SMOKE.md 가 두 example 사용법 + 갱신된 HOTFIXES 동작 (byte 수정 시
errors+=1 → new+=1) 반영.
HOTFIXES `2026-05-02 P7-3` entry 가 \"deferred\" → \"fixed in same PR\" 로
업데이트, vector store orphan caveat 만 남음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `IngestItem.warnings` 가 PDF path 에서 빈 vec 였던 갭 해소. P7-1 의
Provenance Warning (scanned candidate / extract panic 흡수) 노트들을
`IngestItem.warnings` 로 surface — md path 의 `fm_warns + blk_warns`
patten 과 평행. 사용자가 ingest summary 에서 "이 PDF page 2 가 스캔
이라 검색 불가" 를 즉시 확인 가능.
- `mixed_page_pdf_stores_asset_with_scanned_candidate_warning` 에
`IngestItem.warnings` 단정 추가 (정확히 1건 + 노트 내용 검증).
- `encrypted_pdf` / `corrupt_pdf` 테스트의 `errors >= 1` → `errors == 1`
strict 단정. 미래에 다른 source 가 errors 늘리면 즉시 빨개짐.
- `re_ingest_identical_pdf` 에 `chunk_count` 동일성 단정 추가. P1
idempotency contract 의 chunk-단위 axis 검증 (chunk_id 전체 set 비교는
pdf-page-v1 의 `deterministic_chunk_ids_1000` 가 잠그고 있어 chunk_count
가 가벼운 proxy 로 충분).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-1 (`PdfTextExtractor`) + P7-2 (`PdfPageV1Chunker`) 의 라이브러리를
`kebab-app::ingest_with_config` 에 와이어링. `kebab-source-fs` 가 이미
`*.pdf` 를 `MediaType::Pdf` 로 분류하던 자산이 이제 검색 가능한 doc 으로
색인됨. P6-4 image wiring 패턴과 평행 — `ingest_one_asset` 에 `MediaType::Pdf`
arm 추가, 새 private fn `ingest_one_pdf_asset` 로 분기.
핵심 동작:
- per-medium chunker 선택: PDF 자산은 `PdfPageV1Chunker` 하드코딩 (compile-time
match 기반). `config.chunking.chunker_version` 은 markdown 만 represent —
PDF 는 항상 `pdf-page-v1`. HOTFIXES entry `2026-05-02 P7-3` 에 deviation 기록.
- encrypted PDF / corrupt PDF → `errors+=1` + P7-1 의 `qpdf --decrypt` hint
를 `IngestItem.error` 에 verbatim 보존.
- 빈/scanned candidate 페이지 → 0 chunk, P7-1 의 `Provenance::Warning` 그대로
통과. v1 에서는 검색 불가, P+ scanned-PDF OCR fallback 대기.
- determinism stress: extract → chunk 사이 `now()` 추가 호출 없음 (P6-4 invariant
계승). PDF doc/chunk_id 모두 결정적.
통합 테스트 (`tests/pdf_pipeline.rs`, 8 passed + 1 ignored):
- 3-page text PDF → 1 doc + 3 chunk + Page span 검증
- identical re-ingest → Updated, doc_id 동일
- encrypted PDF → Error + `qpdf` hint 보존
- corrupt header PDF → Error + 미저장
- mixed page (page 2 빈) → 2 chunk + Warning 1개
- IngestReport 산술 invariant
- 50-page 긴 PDF → ≥50 chunk
- inspect doc → SourceSpan::Page round-trip
- (ignored) edited bytes re-ingest → storage UNIQUE bug 노출, P+ fix 대기
추가 발견 (HOTFIXES `2026-05-02 P7-3`): `assets.workspace_path` 의 UNIQUE
제약과 `upsert_asset_row` 의 `ON CONFLICT(asset_id)` 만 처리하는 부분 사이에
gap 존재. byte 변경 시 새 asset_id → 같은 workspace_path 충돌. md / image / pdf
모두 영향. P7-3 통합 테스트가 처음 노출. 본 PR 은 fix 안 함 — P+ storage task.
`docs/SMOKE.md` 에 PDF 섹션 + 검증 체크리스트 + 알려진 동작 4건 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- chunk 진입부에 overlap clamp 추가 (`target_bytes / 2` 상한). 병적 정책
(`overlap_tokens >= target_tokens`) 에서 chunk 가 직전 chunk 의 텍스트를
완전히 재발행하던 위험 차단. md-heading-v1 의 `seed_budget = overlap_tokens
.min(target/2)` 가드 패턴과 일치. 회귀 테스트 `overlap_clamped_when
_overlap_exceeds_target` 추가 — `actual_start` 가 인접 chunk 사이에
엄격 증가하는지 검증.
- `char_start as u32` / `char_end as u32` silent truncation → `try_from
::expect` 로 corrupted input 시 명시 panic.
- 모듈 doc 의 `## Splitting policy` 에 약어 케이스 (`Mr.` / `i.e.` 등) +
overlap clamp 두 항목 명시.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PdfPageV1Chunker` 가 `kebab-parse-pdf` 가 emit 한
`CanonicalDocument` (블록당 한 페이지, 모두 `SourceSpan::Page`) 를 받아
페이지 경계를 절대 넘지 않는 `Chunk` 들을 생성. `chunker_version =
"pdf-page-v1"`.
핵심 동작:
- 페이지 텍스트가 `target_tokens × BYTES_PER_TOKEN` (= 3) 안이면 한
덩어리. 초과 시 `\n\n` (paragraph) 또는 sentence-end 구두점 + whitespace
경계를 segment 로 보고 greedy 누적, 기본 한 chunk 당 최소 한 segment.
- 다음 chunk 의 prefix 에 `overlap_tokens × BYTES_PER_TOKEN` 만큼의 직전
꼬리를 prepend (char 단위, 이전 chunk 시작 너머로 backtrack 안 함).
- 빈/공백-only 페이지는 0 chunk (페이지의 `Provenance::Warning` 으로
`kebab-parse-pdf` 단계에 이미 표시됨).
- 비-PDF doc (Block::Paragraph 가 아니거나 SourceSpan 이 Page 아님) →
명시 에러.
Spec deviation (HOTFIXES 2026-05-02 P7-2):
- `chunk_id` 충돌 가드: 같은 페이지에서 여러 chunk 가 나오면 `block_ids`
가 모두 같아 §4.2 recipe 가 충돌. `id_for_chunk` 의 `policy_hash` 인풋을
per-chunk 로 `format!("{base}#c{char_start}")` 변형해 회피. recipe 자체는
불변. `Chunk.policy_hash` 필드는 base 유지.
- `BYTES_PER_TOKEN = 3` (md-heading-v1 실제 코드와 일치). spec 본문은
"/ 4" 라고 했지만 그 자체가 md-heading-v1 의 실코드와 어긋나 있어 일관성
쪽을 택함. cross-chunker `policy_hash` 동일성 unit test 로 잠금.
테스트 (10개 신규):
- chunker_version label, 3-page small, 1-page huge + overlap + chunk_id
유일성, empty page skip, whitespace-only skip, non-PDF error,
cross-page boundary 절대 안 만들어짐, determinism (1000회), snapshot
shape 안정, md-heading-v1 와 policy_hash 동일.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Cargo.toml: 사용하지 않는 deps 제거 (`kebab-config`, `thiserror`,
`pdf-extract`, dev `tempfile` / `serde_json` / `serde`). 특히
`pdf-extract` 가 끌어오던 transitive ~150 crate (pom, postscript,
type1-encoding-parser, adobe-cmap-parser, euclid, chrono, md5,
linked-hash-map …) 가 모두 사라짐. lopdf 만 남음.
- info.rs: BOM 없는 PDFDocEncoded Title 디코드 버그 수정. `from_utf8_lossy`
는 0x80–0xFF 를 U+FFFD 로 치환해 "Café" 같은 레거시 타이틀을 망가뜨림.
byte → `char` 직접 캐스팅 (Latin-1 디코더) 로 교체. 회귀 테스트
`info_dict_title_pdfdocencoding_latin1_high_bytes_decoded` 추가.
- info.rs: 모듈 doc 의 "Latin-1 superset" 부정확 표현 정정 — PDFDocEncoding
은 0x18–0x1F / 0x80–0x9F 영역에서 Latin-1 과 다름.
- lib.rs: `saturating_sub(1)` 가 page=0 케이스를 silent 흡수하던 부분에
`debug_assert!` 추가. release 는 saturating fallback 유지 (panic 보다
garbled order 가 운영에 유리).
- tests: UTF-16 surrogate pair 커버리지 갭 보완 — 🥙 (U+1F959) 가 포함된
타이틀로 `String::from_utf16_lossy` 의 페어-결합 경로 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PdfTextExtractor`(MediaType::Pdf) lopdf 기반 per-page 텍스트 추출.
페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`
emit. 본문이 비거나 추출 panic 인 페이지는 빈 paragraph + `Provenance::Warning`
("scanned candidate") 로 표시 — 이후 OCR fallback (별도 task) 의 입력.
핵심 동작:
- `lopdf::Document::load_mem` + `is_encrypted()` → 암호화 PDF 는 명시 에러
(`qpdf --decrypt` 안내).
- 페이지 단위 `extract_text(&[page])` 를 `catch_unwind` 로 감싸 malformed
page panic 을 recoverable warning 으로 변환.
- `/Info` dict 에서 Title/Producer/Creator best-effort 추출. UTF-16BE BOM
prefixed 문자열도 디코드 (한국어 등 non-ASCII Title 정상 처리).
- 9개 통합 테스트: 3-page emit, scanned-mixed warning, encrypted refuse,
corrupt header error, page_count 메타, UTF-16BE Title, filename
fallback, determinism, snapshot.
`parser_version = "pdf-text-v1"`. Allowed deps: `lopdf 0.32` + `pdf-extract 0.7`
(원본 spec 그대로). 본문 다국어 OCR fallback 은 §9.2 후속 task (out of scope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
수동 스모크 검증 (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 회귀).
- 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.
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.
- 새 모듈 `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.
- 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.
- 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.
- 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 본문에서 명시).
- 새 모듈 `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.
- src/exif_extract.rs:
• `gps_decimal` 에 ±90 / ±180 범위 검증 추가. 비정상 EXIF (예: 위도
300°) 가 들어와도 wire 에 흘러나가지 않고 silent drop.
• GPSLatitudeRef / GPSLongitudeRef 가 빠진 좌표는 양수 가정으로
내보내지 않고 None 반환 — 모호한 부호를 그대로 두는 대신 손상된
메타데이터로 처리.
• `read_from_container` 실패 시 `tracing::debug!` 한 줄로 사유 기록
(운영시 \"EXIF 없음\" vs \"EXIF 손상\" 구분 단서).
- src/dims.rs: `match Some/None` 을 `anyhow::Context::context()?` 로
압축. import 한 줄 추가.
- src/lib.rs: `Vec::with_capacity` 를 dim_warning 분기에 따라
`2` / `3` 으로 정확히 맞추고 의미 주석 한 줄 추가.
- tests/common/mod.rs: `build_exif_blob_gps` 를 `GpsFlavor`
파라미터로 일반화 (`Valid` / `NoRef` / `OutOfRange`). JPEG 스플라이스
로직은 `splice_exif_into_jpeg` 헬퍼로 추출.
- tests/extractor.rs: 회귀 테스트 2건 추가 — `*Ref` 누락 좌표 드롭,
out-of-range 위도 드롭 (경도는 정상 통과 검증).
cargo test -p kebab-parse-image — 16건 (4 unit + 12 integration) pass.
cargo clippy -p kebab-parse-image --all-targets -- -D warnings — pass.
두 reviewer 의 should-fix 4 건 + nit 5 건 push 전 반영.
## should-fix
- `citation_coverage`: 빈 citations[] 가 `Iterator::all` vacuous-true 로
1.0 새는 거 차단 — `!is_empty() && all(non-empty path)` 로 변경.
또한 `_store: &SqliteStore` dead 인자 시그니처에서 제거 (호출 사이트
+ 테스트 helper 정리).
- `refusal_correctness`: lexical-only run 에서 `answer == None` 인 경우
분모 증가 안 함 (NaN/null 출력) — 자동 fail 처리하던 게 metric 의미를
왜곡함. 새 unit test `refusal_correctness_nan_for_non_rag_run` 추가.
- `groundedness`: `must_contain.is_empty() && forbidden.is_empty()`인
golden 은 분모에서 제외. unconfigured entry 가 free 1.0 받지 않게.
새 unit test `groundedness_skips_unconfigured_goldens` 추가.
- `kb-cli/Cargo.toml` rationale 코멘트 사실 오류 정정 — kb-eval →
kb-app 의존이지 그 반대 아님.
## nits
- `KB_EVAL_GOLDEN` / `DEFAULT_GOLDEN_PATH` 중복 — `metrics::` 의
`pub(crate)` 로 단일화, `runner` 가 import.
- `render_report_md` 의 `{:?}` `ComparisonKind` → 명시적 lowercase
매핑 함수 (`win`/`loss`/`draw`/`regression`) — JSON 직렬화 컨벤션과
통일.
- `extract_chunker_version` `None == None` 매치 silent 위험에 대한
defensive 코멘트.
- `delta_null_when_either_nan` 테스트의 `let mut` suppress hack →
struct update syntax 로 정리.
- `empty_store` test helper + 매번 `mem::forget(tmp)` 죽은 코드 제거.
## 추가 spec doc
`tasks/p5/p5-2-metrics-compare.md` deviations 섹션 4 항목 추가:
- `kb-eval` crate-level `kb-app` dep — P5-1 inheritance, 새 모듈 surface
는 import 안 함.
- `citation_coverage` 약화된 resolver — `document_exists_by_path` 기다리는
중.
- `refusal_correctness` non-RAG 런 NaN.
- `groundedness` no-check golden skip.
## 검증
- `cargo test -p kb-eval` 35/35 (18 unit + 2 loader + 8 integration + 7
runner; 새 3 unit test).
- `cargo clippy --workspace --all-targets -- -D warnings` clean.
- `compare_report_snapshot_matches_fixture` 변경 없이 통과 — 새 동작이
스냅샷 입력 (lexical-only, no must_contain, no should-refuse) 영향 없음.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>