Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 403e162ac0 | |||
| fdf09c369c | |||
| e7b58017fd | |||
| 90812e981f | |||
| 15e6918cef | |||
| a8ec354188 | |||
| 2686a4f27d | |||
| 25e94feab8 | |||
| 7b7330cdf2 | |||
| 3d45994693 | |||
| d5c69f6715 | |||
| 148c8b7040 | |||
| 898cdaa043 | |||
| 01a17acd3f | |||
| f3a7222ec5 | |||
| 3d5bb599e3 | |||
| 375a0693e4 | |||
| 8cc4e6d563 | |||
| 901416d8e9 | |||
| b706e3e88c | |||
| 8f8d3a4100 | |||
| 75a543ff69 | |||
| a283e56c5c | |||
| 47ef6532f7 | |||
| 03b0745e9d | |||
| e7cb20990a | |||
| bebf6e4ac7 |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# PP-OCRv5 ONNX OCR models (paddle-onnx engine). git-lfs is not installed on
|
||||
# this host, so they are committed as plain binary blobs (treated as binary —
|
||||
# no textual diff/merge). If/when git-lfs becomes available, migrate with
|
||||
# `git lfs migrate import --include='*.onnx'` and restore the filter line:
|
||||
# *.onnx filter=lfs diff=lfs merge=lfs -text
|
||||
*.onnx -text
|
||||
126
Cargo.lock
generated
126
Cargo.lock
generated
@@ -4417,6 +4417,24 @@ dependencies = [
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imageproc"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"approx",
|
||||
"getrandom 0.2.17",
|
||||
"image",
|
||||
"itertools 0.12.1",
|
||||
"nalgebra",
|
||||
"num",
|
||||
"rand 0.8.6",
|
||||
"rand_distr 0.4.3",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.1"
|
||||
@@ -4548,6 +4566,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -4724,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-app"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -4772,7 +4799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-chunk"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4790,7 +4817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-cli"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4811,7 +4838,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-config"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
@@ -4827,7 +4854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-core"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4841,7 +4868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4855,7 +4882,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-candle"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"candle-core",
|
||||
@@ -4875,7 +4902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-local"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fastembed",
|
||||
@@ -4888,7 +4915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-ollama"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -4903,7 +4930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-eval"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4922,7 +4949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -4931,7 +4958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm-local"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -4948,7 +4975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-mcp"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4966,7 +4993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-nli"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hf-hub",
|
||||
@@ -4981,7 +5008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-code"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gix",
|
||||
@@ -5004,22 +5031,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-image"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"image",
|
||||
"imageproc",
|
||||
"kamadak-exif",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-llm",
|
||||
"kebab-llm-local",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5028,7 +5059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-md"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -5045,7 +5076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -5060,7 +5091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-rag"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -5082,7 +5113,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-search"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
@@ -5101,7 +5132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-source-fs"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -5119,7 +5150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-sqlite"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -5139,7 +5170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-vector"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrow",
|
||||
@@ -5163,7 +5194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
@@ -6423,6 +6454,21 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra"
|
||||
version = "0.32.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"simba",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -8238,6 +8284,15 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.4.5"
|
||||
@@ -8615,6 +8670,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -10220,6 +10288,16 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -32,7 +32,7 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.26.1" # v0.26.1 — ingest 진행 로그 개선: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 실시간 + 경과초 heartbeat `(Ns)`, 종료 시 최장 소요 파일 top-5 요약. 신규 wire 이벤트 `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms` 추가(additive, ingest_progress.v1 유지, serde default 0). 기본 동작 불변. — CLAUDE.md §Release
|
||||
version = "0.28.0" # v0.28.0 — config 스키마 v2→v3 재편: 미디어 형식 설정을 `[ingest.*]` 우산으로 통합(`[indexing]`→`[ingest]` 스칼라, `[chunking]`/`[image.ocr]`/`[image.caption]`/`[pdf.ocr]`→`[ingest.*]`). 기존 v2 파일은 load 시 메모리 자동 변환(디스크 미변경), 파일 갱신은 `kebab config migrate`(값·주석 보존). env 이름(LHS) 100% 보존 + RHS 만 새 경로, 신규 `KEBAB_PDF_OCR_{DET_MODEL,REC_MODEL,DICT,SCORE_THRESH,UNCLIP_RATIO,MAX_BOXES}`. `ingest_config_signature` 바이트 불변(재색인 0). PdfOcrCfg paddle 대칭 키. 신규 인터페이스(config 레이아웃 rename + env 추가) → minor. — CLAUDE.md §Release
|
||||
|
||||
# pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with
|
||||
# intentional allow-list. The allowed lints are either cosmetic (doc style),
|
||||
|
||||
@@ -35,6 +35,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
- **2026-06-04 PP-OCRv5 ONNX Rust 네이티브 OCR** — v0.27.0. `[image.ocr] engine = "paddle-onnx"` 로 PP-OCRv5(검출+인식) ONNX 를 in-process(`ort` =2.0.0-rc.9) 실행 — Python 런타임/원격 호출 없이 큰 페이지 CPU <4초(Ollama vision ~50초 대비). default 는 여전히 `"ollama-vision"`. 후처리(min-area rect/unclip)는 pure-Rust. **함정**: unclip 은 corner 를 centroid 에서 방사 확장하면 안 되고 edge 별 polygon offset 이어야 함(방사 확장 시 wide/short 텍스트 박스 높이가 안 커져 글자 윗부분 잘림 → ㄷ→ㄴ, e2e CER 0.26). 수정 후 CER 0.005. 모델 ONNX 는 `crates/kebab-parse-image/assets/paddleocr-onnx/`(LFS). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-04 PP-OCRv5 ONNX), spec/plan `docs/superpowers/{specs,plans}/2026-06-04-rust-native-ocr-*.md`.
|
||||
- **2026-06-03 ingest 설정 변경 자동 재색인** — v0.26.2. ingest 산출에 영향 주는 설정(청킹/이미지 OCR·caption/pdf.ocr/`[ingest.code]`)을 변경하면 `--force-reingest` 없이 영향 자산만 자동 재색인. 그 설정들의 결정적 서명(`ingest_config_signature`)을 effective parser_version(skip 비교 + 저장 doc 필드 양쪽)에 폴딩 → 다음 ingest 비교가 mismatch. 비산출 설정(search/rag/ui/log + max_pixels/languages/timeout)은 제외(과도 무효화 회피), doc_id 는 base 로 안정 유지. **업그레이드 후 첫 ingest 는 전 자산 1회 재색인**(저장된 상수 parser_version ≠ 새 composite; embedding 은 V012 캐시 히트). 결과 포맷·CLI·wire 불변(내부 skip 판정 정정). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 설정 변경 자동 재색인), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-*invalidation*.md`.
|
||||
- **2026-06-03 ingest 진행 로그 개선** — v0.26.1. 이미지/PDF + OCR/caption on 볼트 ingest 가 "멈춘 듯" 보이던 문제 해소: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 + 경과초 `(Ns)` heartbeat, 종료 시 최장 소요 파일 top-5 요약. 신규 wire `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms`(additive, `ingest_progress.v1` 유지, serde default 0). 이미지·PDF 경로도 `asset_timings` emit(이전 markdown 만). 기본 동작 불변. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 진행 로그), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-ingest-log-improve-*.md`.
|
||||
- **2026-06-03 arctic-embed-l-v2.0 임베더 통합** — v0.26.0. 별칭 제거 후 설명형 query recall 보강(측정 recall@10 130/132, e5 +7). `kebab-embed-candle` 모델 레지스트리화(e5 mean + `snowflake-arctic-embed-l-v2.0` CLS, 모델별 pooling/prefix) + 신규 `kebab-embed-ollama`(`provider="ollama"`, `/api/embed`). config `endpoint: Option<String>` 추가. 기본 e5 유지(opt-in), arctic 전환은 embedding_version cascade → 재색인. candle↔Ollama cosine>0.99 게이트로 pooling/prefix 정확성 고정(`#[ignore]`). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 arctic), spec `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`.
|
||||
- **2026-06-03 doc-side expansion(별칭) 기능 완전 제거** — v0.25.0. 아래 2026-05-31 항목의 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 **전부 제거**(ROI 음수: cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). `Chunk.aliases`/`expansion.rs`/`IngestExpansionCfg`/alias lexical arm/`expansion_progress` wire kind 제거, 신규 마이그레이션 **V013** 이 `chunk_aliases_fts`+`chunks.aliases` DROP. 별칭 default-off 였어 사용자 체감 0, 기존 KB 도 재색인 불요(잔존 별칭 벡터는 `strip_alias_suffix` graceful 매핑/`reset` 정리). `AssetTimings.expansion_ms` 는 wire 호환 위해 값 0 으로 유지. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03), spec `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`.
|
||||
|
||||
@@ -68,7 +68,7 @@ rsync -a <src-data_dir>/lancedb/ user@server:<dst-data_dir>/lancedb/
|
||||
|
||||
### 멀티미디어 색인
|
||||
|
||||
Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go/Java/Kotlin/C/C++ AST) · 리소스(YAML/Dockerfile/TOML/JSON/XML 등)를 확장자에 따라 자동으로 적절한 chunker 에 라우팅한다. embedded text 가 없는 scanned PDF 는 `[pdf.ocr]` 로 page-단위 OCR (opt-in). 전체 확장자→chunker 매핑은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go/Java/Kotlin/C/C++ AST) · 리소스(YAML/Dockerfile/TOML/JSON/XML 등)를 확장자에 따라 자동으로 적절한 chunker 에 라우팅한다. embedded text 가 없는 scanned PDF 는 `[ingest.pdf.ocr]` 로 page-단위 OCR (opt-in). 전체 확장자→chunker 매핑은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
### RAG (근거 인용 + 거절)
|
||||
|
||||
@@ -182,9 +182,11 @@ prompt_template_version = "rag-v3" # 답변 언어 = 질문 언어. rag-v1/v2
|
||||
nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedness 검증.
|
||||
```
|
||||
|
||||
- **`[ingest]`** (v0.28.0) — 모든 형식 ingest 설정의 우산. 병렬도(`max_parallel_extractors`/`max_parallel_embeddings`/`watch_filesystem`, ← 옛 `[indexing]`)와 형식별 하위 절(`[ingest.chunking]` ← 옛 `[chunking]`, `[ingest.code]`, `[ingest.image.ocr]` ← 옛 `[image.ocr]`, `[ingest.pdf.ocr]` ← 옛 `[pdf.ocr]`)이 전부 이 아래로 모인다. 기존 v2 `config.toml` 은 그대로 둬도 로드 시 메모리에서 자동 변환되며, 파일을 새 레이아웃으로 갱신하려면 `kebab config migrate` (값·주석 보존).
|
||||
- **파생물 캐시** — embedding 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
|
||||
- **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer.
|
||||
- **`[pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). 활성화 후 v0.19 시절 색인분은 `kebab ingest --force-reingest` 로 재처리.
|
||||
- **`[ingest.image.ocr]`** — 이미지 OCR (default off / opt-in). `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능 (`KEBAB_IMAGE_OCR_*` env 동일 지원 — env 이름은 v3 에서도 불변). engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
|
||||
- **`[ingest.pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). `engine` 은 `[ingest.image.ocr]` 과 동일하게 `"ollama-vision"`/`"paddle-onnx"` 선택. v3 에서 paddle 모델 경로 키(`det_model`/`rec_model`/`dict`/`score_thresh`/`unclip_ratio`/`max_boxes`)를 PDF 자체적으로 가질 수 있다(`KEBAB_PDF_OCR_*` env 동일). 활성화 후 옛 색인분은 `kebab ingest --force-reingest` 로 재처리.
|
||||
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI · TUI 모두 honor).
|
||||
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
|
||||
- **`KEBAB_*` env** — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN` 등).
|
||||
|
||||
@@ -924,7 +924,7 @@ impl App {
|
||||
k: u32::try_from(query.k).unwrap_or(u32::MAX),
|
||||
snippet_chars: u32::try_from(self.config.search.snippet_chars).unwrap_or(u32::MAX),
|
||||
embedding_version,
|
||||
chunker_version: self.config.chunking.chunker_version.clone(),
|
||||
chunker_version: self.config.ingest.chunking.chunker_version.clone(),
|
||||
corpus_revision: self.sqlite.corpus_revision(),
|
||||
})
|
||||
}
|
||||
@@ -1025,7 +1025,7 @@ impl App {
|
||||
fn lexical_index_version(config: &kebab_config::Config) -> IndexVersion {
|
||||
IndexVersion(format!(
|
||||
"lex:{}:fts5-v009-korean-morphological",
|
||||
config.chunking.chunker_version
|
||||
config.ingest.chunking.chunker_version
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ use kebab_core::{
|
||||
SearchHit, SearchQuery, SourceScope, SourceUri, VectorRecord, VectorStore,
|
||||
};
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
use kebab_parse_image::{OcrEngine, OllamaVisionOcr, apply_caption, apply_ocr};
|
||||
use kebab_parse_image::{
|
||||
OLLAMA_VISION_ENGINE, OcrEngine, OllamaVisionOcr, OnnxPaddleOcr, PADDLE_ONNX_ENGINE,
|
||||
apply_caption, apply_ocr, engine_version_for_paths,
|
||||
};
|
||||
use kebab_parse_md::{BodyHints, build_canonical_document, parse_blocks, parse_frontmatter};
|
||||
use kebab_source_fs::FsSourceConnector;
|
||||
|
||||
@@ -357,12 +360,12 @@ pub fn ingest_with_config_opts(
|
||||
// loop is correct and cheap. Construction failure (e.g. invalid
|
||||
// endpoint) aborts ingest fail-fast — better than silently disabling
|
||||
// OCR/caption mid-run.
|
||||
let ocr_engine: Option<OllamaVisionOcr> = if app.config.image.ocr.enabled {
|
||||
Some(OllamaVisionOcr::new(&app.config).context("kb-app::ingest: build OllamaVisionOcr")?)
|
||||
let ocr_engine: Option<Box<dyn OcrEngine>> = if app.config.ingest.image.ocr.enabled {
|
||||
Some(build_image_ocr_engine(&app.config).context("kb-app::ingest: build image OCR engine")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let caption_llm: Option<Box<dyn LanguageModel>> = if app.config.image.caption.enabled {
|
||||
let caption_llm: Option<Box<dyn LanguageModel>> = if app.config.ingest.image.caption.enabled {
|
||||
Some(Box::new(OllamaLanguageModel::new(&app.config).context(
|
||||
"kb-app::ingest: build OllamaLanguageModel for caption",
|
||||
)?))
|
||||
@@ -370,28 +373,17 @@ pub fn ingest_with_config_opts(
|
||||
None
|
||||
};
|
||||
let image_pipeline = ImagePipeline {
|
||||
ocr_engine: ocr_engine.as_ref(),
|
||||
ocr_engine: ocr_engine.as_deref(),
|
||||
caption_llm: caption_llm.as_deref(),
|
||||
};
|
||||
|
||||
// p10 / v0.20 sub-item 1: PDF OCR engine eager init (H-5 resolution).
|
||||
// image OCR pattern mirror — per-ingest 1회 build, fallible → fail-fast.
|
||||
let pdf_ocr_engine: Option<OllamaVisionOcr> =
|
||||
if app.config.pdf.ocr.enabled || app.config.pdf.ocr.always_on {
|
||||
let cfg = &app.config.pdf.ocr;
|
||||
let endpoint = match cfg.endpoint.as_deref() {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => app.config.models.llm.endpoint.clone(),
|
||||
};
|
||||
let pdf_ocr_engine: Option<Box<dyn OcrEngine>> =
|
||||
if app.config.ingest.pdf.ocr.enabled || app.config.ingest.pdf.ocr.always_on {
|
||||
Some(
|
||||
OllamaVisionOcr::from_parts(
|
||||
endpoint,
|
||||
cfg.model.clone(),
|
||||
cfg.languages.clone(),
|
||||
cfg.max_pixels,
|
||||
cfg.request_timeout_secs,
|
||||
)
|
||||
.context("kb-app::ingest: build OllamaVisionOcr (pdf)")?,
|
||||
build_pdf_ocr_engine(&app.config)
|
||||
.context("kb-app::ingest: build pdf OCR engine")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -488,7 +480,7 @@ pub fn ingest_with_config_opts(
|
||||
&existing_doc_ids,
|
||||
&image_pipeline,
|
||||
force_reingest,
|
||||
pdf_ocr_engine.as_ref(),
|
||||
pdf_ocr_engine.as_deref(),
|
||||
progress,
|
||||
opts.cancel.as_ref(),
|
||||
log_writer.clone(),
|
||||
@@ -832,11 +824,84 @@ fn mint_ingest_run_id(scope_json: &str, at: time::OffsetDateTime) -> String {
|
||||
/// `<… as JobRepo>` to be explicit.
|
||||
type SqliteStoreAlias = kebab_store_sqlite::SqliteStore;
|
||||
|
||||
/// v0.27.0 (T8): build the image OCR engine selected by
|
||||
/// `config.ingest.image.ocr.engine`. Returns a boxed trait object so the ingest
|
||||
/// pipeline is engine-agnostic. Construction is fail-fast (model load /
|
||||
/// hash / endpoint validation) — mirrors the prior concrete-type behaviour.
|
||||
///
|
||||
/// `--config` facade: the caller threads the explicit [`kebab_config::Config`]
|
||||
/// in, so `OnnxPaddleOcr::new` honours `image.ocr.{det_model,rec_model,dict,…}`
|
||||
/// overrides resolved from that config (not a re-loaded XDG default).
|
||||
fn build_image_ocr_engine(
|
||||
config: &kebab_config::Config,
|
||||
) -> anyhow::Result<Box<dyn OcrEngine>> {
|
||||
match config.ingest.image.ocr.engine.as_str() {
|
||||
OLLAMA_VISION_ENGINE => Ok(Box::new(
|
||||
OllamaVisionOcr::new(config).context("build OllamaVisionOcr")?,
|
||||
)),
|
||||
PADDLE_ONNX_ENGINE => Ok(Box::new(
|
||||
OnnxPaddleOcr::new(config).context("build OnnxPaddleOcr")?,
|
||||
)),
|
||||
other => anyhow::bail!(
|
||||
"unknown image.ocr.engine {other:?}; expected \
|
||||
{OLLAMA_VISION_ENGINE:?} or {PADDLE_ONNX_ENGINE:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.27.0 (T8): build the PDF OCR engine selected by
|
||||
/// `config.ingest.pdf.ocr.engine`. The ollama-vision arm uses the PDF-specific
|
||||
/// `model` / `languages` / `max_pixels` / `request_timeout_secs` knobs (and
|
||||
/// endpoint fallback to `models.llm.endpoint`). The paddle-onnx arm shares
|
||||
/// the same bundled ONNX models as image OCR (resolved from `image.ocr`
|
||||
/// overrides) — PaddleOCR is page-agnostic and carries no per-engine prompt.
|
||||
///
|
||||
/// # Paddle-ONNX asymmetry
|
||||
///
|
||||
/// When `pdf.ocr.engine = "paddle-onnx"`, the model paths and tuning knobs
|
||||
/// (`det_model`, `rec_model`, `dict`, `score_thresh`, `unclip_ratio`,
|
||||
/// `max_boxes`, `max_pixels`) are read from **`[image.ocr]`**, not
|
||||
/// `[pdf.ocr]`. PaddleOCR has no PDF-specific prompt or page-level config;
|
||||
/// `[pdf.ocr]` fields other than `engine` / `enabled` / `always_on` /
|
||||
/// `valid_ratio_threshold` / `min_char_count` / `lang_hint` are effectively
|
||||
/// ignored for the paddle path. This asymmetry is intentional — one set of
|
||||
/// tuned ONNX knobs serves both image and PDF pages.
|
||||
fn build_pdf_ocr_engine(
|
||||
config: &kebab_config::Config,
|
||||
) -> anyhow::Result<Box<dyn OcrEngine>> {
|
||||
match config.ingest.pdf.ocr.engine.as_str() {
|
||||
OLLAMA_VISION_ENGINE => {
|
||||
let cfg = &config.ingest.pdf.ocr;
|
||||
let endpoint = match cfg.endpoint.as_deref() {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => config.models.llm.endpoint.clone(),
|
||||
};
|
||||
Ok(Box::new(
|
||||
OllamaVisionOcr::from_parts(
|
||||
endpoint,
|
||||
cfg.model.clone(),
|
||||
cfg.languages.clone(),
|
||||
cfg.max_pixels,
|
||||
cfg.request_timeout_secs,
|
||||
)
|
||||
.context("build OllamaVisionOcr (pdf)")?,
|
||||
))
|
||||
}
|
||||
PADDLE_ONNX_ENGINE => Ok(Box::new(
|
||||
OnnxPaddleOcr::new(config).context("build OnnxPaddleOcr (pdf)")?,
|
||||
)),
|
||||
other => anyhow::bail!(
|
||||
"unknown pdf.ocr.engine {other:?}; expected \
|
||||
{OLLAMA_VISION_ENGINE:?} or {PADDLE_ONNX_ENGINE:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
ocr_engine: Option<&'a OllamaVisionOcr>,
|
||||
ocr_engine: Option<&'a dyn OcrEngine>,
|
||||
caption_llm: Option<&'a dyn LanguageModel>,
|
||||
}
|
||||
|
||||
@@ -1110,7 +1175,7 @@ fn ingest_one_asset(
|
||||
existing_doc_ids: &std::collections::HashSet<String>,
|
||||
image_pipeline: &ImagePipeline<'_>,
|
||||
force_reingest: bool,
|
||||
pdf_ocr_engine: Option<&OllamaVisionOcr>,
|
||||
pdf_ocr_engine: Option<&dyn OcrEngine>,
|
||||
progress: Option<&std::sync::mpsc::Sender<crate::ingest_progress::IngestEvent>>,
|
||||
cancel: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
log_writer: Option<Arc<Mutex<crate::ingest_log::IngestLogWriter>>>,
|
||||
@@ -1242,6 +1307,12 @@ fn ingest_one_asset(
|
||||
}
|
||||
};
|
||||
|
||||
// v0.26.2: fold the ingest-config signature into the effective
|
||||
// parser_version for the skip compare + the stored doc field, so a
|
||||
// change to any markdown-affecting setting (chunking params) re-indexes.
|
||||
// `doc_id` keeps deriving from the base version below (stability).
|
||||
let eff_parser_version = effective_parser_version(&app.config, asset, parser_version);
|
||||
|
||||
// p9-fb-23 task 7: incremental-ingest early-skip. When force_reingest
|
||||
// is false AND the on-disk asset's checksum + parser_version +
|
||||
// last_chunker_version + last_embedding_version all match the existing
|
||||
@@ -1251,7 +1322,7 @@ fn ingest_one_asset(
|
||||
if let Some(item) = try_skip_unchanged(
|
||||
app,
|
||||
asset,
|
||||
parser_version,
|
||||
&eff_parser_version,
|
||||
&MdHeadingV1Chunker.chunker_version(),
|
||||
embedder.map(|e| e.model_version()).as_ref(),
|
||||
force_reingest,
|
||||
@@ -1297,6 +1368,10 @@ fn ingest_one_asset(
|
||||
let mut canonical =
|
||||
build_canonical_document(asset, metadata, parsed_blocks, parser_version, all_warnings)
|
||||
.context("kb-parse-md::build_canonical_document")?;
|
||||
// v0.26.2: persist the composite parser_version (base|signature) so the
|
||||
// next run's skip compare matches what was computed above. doc_id was
|
||||
// already derived from the base version inside build_canonical_document.
|
||||
canonical.parser_version = eff_parser_version.clone();
|
||||
|
||||
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
@@ -1529,11 +1604,15 @@ fn ingest_one_image_asset(
|
||||
// embedding-version check matches the markdown path: when the
|
||||
// active embedder's model_version equals what was stamped on the
|
||||
// existing doc, the asset is Unchanged.
|
||||
// v0.26.2: composite parser_version folds image OCR / caption + chunking
|
||||
// settings, so toggling `[image.ocr]` / `[image.caption]` (or changing
|
||||
// their model / prompt version) auto-re-indexes the affected images.
|
||||
let image_parser_version = ParserVersion(kebab_parse_image::PARSER_VERSION.to_string());
|
||||
let eff_parser_version = effective_parser_version(&app.config, asset, &image_parser_version);
|
||||
if let Some(item) = try_skip_unchanged(
|
||||
app,
|
||||
asset,
|
||||
&image_parser_version,
|
||||
&eff_parser_version,
|
||||
&MdHeadingV1Chunker.chunker_version(),
|
||||
embedder.map(|e| e.model_version()).as_ref(),
|
||||
force_reingest,
|
||||
@@ -1563,6 +1642,10 @@ fn ingest_one_image_asset(
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (image)")?;
|
||||
// v0.26.2: store the composite parser_version (extractor baked the base
|
||||
// `image-meta-v1`, which already fixed doc_id). Skip compare + stored
|
||||
// field must agree for next-run detection.
|
||||
canonical.parser_version = eff_parser_version.clone();
|
||||
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// 2 + 3. Apply OCR / caption when their adapters exist. Both are
|
||||
@@ -2061,7 +2144,7 @@ fn sweep_deleted_files(
|
||||
/// asset rollback on embed-fail is a P+ task).
|
||||
///
|
||||
/// `chunker_version` is hard-coded to `pdf-page-v1` (HOTFIXES entry —
|
||||
/// `config.chunking.chunker_version` is single-valued today and serves
|
||||
/// `config.ingest.chunking.chunker_version` is single-valued today and serves
|
||||
/// the markdown path; per-medium config split is a P+ chunker registry
|
||||
/// task).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2075,7 +2158,7 @@ fn ingest_one_pdf_asset(
|
||||
vector_store: Option<&Arc<kebab_store_vector::LanceVectorStore>>,
|
||||
existing_doc_ids: &std::collections::HashSet<String>,
|
||||
force_reingest: bool,
|
||||
pdf_ocr_engine: Option<&OllamaVisionOcr>,
|
||||
pdf_ocr_engine: Option<&dyn OcrEngine>,
|
||||
progress: Option<&std::sync::mpsc::Sender<crate::ingest_progress::IngestEvent>>,
|
||||
cancel: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
log_writer: Option<Arc<Mutex<crate::ingest_log::IngestLogWriter>>>,
|
||||
@@ -2106,11 +2189,14 @@ fn ingest_one_pdf_asset(
|
||||
// p9-fb-23 task 7: incremental-ingest early-skip for the PDF flow.
|
||||
// PDF docs use `pdf-text-v1` as the parser_version and `PdfPageV1Chunker`
|
||||
// as the chunker — both pinned per-medium today (no config knob).
|
||||
// v0.26.2: composite parser_version folds pdf.ocr (enabled/always_on/
|
||||
// model) + chunking, so enabling scanned-PDF OCR auto-re-indexes PDFs.
|
||||
let pdf_parser_version = ParserVersion(kebab_parse_pdf::PARSER_VERSION.to_string());
|
||||
let eff_parser_version = effective_parser_version(&app.config, asset, &pdf_parser_version);
|
||||
if let Some(item) = try_skip_unchanged(
|
||||
app,
|
||||
asset,
|
||||
&pdf_parser_version,
|
||||
&eff_parser_version,
|
||||
&PdfPageV1Chunker.chunker_version(),
|
||||
embedder.map(|e| e.model_version()).as_ref(),
|
||||
force_reingest,
|
||||
@@ -2135,20 +2221,23 @@ fn ingest_one_pdf_asset(
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (pdf)")?;
|
||||
// v0.26.2: store the composite parser_version (base `pdf-text-v1` already
|
||||
// fixed doc_id) so the next run's skip compare matches.
|
||||
canonical.parser_version = eff_parser_version.clone();
|
||||
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// v0.20 sub-item 1: post-extract OCR enrichment (PR #187 registry
|
||||
// dispatch invariant 보존 — extract_for 가 normal entry).
|
||||
let (pdf_ocr_pages, pdf_ocr_ms_total): (Option<u32>, Option<u64>) =
|
||||
if app.config.pdf.ocr.enabled || app.config.pdf.ocr.always_on {
|
||||
if app.config.ingest.pdf.ocr.enabled || app.config.ingest.pdf.ocr.always_on {
|
||||
match pdf_ocr_engine {
|
||||
Some(engine) => {
|
||||
let ocr_opts = crate::pdf_ocr_apply::PdfOcrOpts {
|
||||
enabled: app.config.pdf.ocr.enabled || app.config.pdf.ocr.always_on,
|
||||
always_on: app.config.pdf.ocr.always_on,
|
||||
valid_ratio_threshold: app.config.pdf.ocr.valid_ratio_threshold,
|
||||
min_char_count: app.config.pdf.ocr.min_char_count,
|
||||
lang_hint: app.config.pdf.ocr.lang_hint.clone().map(kebab_core::Lang),
|
||||
enabled: app.config.ingest.pdf.ocr.enabled || app.config.ingest.pdf.ocr.always_on,
|
||||
always_on: app.config.ingest.pdf.ocr.always_on,
|
||||
valid_ratio_threshold: app.config.ingest.pdf.ocr.valid_ratio_threshold,
|
||||
min_char_count: app.config.ingest.pdf.ocr.min_char_count,
|
||||
lang_hint: app.config.ingest.pdf.ocr.lang_hint.clone().map(kebab_core::Lang),
|
||||
cancel: cancel.cloned(),
|
||||
};
|
||||
// v0.20.x Hook 2: pre-clone Arcs for capture by OCR closure.
|
||||
@@ -2267,7 +2356,7 @@ fn ingest_one_pdf_asset(
|
||||
};
|
||||
|
||||
// Per-medium chunker selection: PDF docs always use pdf-page-v1
|
||||
// regardless of `config.chunking.chunker_version`. The chunker
|
||||
// regardless of `config.ingest.chunking.chunker_version`. The chunker
|
||||
// validates every block carries `SourceSpan::Page`; failure here
|
||||
// means the parser drifted from its contract.
|
||||
let chunker = PdfPageV1Chunker;
|
||||
@@ -2510,10 +2599,19 @@ fn ingest_one_code_asset(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// v0.26.2: composite parser_version folds [ingest.code] options + common
|
||||
// chunking so editing any code-ingest setting auto-re-indexes code assets.
|
||||
// The base per-lang version still derives doc_id (synthesize_tier2_document
|
||||
// / extract_for keep using `parser_version`). A Tier-3 fallback document
|
||||
// intentionally keeps the bare "none-v1" parser_version (the
|
||||
// `stored_is_tier3_fallback` bypass in try_skip_unchanged depends on the
|
||||
// exact "none-v1" sentinel), so the composite is only stamped on the
|
||||
// normal (non-fallback) outcome below.
|
||||
let eff_parser_version = effective_parser_version(&app.config, asset, &parser_version);
|
||||
if let Some(item) = try_skip_unchanged(
|
||||
app,
|
||||
asset,
|
||||
&parser_version,
|
||||
&eff_parser_version,
|
||||
&chunker_version,
|
||||
embedder.map(|e| e.model_version()).as_ref(),
|
||||
force_reingest,
|
||||
@@ -2678,6 +2776,20 @@ fn ingest_one_code_asset(
|
||||
}
|
||||
};
|
||||
|
||||
// v0.26.2: stamp the composite parser_version for the normal outcome so
|
||||
// editing any [ingest.code] / chunking setting re-indexes this asset next
|
||||
// run. A Tier-3 fallback (an AST / manifest lang whose extractor or
|
||||
// chunker degraded to CodeTextParagraphV1Chunker) must keep the bare
|
||||
// "none-v1" sentinel, because `try_skip_unchanged`'s
|
||||
// `stored_is_tier3_fallback` bypass keys off that exact string. `shell`
|
||||
// is native Tier 3 (no bypass — `tier3_fallback_cv` is None for it), so it
|
||||
// still gets the composite.
|
||||
let is_tier3_fallback_outcome =
|
||||
code_lang != "shell" && chunker_version == CodeTextParagraphV1Chunker.chunker_version();
|
||||
if !is_tier3_fallback_outcome {
|
||||
canonical.parser_version = eff_parser_version.clone();
|
||||
}
|
||||
|
||||
// Stamp chunker + embedding versions so incremental skip detection has
|
||||
// data on the second run.
|
||||
canonical.last_chunker_version = Some(chunker_version.clone());
|
||||
@@ -2944,13 +3056,195 @@ fn build_body_hints(asset: &RawAsset) -> BodyHints {
|
||||
/// Build a `ChunkPolicy` from the active config.
|
||||
fn chunk_policy_from_config(config: &kebab_config::Config) -> ChunkPolicy {
|
||||
ChunkPolicy {
|
||||
target_tokens: config.chunking.target_tokens,
|
||||
overlap_tokens: config.chunking.overlap_tokens,
|
||||
respect_markdown_headings: config.chunking.respect_markdown_headings,
|
||||
chunker_version: ChunkerVersion(config.chunking.chunker_version.clone()),
|
||||
target_tokens: config.ingest.chunking.target_tokens,
|
||||
overlap_tokens: config.ingest.chunking.overlap_tokens,
|
||||
respect_markdown_headings: config.ingest.chunking.respect_markdown_headings,
|
||||
chunker_version: ChunkerVersion(config.ingest.chunking.chunker_version.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.26.2: deterministic signature of the **ingest-output-affecting**
|
||||
/// config for an asset's media type, folded into the effective
|
||||
/// `parser_version` (both the `try_skip_unchanged` compare field AND the
|
||||
/// persisted `documents.parser_version`). When any setting that changes the
|
||||
/// produced chunks / embeddings is edited, the next ingest's signature no
|
||||
/// longer matches the stored one → the affected assets (only) are
|
||||
/// automatically re-indexed without `--force-reingest`.
|
||||
///
|
||||
/// Inclusion rule: "does changing this value alter the chunk / embedding
|
||||
/// content that gets indexed?" Settings that do NOT (search / rag / nli /
|
||||
/// ui / logging / storage / workspace, plus runtime-only knobs like
|
||||
/// `max_pixels` / `languages` / `*_timeout_secs`) are deliberately excluded
|
||||
/// to avoid over-invalidation. Embedding model/dim is already covered by the
|
||||
/// separate `embedding_version` cascade in [`try_skip_unchanged`], so it is
|
||||
/// not duplicated here.
|
||||
///
|
||||
/// The output is purely a comparison token — it is never parsed back, so the
|
||||
/// exact format is internal. Field order is fixed and `Vec`s are joined so
|
||||
/// the same `Config` always yields the same string.
|
||||
/// Process-wide memo of the paddle-onnx `engine_version`, keyed by the
|
||||
/// resolved (det,rec,dict) override triple. Hashing the ~17 MB of model bytes
|
||||
/// happens once per triple per process (m3 — never re-hash per asset); the
|
||||
/// per-asset [`ingest_config_signature`] calls hit this cache.
|
||||
static PADDLE_OCR_VERSION_MEMO: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
/// T9/v3: resolve the OCR `engine_version` string used inside the ingest config
|
||||
/// signature. ollama-vision is self-describing from `engine/model` (cheap, no
|
||||
/// I/O). paddle-onnx hashes the bundled/override model assets (memoized).
|
||||
///
|
||||
/// v3: paddle 경로(det/rec/dict)는 **호출자가 미디어별로** 넘긴다 — image 는
|
||||
/// `[ingest.image.ocr]`, pdf 는 `[ingest.pdf.ocr]`. v2 의 "pdf 가 image paddle
|
||||
/// 을 빌려쓰던" 비대칭을 제거한다. 마이그레이션(T5)이 pdf 대칭 키를 image 값
|
||||
/// 으로 채우므로 미변환 v2 → v3 의 signature 는 바이트 동일하게 유지된다.
|
||||
fn ocr_engine_version_for_sig(
|
||||
engine: &str,
|
||||
model: &str,
|
||||
det: Option<&str>,
|
||||
rec: Option<&str>,
|
||||
dict: Option<&str>,
|
||||
) -> String {
|
||||
if engine != PADDLE_ONNX_ENGINE {
|
||||
// ollama-vision (and any non-paddle engine): the daemon exposes no
|
||||
// stable per-model revision, so engine/model is the identity.
|
||||
return format!("ollama/{model}");
|
||||
}
|
||||
let key = format!(
|
||||
"{}|{}|{}",
|
||||
det.unwrap_or("<bundled>"),
|
||||
rec.unwrap_or("<bundled>"),
|
||||
dict.unwrap_or("<bundled>"),
|
||||
);
|
||||
let memo = PADDLE_OCR_VERSION_MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
if let Some(v) = memo.lock().unwrap().get(&key) {
|
||||
return v.clone();
|
||||
}
|
||||
// First call for this triple in this process: hash once. In any real
|
||||
// ingest the engine was already built (fail-fast) so the assets are
|
||||
// present and this succeeds; the path-derived identity below is an
|
||||
// unreachable-in-practice guard that keeps the signature total.
|
||||
let version = engine_version_for_paths(det, rec, dict).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "kebab-app::ingest",
|
||||
error = %e,
|
||||
"paddle-onnx engine_version hash failed; using path-derived identity for signature"
|
||||
);
|
||||
format!("ppocrv5-mobile-kor-paths:{key}")
|
||||
});
|
||||
memo.lock().unwrap().insert(key, version.clone());
|
||||
version
|
||||
}
|
||||
|
||||
/// v3: signature 바이트 불변 골든을 위한 테스트 seam. `ingest_config_signature`
|
||||
/// 는 private 이라 통합 테스트에서 직접 못 부른다. 값 기반이라 struct 경로가
|
||||
/// 바뀌어도(미디어 ingest 통합) 출력 문자열은 v2 와 바이트 동일해야 한다.
|
||||
#[doc(hidden)]
|
||||
pub fn test_ingest_config_signature(c: &kebab_config::Config, m: &MediaType) -> String {
|
||||
ingest_config_signature(c, m)
|
||||
}
|
||||
|
||||
fn ingest_config_signature(config: &kebab_config::Config, media: &MediaType) -> String {
|
||||
// Common (every media type): chunking parameters that move chunk
|
||||
// boundaries. `target_tokens` / `overlap_tokens` change re-chunking for
|
||||
// markdown / image / pdf / code alike, so a change re-indexes all types.
|
||||
let c = &config.ingest.chunking;
|
||||
let mut sig = format!(
|
||||
"chunk:{}:{}:{}:{}",
|
||||
c.target_tokens, c.overlap_tokens, c.respect_markdown_headings, c.chunker_version
|
||||
);
|
||||
match media {
|
||||
MediaType::Image(_) => {
|
||||
// OCR / caption only affect output when their `enabled` flag is
|
||||
// on; the model / prompt version matters only then. Off ↔ off is
|
||||
// a stable empty token so re-running the same config skips.
|
||||
let ocr = &config.ingest.image.ocr;
|
||||
if ocr.enabled {
|
||||
// v0.27.0 (T9): engine + engine_version so switching engine
|
||||
// (ollama-vision ↔ paddle-onnx) OR changing the model/assets
|
||||
// invalidates downstream chunks (design §9 cascade).
|
||||
sig.push_str(&format!(
|
||||
"|ocr:1:{}:{}",
|
||||
ocr.engine,
|
||||
ocr_engine_version_for_sig(
|
||||
&ocr.engine,
|
||||
&ocr.model,
|
||||
ocr.det_model.as_deref(),
|
||||
ocr.rec_model.as_deref(),
|
||||
ocr.dict.as_deref(),
|
||||
)
|
||||
));
|
||||
} else {
|
||||
sig.push_str("|ocr:0");
|
||||
}
|
||||
let cap = &config.ingest.image.caption;
|
||||
if cap.enabled {
|
||||
sig.push_str(&format!("|cap:1:{}", cap.prompt_template_version));
|
||||
} else {
|
||||
sig.push_str("|cap:0");
|
||||
}
|
||||
}
|
||||
MediaType::Pdf => {
|
||||
// PDF OCR is active when EITHER `enabled` or `always_on` is set
|
||||
// (mirrors the ingest gate). `model` only matters when active.
|
||||
let ocr = &config.ingest.pdf.ocr;
|
||||
if ocr.enabled || ocr.always_on {
|
||||
// v0.27.0 (T9): engine + engine_version (same cascade rule as
|
||||
// image OCR above) alongside the enabled/always_on gate.
|
||||
sig.push_str(&format!(
|
||||
"|pdfocr:{}:{}:{}:{}",
|
||||
ocr.enabled,
|
||||
ocr.always_on,
|
||||
ocr.engine,
|
||||
ocr_engine_version_for_sig(
|
||||
&ocr.engine,
|
||||
&ocr.model,
|
||||
ocr.det_model.as_deref(),
|
||||
ocr.rec_model.as_deref(),
|
||||
ocr.dict.as_deref(),
|
||||
)
|
||||
));
|
||||
} else {
|
||||
sig.push_str("|pdfocr:0");
|
||||
}
|
||||
}
|
||||
MediaType::Code(_) => {
|
||||
let cc = &config.ingest.code;
|
||||
sig.push_str(&format!(
|
||||
"|code:{}:{}:{}:{}:{}:{}:{}",
|
||||
cc.skip_generated_header,
|
||||
cc.max_file_bytes,
|
||||
cc.max_file_lines,
|
||||
cc.extra_skip_globs.join(","),
|
||||
cc.ast_chunk_max_lines,
|
||||
cc.fallback_lines_per_chunk,
|
||||
cc.fallback_lines_overlap
|
||||
));
|
||||
}
|
||||
// Markdown carries common-only; Audio / Other are not ingested yet.
|
||||
MediaType::Markdown | MediaType::Audio(_) | MediaType::Other(_) => {}
|
||||
}
|
||||
sig
|
||||
}
|
||||
|
||||
/// Compose an extractor's base `parser_version` with the ingest-config
|
||||
/// signature for `asset`'s media type. The result is used as the
|
||||
/// `try_skip_unchanged` compare value and stored on the persisted document,
|
||||
/// while the **base** version is what derives `doc_id` (kept stable to avoid
|
||||
/// orphan churn — see the spec at
|
||||
/// `docs/superpowers/specs/2026-06-03-ocr-toggle-invalidation-spec.md`).
|
||||
fn effective_parser_version(
|
||||
config: &kebab_config::Config,
|
||||
asset: &RawAsset,
|
||||
base: &ParserVersion,
|
||||
) -> ParserVersion {
|
||||
ParserVersion(format!(
|
||||
"{}|{}",
|
||||
base.0,
|
||||
ingest_config_signature(config, &asset.media_type)
|
||||
))
|
||||
}
|
||||
|
||||
// ── list_docs / inspect_doc / inspect_chunk ───────────────────────────────
|
||||
|
||||
pub fn list_docs(filter: DocFilter) -> anyhow::Result<Vec<DocSummary>> {
|
||||
@@ -3429,3 +3723,337 @@ fn check_kebabignore_match(
|
||||
.is_ignore()
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod ingest_config_signature_tests {
|
||||
//! v0.26.2: unit tests for [`ingest_config_signature`] — the
|
||||
//! ingest-output-affecting config fingerprint that is folded into the
|
||||
//! effective `parser_version` so that changing any setting that alters
|
||||
//! the produced chunks/embeddings auto-re-indexes the affected assets,
|
||||
//! while changes to unrelated settings (search/rag/ui/…) do not.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{ImageType, MediaType};
|
||||
|
||||
use super::ingest_config_signature;
|
||||
|
||||
fn img() -> MediaType {
|
||||
MediaType::Image(ImageType::Png)
|
||||
}
|
||||
fn pdf() -> MediaType {
|
||||
MediaType::Pdf
|
||||
}
|
||||
fn code() -> MediaType {
|
||||
MediaType::Code("rust".to_string())
|
||||
}
|
||||
fn md() -> MediaType {
|
||||
MediaType::Markdown
|
||||
}
|
||||
|
||||
/// The signature is deterministic: same config + same media → same string.
|
||||
#[test]
|
||||
fn deterministic_for_unchanged_config() {
|
||||
let c = Config::defaults();
|
||||
for m in [md(), img(), pdf(), code()] {
|
||||
assert_eq!(
|
||||
ingest_config_signature(&c, &m),
|
||||
ingest_config_signature(&c, &m),
|
||||
"signature must be stable for {m:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing a common chunking parameter changes the signature for EVERY
|
||||
/// media type (re-chunk cascade).
|
||||
#[test]
|
||||
fn chunking_change_invalidates_all_types() {
|
||||
let base = Config::defaults();
|
||||
let mut bumped = base.clone();
|
||||
bumped.ingest.chunking.target_tokens += 100;
|
||||
for m in [md(), img(), pdf(), code()] {
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &m),
|
||||
ingest_config_signature(&bumped, &m),
|
||||
"target_tokens change must invalidate {m:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut overlap = base.clone();
|
||||
overlap.ingest.chunking.overlap_tokens += 10;
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &md()),
|
||||
ingest_config_signature(&overlap, &md())
|
||||
);
|
||||
|
||||
let mut headings = base.clone();
|
||||
headings.ingest.chunking.respect_markdown_headings = !base.ingest.chunking.respect_markdown_headings;
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &md()),
|
||||
ingest_config_signature(&headings, &md())
|
||||
);
|
||||
}
|
||||
|
||||
/// Image OCR toggle (off→on) changes only the image signature; pdf / code
|
||||
/// / markdown are unaffected.
|
||||
#[test]
|
||||
fn image_ocr_toggle_invalidates_image_only() {
|
||||
let base = Config::defaults();
|
||||
assert!(!base.ingest.image.ocr.enabled, "default OCR is off");
|
||||
let mut on = base.clone();
|
||||
on.ingest.image.ocr.enabled = true;
|
||||
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &img()),
|
||||
ingest_config_signature(&on, &img()),
|
||||
"image OCR toggle must invalidate images"
|
||||
);
|
||||
for m in [md(), pdf(), code()] {
|
||||
assert_eq!(
|
||||
ingest_config_signature(&base, &m),
|
||||
ingest_config_signature(&on, &m),
|
||||
"image OCR toggle must NOT touch {m:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// When OCR is enabled, changing the OCR model changes the image
|
||||
/// signature; when OCR is off, the model field is irrelevant.
|
||||
#[test]
|
||||
fn image_ocr_model_matters_only_when_enabled() {
|
||||
let mut off_a = Config::defaults();
|
||||
let mut off_b = off_a.clone();
|
||||
off_b.ingest.image.ocr.model = "some-other-model".to_string();
|
||||
assert_eq!(
|
||||
ingest_config_signature(&off_a, &img()),
|
||||
ingest_config_signature(&off_b, &img()),
|
||||
"OCR model is irrelevant while OCR is off"
|
||||
);
|
||||
|
||||
off_a.ingest.image.ocr.enabled = true;
|
||||
let mut on_b = off_a.clone();
|
||||
on_b.ingest.image.ocr.model = "some-other-model".to_string();
|
||||
assert_ne!(
|
||||
ingest_config_signature(&off_a, &img()),
|
||||
ingest_config_signature(&on_b, &img()),
|
||||
"OCR model change matters while OCR is on"
|
||||
);
|
||||
}
|
||||
|
||||
/// Image caption toggle + prompt-template-version change invalidate images.
|
||||
#[test]
|
||||
fn image_caption_toggle_and_prompt_invalidate_image() {
|
||||
let base = Config::defaults();
|
||||
let mut on = base.clone();
|
||||
on.ingest.image.caption.enabled = true;
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &img()),
|
||||
ingest_config_signature(&on, &img())
|
||||
);
|
||||
|
||||
let mut prompt = on.clone();
|
||||
prompt.ingest.image.caption.prompt_template_version = "caption-v9".to_string();
|
||||
assert_ne!(
|
||||
ingest_config_signature(&on, &img()),
|
||||
ingest_config_signature(&prompt, &img()),
|
||||
"caption prompt version change matters while caption is on"
|
||||
);
|
||||
}
|
||||
|
||||
/// PDF OCR `enabled` and `always_on` both invalidate PDFs (either turns
|
||||
/// OCR on); they do not touch other media types.
|
||||
#[test]
|
||||
fn pdf_ocr_toggle_invalidates_pdf_only() {
|
||||
let base = Config::defaults();
|
||||
let mut enabled = base.clone();
|
||||
enabled.ingest.pdf.ocr.enabled = true;
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &pdf()),
|
||||
ingest_config_signature(&enabled, &pdf()),
|
||||
"pdf.ocr.enabled toggle must invalidate PDFs"
|
||||
);
|
||||
|
||||
let mut always = base.clone();
|
||||
always.ingest.pdf.ocr.always_on = true;
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &pdf()),
|
||||
ingest_config_signature(&always, &pdf()),
|
||||
"pdf.ocr.always_on toggle must invalidate PDFs"
|
||||
);
|
||||
|
||||
for m in [md(), img(), code()] {
|
||||
assert_eq!(
|
||||
ingest_config_signature(&base, &m),
|
||||
ingest_config_signature(&enabled, &m),
|
||||
"pdf OCR toggle must NOT touch {m:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each `[ingest.code]` option change invalidates code assets only.
|
||||
#[test]
|
||||
fn code_options_invalidate_code_only() {
|
||||
let base = Config::defaults();
|
||||
|
||||
let mut variants = Vec::new();
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.skip_generated_header = !base.ingest.code.skip_generated_header;
|
||||
variants.push(v);
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.max_file_bytes += 1;
|
||||
variants.push(v);
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.max_file_lines += 1;
|
||||
variants.push(v);
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.extra_skip_globs.push("**/vendor/**".to_string());
|
||||
variants.push(v);
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.ast_chunk_max_lines += 1;
|
||||
variants.push(v);
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.fallback_lines_per_chunk += 1;
|
||||
variants.push(v);
|
||||
let mut v = base.clone();
|
||||
v.ingest.code.fallback_lines_overlap += 1;
|
||||
variants.push(v);
|
||||
|
||||
for v in &variants {
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &code()),
|
||||
ingest_config_signature(v, &code()),
|
||||
"code option change must invalidate code assets"
|
||||
);
|
||||
// ...but must NOT touch md / image / pdf.
|
||||
for m in [md(), img(), pdf()] {
|
||||
assert_eq!(
|
||||
ingest_config_signature(&base, &m),
|
||||
ingest_config_signature(v, &m),
|
||||
"code option change must NOT touch {m:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard: search / rag / nli / ui / logging / storage /
|
||||
/// workspace settings — and ingest runtime-only knobs that do NOT change
|
||||
/// indexed output — never change the signature for ANY media type.
|
||||
#[test]
|
||||
fn unrelated_settings_never_invalidate() {
|
||||
let base = Config::defaults();
|
||||
let mut other = base.clone();
|
||||
// search
|
||||
other.search.default_k += 5;
|
||||
other.search.rrf_k += 1;
|
||||
other.search.snippet_chars += 10;
|
||||
// rag
|
||||
other.rag.score_gate += 0.1;
|
||||
other.rag.prompt_template_version = "rag-v99".to_string();
|
||||
// ui
|
||||
other.ui.theme = "light".to_string();
|
||||
// image runtime-only (non-output) knobs
|
||||
other.ingest.image.ocr.max_pixels += 100;
|
||||
other.ingest.image.ocr.languages.push("jpn".to_string());
|
||||
other.ingest.image.ocr.request_timeout_secs += 10;
|
||||
// pdf runtime-only knobs
|
||||
other.ingest.pdf.ocr.max_pixels += 100;
|
||||
other.ingest.pdf.ocr.request_timeout_secs += 10;
|
||||
other.ingest.pdf.ocr.languages.push("jpn".to_string());
|
||||
|
||||
for m in [md(), img(), pdf(), code()] {
|
||||
assert_eq!(
|
||||
ingest_config_signature(&base, &m),
|
||||
ingest_config_signature(&other, &m),
|
||||
"unrelated/runtime-only settings must NOT invalidate {m:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── v0.27.0 (T9): engine + engine_version cascade ─────────────────────
|
||||
|
||||
/// (a) Switching the engine (ollama-vision → paddle-onnx) with the SAME
|
||||
/// model id changes the image signature — different engines produce
|
||||
/// different output even from an identically-named model.
|
||||
#[test]
|
||||
fn image_ocr_engine_switch_invalidates_image() {
|
||||
let mut ollama = Config::defaults();
|
||||
ollama.ingest.image.ocr.enabled = true;
|
||||
// same `model` string on both — only the engine differs
|
||||
let mut paddle = ollama.clone();
|
||||
paddle.ingest.image.ocr.engine = "paddle-onnx".to_string();
|
||||
assert_ne!(
|
||||
ingest_config_signature(&ollama, &img()),
|
||||
ingest_config_signature(&paddle, &img()),
|
||||
"engine switch with identical model must invalidate images"
|
||||
);
|
||||
}
|
||||
|
||||
/// (b) A different engine_version (here: a different ollama model id, which
|
||||
/// the signature folds into `ollama/{model}`) changes the image signature.
|
||||
#[test]
|
||||
fn image_ocr_engine_version_change_invalidates_image() {
|
||||
let mut a = Config::defaults();
|
||||
a.ingest.image.ocr.enabled = true;
|
||||
a.ingest.image.ocr.model = "gemma4:e4b".to_string();
|
||||
let mut b = a.clone();
|
||||
b.ingest.image.ocr.model = "qwen2.5vl:3b".to_string();
|
||||
assert_ne!(
|
||||
ingest_config_signature(&a, &img()),
|
||||
ingest_config_signature(&b, &img()),
|
||||
"engine_version change must invalidate images"
|
||||
);
|
||||
}
|
||||
|
||||
/// (b') For the paddle-onnx engine, pointing at a different model asset
|
||||
/// (override path) yields a different engine_version → different signature.
|
||||
#[test]
|
||||
fn image_ocr_paddle_model_path_change_invalidates_image() {
|
||||
let mut base = Config::defaults();
|
||||
base.ingest.image.ocr.enabled = true;
|
||||
base.ingest.image.ocr.engine = "paddle-onnx".to_string();
|
||||
let mut overridden = base.clone();
|
||||
overridden.ingest.image.ocr.det_model = Some("/some/other/det.onnx".to_string());
|
||||
assert_ne!(
|
||||
ingest_config_signature(&base, &img()),
|
||||
ingest_config_signature(&overridden, &img()),
|
||||
"paddle-onnx model path change must invalidate images"
|
||||
);
|
||||
}
|
||||
|
||||
/// (c) Unrelated settings leave the paddle-onnx image signature stable
|
||||
/// (engine_version is memoized + deterministic for a fixed asset triple).
|
||||
#[test]
|
||||
fn paddle_image_signature_stable_for_unrelated_change() {
|
||||
let mut base = Config::defaults();
|
||||
base.ingest.image.ocr.enabled = true;
|
||||
base.ingest.image.ocr.engine = "paddle-onnx".to_string();
|
||||
let mut other = base.clone();
|
||||
other.search.default_k += 3;
|
||||
other.ingest.image.ocr.max_pixels += 100; // runtime-only knob
|
||||
assert_eq!(
|
||||
ingest_config_signature(&base, &img()),
|
||||
ingest_config_signature(&other, &img()),
|
||||
"unrelated/runtime-only changes must not invalidate paddle images"
|
||||
);
|
||||
}
|
||||
|
||||
/// PDF OCR: engine switch with the same model invalidates pdf only.
|
||||
#[test]
|
||||
fn pdf_ocr_engine_switch_invalidates_pdf() {
|
||||
let mut ollama = Config::defaults();
|
||||
ollama.ingest.pdf.ocr.enabled = true;
|
||||
let mut paddle = ollama.clone();
|
||||
paddle.ingest.pdf.ocr.engine = "paddle-onnx".to_string();
|
||||
assert_ne!(
|
||||
ingest_config_signature(&ollama, &pdf()),
|
||||
ingest_config_signature(&paddle, &pdf()),
|
||||
"pdf engine switch must invalidate pdf"
|
||||
);
|
||||
for m in [md(), img(), code()] {
|
||||
assert_eq!(
|
||||
ingest_config_signature(&ollama, &m),
|
||||
ingest_config_signature(&paddle, &m),
|
||||
"pdf engine switch must NOT touch {m:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> Mode
|
||||
// maintain their own versions; surface those when SchemaV1.models
|
||||
// becomes a multi-medium map (P+).
|
||||
parser_version: kebab_parse_md::PARSER_VERSION.to_string(),
|
||||
chunker_version: cfg.chunking.chunker_version.clone(),
|
||||
chunker_version: cfg.ingest.chunking.chunker_version.clone(),
|
||||
active_parsers,
|
||||
active_chunkers,
|
||||
// EmbeddingModelCfg uses `.model` (not `.id`) — adapt from plan.
|
||||
|
||||
@@ -52,7 +52,9 @@ fn rust_file_ingests_and_searches_as_code_citation() {
|
||||
"at least one chunk expected: {code_item:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
code_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
code_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-rust-v1"),
|
||||
"parser_version must be code-rust-v1"
|
||||
);
|
||||
@@ -185,7 +187,9 @@ fn python_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("metrics.py"))
|
||||
.expect("metrics.py item");
|
||||
assert_eq!(
|
||||
py_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
py_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-python-v1"),
|
||||
"parser_version must be code-python-v1"
|
||||
);
|
||||
@@ -261,7 +265,9 @@ fn typescript_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Foo.ts"))
|
||||
.expect("Foo.ts item");
|
||||
assert_eq!(
|
||||
ts_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
ts_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-ts-v1"),
|
||||
"parser_version must be code-ts-v1"
|
||||
);
|
||||
@@ -337,7 +343,9 @@ fn javascript_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Bar.js"))
|
||||
.expect("Bar.js item");
|
||||
assert_eq!(
|
||||
js_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
js_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-js-v1"),
|
||||
"parser_version must be code-js-v1"
|
||||
);
|
||||
@@ -415,7 +423,9 @@ fn go_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("ast.go"))
|
||||
.expect("ast.go item present");
|
||||
assert_eq!(
|
||||
go_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
go_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-go-v1"),
|
||||
"parser_version must be code-go-v1"
|
||||
);
|
||||
@@ -486,7 +496,9 @@ fn java_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Foo.java"))
|
||||
.expect("Foo.java item present");
|
||||
assert_eq!(
|
||||
java_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
java_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-java-v1"),
|
||||
"parser_version must be code-java-v1"
|
||||
);
|
||||
@@ -561,7 +573,9 @@ fn kotlin_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Foo.kt"))
|
||||
.expect("Foo.kt item present");
|
||||
assert_eq!(
|
||||
kt_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
kt_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-kotlin-v1"),
|
||||
"parser_version must be code-kotlin-v1"
|
||||
);
|
||||
@@ -634,7 +648,9 @@ fn tier2_k8s_yaml_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("deploy.yaml"))
|
||||
.expect("deploy.yaml item present");
|
||||
assert_eq!(
|
||||
yaml_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
yaml_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1"
|
||||
);
|
||||
@@ -717,7 +733,9 @@ fn tier2_dockerfile_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("Dockerfile"))
|
||||
.expect("Dockerfile item present");
|
||||
assert_eq!(
|
||||
df_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
df_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1"
|
||||
);
|
||||
@@ -800,7 +818,9 @@ fn tier2_cargo_toml_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("Cargo.toml"))
|
||||
.expect("Cargo.toml item present");
|
||||
assert_eq!(
|
||||
toml_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
toml_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1"
|
||||
);
|
||||
@@ -883,7 +903,9 @@ fn tier3_shell_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("deploy.sh"))
|
||||
.expect("deploy.sh item present");
|
||||
assert_eq!(
|
||||
sh_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
sh_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1 for shell (Tier 3 direct)"
|
||||
);
|
||||
@@ -974,7 +996,9 @@ fn tier3_yaml_fallback_picks_up_non_k8s_yaml() {
|
||||
.find(|i| i.doc_path.0.ends_with("docker-compose.yml"))
|
||||
.expect("docker-compose.yml item present");
|
||||
assert_eq!(
|
||||
yaml_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
yaml_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1 after Tier 3 fallback"
|
||||
);
|
||||
@@ -1144,7 +1168,9 @@ fn tier1_c_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("parser.c"))
|
||||
.expect("parser.c item present");
|
||||
assert_eq!(
|
||||
c_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
c_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-c-v2"),
|
||||
"parser_version must be code-c-v2 (v0.17.0 PR-B: typedef-wrapped struct/enum/union 이 typedef alias unit 으로 방출)"
|
||||
);
|
||||
@@ -1228,7 +1254,9 @@ fn tier1_cpp_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("chunker.cpp"))
|
||||
.expect("chunker.cpp item present");
|
||||
assert_eq!(
|
||||
cpp_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
cpp_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-cpp-v1"),
|
||||
"parser_version must be code-cpp-v1"
|
||||
);
|
||||
|
||||
@@ -39,6 +39,11 @@ impl OcrEngine for MockOcrEngine {
|
||||
"mock-v1".to_string()
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_literal_bound)]
|
||||
fn model(&self) -> &str {
|
||||
"mock-model"
|
||||
}
|
||||
|
||||
fn recognize(&self, _img: &[u8], _hint: Option<&Lang>) -> Result<OcrText> {
|
||||
if self.fail {
|
||||
anyhow::bail!("mock failure");
|
||||
|
||||
@@ -62,8 +62,8 @@ impl TestEnv {
|
||||
// Drop in a small chunk policy so the fixture's small files
|
||||
// emit at least a couple of chunks even with overlap_tokens
|
||||
// honored.
|
||||
config.chunking.target_tokens = 80;
|
||||
config.chunking.overlap_tokens = 20;
|
||||
config.ingest.chunking.target_tokens = 80;
|
||||
config.ingest.chunking.overlap_tokens = 20;
|
||||
|
||||
Self {
|
||||
temp,
|
||||
|
||||
169
crates/kebab-app/tests/config_invalidation.rs
Normal file
169
crates/kebab-app/tests/config_invalidation.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! v0.26.2: ingest-config invalidation — changing a setting that affects
|
||||
//! ingest output auto-re-indexes the affected assets on the next ingest
|
||||
//! (no `--force-reingest`), while changing an unrelated setting does not.
|
||||
//!
|
||||
//! These end-to-end tests exercise the model-free signals (chunking +
|
||||
//! `[ingest.code]` options vs `search` settings). The exhaustive per-setting
|
||||
//! mapping (image OCR / caption, pdf.ocr, code options, search/rag/ui
|
||||
//! invariance) is unit-tested in
|
||||
//! `kebab-app/src/lib.rs::ingest_config_signature_tests` — those toggles
|
||||
//! (OCR/caption) require a live vision endpoint to ingest, so the wiring is
|
||||
//! verified here via the signature-driven chunking path that shares the same
|
||||
//! `effective_parser_version` plumbing.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestEnv;
|
||||
|
||||
use kebab_app::{IngestOpts, ingest_with_config, ingest_with_config_opts};
|
||||
use kebab_core::IngestItemKind;
|
||||
|
||||
/// Seed a workspace with a markdown + a rust file so both the markdown and
|
||||
/// the code ingest paths are exercised. Returns the first-ingest report.
|
||||
fn seed_and_first_ingest(env: &TestEnv) -> kebab_core::IngestReport {
|
||||
std::fs::write(
|
||||
env.workspace_root.join("demo.rs"),
|
||||
"/// adds two integers\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let first = ingest_with_config(env.config.clone(), env.scope(), false).expect("first ingest");
|
||||
assert_eq!(first.errors, 0, "first ingest must not error: {first:?}");
|
||||
assert!(first.new >= 1, "first ingest creates docs: {first:?}");
|
||||
assert_eq!(first.unchanged, 0, "first ingest has no unchanged: {first:?}");
|
||||
first
|
||||
}
|
||||
|
||||
fn reingest(env: &TestEnv) -> kebab_core::IngestReport {
|
||||
ingest_with_config_opts(env.config.clone(), env.scope(), false, IngestOpts::default())
|
||||
.expect("re-ingest")
|
||||
}
|
||||
|
||||
/// Re-running with the identical config skips every asset (no spurious
|
||||
/// re-index). Regression guard for over-invalidation.
|
||||
#[test]
|
||||
fn identical_config_skips_all_assets() {
|
||||
let env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
||||
assert_eq!(second.updated, 0, "nothing re-indexed: {second:?}");
|
||||
assert_eq!(second.unchanged, scanned, "every doc Unchanged: {second:?}");
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
|
||||
/// Changing a common chunking parameter re-indexes EVERY media type
|
||||
/// (markdown + code here) without `--force-reingest`.
|
||||
#[test]
|
||||
fn chunking_change_reindexes_all_types() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
// Bump target_tokens — folds into every type's signature.
|
||||
env.config.ingest.chunking.target_tokens += 100;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
||||
assert_eq!(
|
||||
second.unchanged, 0,
|
||||
"chunking change must re-index all: {second:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
second.updated, scanned,
|
||||
"every doc re-indexed as Updated: {second:?}"
|
||||
);
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
|
||||
/// Changing an `[ingest.code]` option re-indexes only the code asset; the
|
||||
/// markdown assets stay Unchanged.
|
||||
#[test]
|
||||
fn code_option_change_reindexes_code_only() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
// Raise max_file_lines (keeps the tiny demo.rs in-scope; only the code
|
||||
// signature changes).
|
||||
env.config.ingest.code.max_file_lines += 1000;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
||||
assert_eq!(second.errors, 0);
|
||||
assert_eq!(
|
||||
second.updated, 1,
|
||||
"exactly the code asset re-indexed: {second:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
second.unchanged,
|
||||
scanned - 1,
|
||||
"all markdown assets stay Unchanged: {second:?}"
|
||||
);
|
||||
|
||||
let items = second.items.as_ref().expect("items present");
|
||||
let code = items
|
||||
.iter()
|
||||
.find(|i| i.doc_path.0.ends_with("demo.rs"))
|
||||
.expect("demo.rs item");
|
||||
assert_eq!(
|
||||
code.kind,
|
||||
IngestItemKind::Updated,
|
||||
"demo.rs must be re-indexed: {code:?}"
|
||||
);
|
||||
for i in items.iter().filter(|i| i.doc_path.0.ends_with(".md")) {
|
||||
assert_eq!(
|
||||
i.kind,
|
||||
IngestItemKind::Unchanged,
|
||||
"markdown must be Unchanged: {i:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard: changing a non-ingest setting (`search.default_k`) does
|
||||
/// NOT re-index anything.
|
||||
#[test]
|
||||
fn search_setting_change_reindexes_nothing() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
env.config.search.default_k += 5;
|
||||
env.config.search.snippet_chars += 50;
|
||||
env.config.rag.score_gate = 0.5;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(
|
||||
second.unchanged, scanned,
|
||||
"search/rag changes must not re-index: {second:?}"
|
||||
);
|
||||
assert_eq!(second.updated, 0, "nothing re-indexed: {second:?}");
|
||||
assert_eq!(second.new, 0);
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
|
||||
/// v3 불변식 #1: `ingest_config_signature` 출력 문자열은 값 기반이라 struct
|
||||
/// 경로 재편(미디어 ingest 통합) 후에도 v2 와 **바이트 동일**해야 한다. 깨지면
|
||||
/// 업그레이드 시 전체 재색인 발생. paddle-onnx image 분기 형식 골든.
|
||||
#[test]
|
||||
fn ingest_signature_image_paddle_byte_stable() {
|
||||
let mut cfg = kebab_config::Config::defaults();
|
||||
cfg.ingest.image.ocr.enabled = true;
|
||||
cfg.ingest.image.ocr.engine = "paddle-onnx".into();
|
||||
let sig = kebab_app::test_ingest_config_signature(
|
||||
&cfg,
|
||||
&kebab_core::MediaType::Image(kebab_core::ImageType::Png),
|
||||
);
|
||||
// 골든: chunk:... |ocr:1:paddle-onnx:<engine_version> |cap:0
|
||||
assert!(
|
||||
sig.starts_with("chunk:500:80:true:md-heading-v1"),
|
||||
"chunk prefix drift: {sig}"
|
||||
);
|
||||
assert!(sig.contains("|ocr:1:paddle-onnx:"), "ocr token drift: {sig}");
|
||||
assert!(sig.ends_with("|cap:0"), "cap token drift: {sig}");
|
||||
}
|
||||
@@ -34,11 +34,11 @@ fn cfg_with_image_pipeline(env: &TestEnv, mock_endpoint: &str) -> Config {
|
||||
let mut cfg = env.config.clone();
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
cfg.image.ocr.enabled = true;
|
||||
cfg.image.ocr.endpoint = Some(mock_endpoint.to_string());
|
||||
cfg.image.ocr.model = "vision-mock:1b".to_string();
|
||||
cfg.image.ocr.max_pixels = 512;
|
||||
cfg.image.caption.enabled = false; // tested separately below
|
||||
cfg.ingest.image.ocr.enabled = true;
|
||||
cfg.ingest.image.ocr.endpoint = Some(mock_endpoint.to_string());
|
||||
cfg.ingest.image.ocr.model = "vision-mock:1b".to_string();
|
||||
cfg.ingest.image.ocr.max_pixels = 512;
|
||||
cfg.ingest.image.caption.enabled = false; // tested separately below
|
||||
cfg.models.llm.endpoint = mock_endpoint.to_string();
|
||||
cfg.models.llm.model = "vision-mock:1b".to_string();
|
||||
cfg
|
||||
@@ -161,8 +161,8 @@ async fn ingest_image_with_ocr_and_caption_populates_both_fields() {
|
||||
let env = TestEnv::lexical_only();
|
||||
write_red_png(&env.workspace_root, "diagram.png");
|
||||
let mut cfg = cfg_with_image_pipeline(&env, &server.uri());
|
||||
cfg.image.caption.enabled = true;
|
||||
cfg.image.caption.max_pixels = 384;
|
||||
cfg.ingest.image.caption.enabled = true;
|
||||
cfg.ingest.image.caption.max_pixels = 384;
|
||||
|
||||
let cfg_clone = cfg.clone();
|
||||
let scope = env.scope();
|
||||
@@ -270,8 +270,8 @@ async fn image_indexed_with_filename_when_ocr_and_caption_disabled() {
|
||||
let mut cfg = env.config.clone();
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
cfg.image.ocr.enabled = false;
|
||||
cfg.image.caption.enabled = false;
|
||||
cfg.ingest.image.ocr.enabled = false;
|
||||
cfg.ingest.image.caption.enabled = false;
|
||||
|
||||
let cfg_clone = cfg.clone();
|
||||
let scope = env.scope();
|
||||
@@ -334,8 +334,8 @@ async fn garbage_png_increments_errors_counter_exactly_once() {
|
||||
let mut cfg = env.config.clone();
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
cfg.image.ocr.enabled = false;
|
||||
cfg.image.caption.enabled = false;
|
||||
cfg.ingest.image.ocr.enabled = false;
|
||||
cfg.ingest.image.caption.enabled = false;
|
||||
|
||||
let cfg_clone = cfg.clone();
|
||||
let scope = env.scope();
|
||||
|
||||
@@ -23,8 +23,8 @@ fn minimal_config(workspace: &std::path::Path, log_dir: &std::path::Path) -> Con
|
||||
cfg.storage.model_dir = model_dir.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg.chunking.target_tokens = 80;
|
||||
cfg.chunking.overlap_tokens = 20;
|
||||
cfg.ingest.chunking.target_tokens = 80;
|
||||
cfg.ingest.chunking.overlap_tokens = 20;
|
||||
cfg.logging = LoggingCfg {
|
||||
ingest_log_enabled: true,
|
||||
ingest_log_dir: log_dir.to_path_buf(),
|
||||
|
||||
@@ -22,8 +22,8 @@ fn ollama_endpoint() -> String {
|
||||
|
||||
fn make_ocr_env_real() -> TestEnv {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
env.config.pdf.ocr.enabled = true;
|
||||
env.config.pdf.ocr.endpoint = Some(ollama_endpoint());
|
||||
env.config.ingest.pdf.ocr.enabled = true;
|
||||
env.config.ingest.pdf.ocr.endpoint = Some(ollama_endpoint());
|
||||
env.config.models.embedding.provider = "none".to_string();
|
||||
|
||||
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -92,8 +92,8 @@ fn ocr_text_indexed_and_searchable() {
|
||||
#[test]
|
||||
fn ingest_with_cancel_aborts_mid_pdf() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
env.config.pdf.ocr.enabled = true;
|
||||
env.config.pdf.ocr.endpoint = Some("http://127.0.0.1:1".to_string());
|
||||
env.config.ingest.pdf.ocr.enabled = true;
|
||||
env.config.ingest.pdf.ocr.endpoint = Some("http://127.0.0.1:1".to_string());
|
||||
|
||||
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
|
||||
@@ -196,9 +196,9 @@ fn pdf_ocr_progress_emits_started_finished_events() {
|
||||
config.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
||||
config.models.embedding.provider = "none".to_string();
|
||||
config.models.embedding.dimensions = 0;
|
||||
config.pdf.ocr.enabled = true;
|
||||
config.ingest.pdf.ocr.enabled = true;
|
||||
if let Ok(endpoint) = std::env::var("KEBAB_PDF_OCR_ENDPOINT") {
|
||||
config.pdf.ocr.endpoint = Some(endpoint);
|
||||
config.ingest.pdf.ocr.endpoint = Some(endpoint);
|
||||
}
|
||||
|
||||
let scope = kebab_core::SourceScope {
|
||||
|
||||
@@ -49,9 +49,9 @@ async fn ingest_dual_write_doc_id_matches_ndjson() {
|
||||
let result = spawn_blocking(move || {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
// Enable PDF OCR + set up mock endpoint
|
||||
env.config.pdf.ocr.enabled = true;
|
||||
env.config.pdf.ocr.endpoint = Some(mock_url.clone());
|
||||
env.config.pdf.ocr.model = "qwen2.5vl:3b".to_string();
|
||||
env.config.ingest.pdf.ocr.enabled = true;
|
||||
env.config.ingest.pdf.ocr.endpoint = Some(mock_url.clone());
|
||||
env.config.ingest.pdf.ocr.model = "qwen2.5vl:3b".to_string();
|
||||
// Enable ingest log
|
||||
let log_dir = env.temp.path().join("logs");
|
||||
std::fs::create_dir_all(&log_dir).unwrap();
|
||||
|
||||
@@ -121,8 +121,8 @@ fn cfg_with_pdf(env: &TestEnv) -> Config {
|
||||
// PDF ingest does not need OCR / caption / LM — leave defaults
|
||||
// (ocr.enabled=false, caption.enabled=false). The image pipeline
|
||||
// construction step skips both adapters.
|
||||
cfg.image.ocr.enabled = false;
|
||||
cfg.image.caption.enabled = false;
|
||||
cfg.ingest.image.ocr.enabled = false;
|
||||
cfg.ingest.image.caption.enabled = false;
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -162,7 +162,9 @@ fn ingest_3_page_pdf_produces_one_doc_and_per_page_chunks() {
|
||||
"one chunk per non-empty page"
|
||||
);
|
||||
assert_eq!(
|
||||
pdf_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
pdf_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("pdf-text-v1")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -477,7 +479,10 @@ fn inspect_doc_surfaces_page_spans() {
|
||||
.find(|i| i.doc_path.0.ends_with("inspect.pdf"))
|
||||
.unwrap();
|
||||
let doc = kebab_app::inspect_doc_with_config(cfg, pdf_item.doc_id.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(doc.parser_version.0, "pdf-text-v1");
|
||||
// v0.26.2: stored parser_version is now `pdf-text-v1|<ingest-config-sig>`
|
||||
// (the signature folds chunking / pdf.ocr settings for skip detection).
|
||||
// Assert the base identity by taking the prefix before the first '|'.
|
||||
assert_eq!(doc.parser_version.0.split('|').next().unwrap(), "pdf-text-v1");
|
||||
assert_eq!(doc.blocks.len(), 3);
|
||||
for block in &doc.blocks {
|
||||
match block {
|
||||
|
||||
@@ -12,8 +12,8 @@ fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path)
|
||||
cfg.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg.chunking.target_tokens = 80;
|
||||
cfg.chunking.overlap_tokens = 20;
|
||||
cfg.ingest.chunking.target_tokens = 80;
|
||||
cfg.ingest.chunking.overlap_tokens = 20;
|
||||
cfg
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path)
|
||||
config.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned();
|
||||
config.models.embedding.provider = "none".to_string();
|
||||
config.models.embedding.dimensions = 0;
|
||||
config.chunking.target_tokens = 80;
|
||||
config.chunking.overlap_tokens = 20;
|
||||
config.ingest.chunking.target_tokens = 80;
|
||||
config.ingest.chunking.overlap_tokens = 20;
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,18 @@ mod paths;
|
||||
pub mod migrate;
|
||||
pub use paths::{expand_path, expand_path_with_base};
|
||||
|
||||
/// f32 의 shortest round-trip(Display)을 f64 로 재파싱해 직렬화한다.
|
||||
/// `0.3_f32` 가 `0.30000001192092896` 으로 새지 않고 `0.3` 으로 출력되게 한다.
|
||||
/// 마이그레이션 시 toml_edit relocation 의 무손실 비교를 깨지 않도록, 그리고
|
||||
/// `kebab config migrate` 산출물이 사람이 읽기 좋게.
|
||||
fn ser_f32_clean<S>(v: &f32, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let clean: f64 = format!("{v}").parse().unwrap_or(f64::from(*v));
|
||||
s.serialize_f64(clean)
|
||||
}
|
||||
|
||||
/// Signal: `Config::from_file` / `Config::load` failed due to missing path,
|
||||
/// I/O failure, TOML parse failure, or post-parse validation failure.
|
||||
///
|
||||
@@ -39,32 +51,20 @@ pub struct Config {
|
||||
pub schema_version: u32,
|
||||
pub workspace: WorkspaceCfg,
|
||||
pub storage: StorageCfg,
|
||||
pub indexing: IndexingCfg,
|
||||
pub chunking: ChunkingCfg,
|
||||
pub models: ModelsCfg,
|
||||
/// v3: 모든 미디어 형식 ingest 설정의 우산 — 병렬도(← 옛 `[indexing]`),
|
||||
/// chunking, code, image, pdf 가 전부 `[ingest.*]` 하위로 통합됐다.
|
||||
/// `#[serde(default)]` 로 두어 미변환 / 부분 config 도 로드된다(자동
|
||||
/// 변환은 `Config::from_file` 가 메모리에서 수행 — T6).
|
||||
#[serde(default)]
|
||||
pub ingest: IngestCfg,
|
||||
pub search: SearchCfg,
|
||||
pub rag: RagCfg,
|
||||
/// Image-pipeline settings (P6: OCR, captioning). Tagged
|
||||
/// `#[serde(default)]` so pre-P6 config files that predate the
|
||||
/// `[image]` section still load — defaults disable OCR / caption
|
||||
/// (they cost a model call per asset).
|
||||
#[serde(default = "ImageCfg::defaults")]
|
||||
pub image: ImageCfg,
|
||||
/// p9-fb-14: TUI palette + role-style mapping. `#[serde(default)]`
|
||||
/// so configs that predate this section still load (defaults to
|
||||
/// `dark`).
|
||||
#[serde(default = "UiCfg::defaults")]
|
||||
pub ui: UiCfg,
|
||||
/// p10-1A-1: code ingest settings. `#[serde(default)]` so existing
|
||||
/// config files without an `[ingest]` / `[ingest.code]` section
|
||||
/// load cleanly with built-in defaults.
|
||||
#[serde(default)]
|
||||
pub ingest: IngestCfg,
|
||||
/// v0.20.0 sub-item 1: PDF ingest pipeline settings. `#[serde(default)]`
|
||||
/// so pre-v0.20 config files without a `[pdf]` section load with
|
||||
/// built-in defaults (OCR disabled — opt-in for scanned PDF KB).
|
||||
#[serde(default = "PdfCfg::defaults")]
|
||||
pub pdf: PdfCfg,
|
||||
/// v0.20.x ingest log surface. `#[serde(default)]` so pre-v0.20
|
||||
/// config files without a `[logging]` section load with built-in
|
||||
/// defaults (enabled=true, dir=~/.local/state/kebab/logs).
|
||||
@@ -104,13 +104,6 @@ pub struct StorageCfg {
|
||||
pub copy_threshold_mb: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IndexingCfg {
|
||||
pub max_parallel_extractors: u32,
|
||||
pub max_parallel_embeddings: u32,
|
||||
pub watch_filesystem: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChunkingCfg {
|
||||
pub target_tokens: usize,
|
||||
@@ -119,6 +112,17 @@ pub struct ChunkingCfg {
|
||||
pub chunker_version: String,
|
||||
}
|
||||
|
||||
impl ChunkingCfg {
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
target_tokens: 500,
|
||||
overlap_tokens: 80,
|
||||
respect_markdown_headings: true,
|
||||
chunker_version: "md-heading-v1".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelsCfg {
|
||||
pub embedding: EmbeddingModelCfg,
|
||||
@@ -186,6 +190,7 @@ pub struct LlmCfg {
|
||||
pub model: String,
|
||||
pub context_tokens: usize,
|
||||
pub endpoint: String,
|
||||
#[serde(serialize_with = "ser_f32_clean")]
|
||||
pub temperature: f32,
|
||||
pub seed: u64,
|
||||
/// v0.17.0 post-dogfood: Hard ceiling on a single HTTP exchange to
|
||||
@@ -244,6 +249,7 @@ fn default_stale_threshold_days() -> u32 {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RagCfg {
|
||||
pub prompt_template_version: String,
|
||||
#[serde(serialize_with = "ser_f32_clean")]
|
||||
pub score_gate: f32,
|
||||
pub explain_default: bool,
|
||||
pub max_context_tokens: usize,
|
||||
@@ -293,7 +299,7 @@ pub struct RagCfg {
|
||||
///
|
||||
/// Single-pass `ask` ignores this knob entirely — only multi-hop
|
||||
/// runs through the verification step (PR-9c-2 wires it).
|
||||
#[serde(default = "default_nli_threshold")]
|
||||
#[serde(default = "default_nli_threshold", serialize_with = "ser_f32_clean")]
|
||||
pub nli_threshold: f32,
|
||||
}
|
||||
|
||||
@@ -377,6 +383,36 @@ pub struct OcrCfg {
|
||||
/// `86400`).
|
||||
#[serde(default = "default_ocr_request_timeout_secs")]
|
||||
pub request_timeout_secs: u64,
|
||||
|
||||
// ── paddle-onnx engine overrides (v0.27.0) ──────────────────────────
|
||||
// Only consulted when `engine == "paddle-onnx"`; the ollama-vision
|
||||
// engine ignores them. All `#[serde(default)]` so pre-v0.27 config
|
||||
// files load unchanged.
|
||||
/// Override path to the detection ONNX model. `None` → bundled
|
||||
/// `assets/paddleocr-onnx/ppocrv5_mobile_det.onnx` (or the directory
|
||||
/// named by `KEBAB_IMAGE_OCR_MODEL_DIR`).
|
||||
#[serde(default)]
|
||||
pub det_model: Option<String>,
|
||||
/// Override path to the recognition ONNX model. `None` → bundled
|
||||
/// `assets/paddleocr-onnx/korean_ppocrv5_mobile_rec.onnx`.
|
||||
#[serde(default)]
|
||||
pub rec_model: Option<String>,
|
||||
/// Override path to the character dictionary. `None` → bundled
|
||||
/// `assets/paddleocr-onnx/korean_dict.txt`.
|
||||
#[serde(default)]
|
||||
pub dict: Option<String>,
|
||||
/// DBNet detection box score threshold (0.0..=1.0). Boxes whose mean
|
||||
/// probability is below this are dropped. Default `0.3`.
|
||||
#[serde(default = "default_ocr_score_thresh", serialize_with = "ser_f32_clean")]
|
||||
pub score_thresh: f32,
|
||||
/// Polygon unclip ratio applied to each detected box before crop.
|
||||
/// Larger = more padding around the text. Default `1.5`.
|
||||
#[serde(default = "default_ocr_unclip_ratio", serialize_with = "ser_f32_clean")]
|
||||
pub unclip_ratio: f32,
|
||||
/// Hard cap on detected boxes per image (runaway guard). Extra boxes
|
||||
/// past this count are truncated with a warning. Default `1000`.
|
||||
#[serde(default = "default_ocr_max_boxes")]
|
||||
pub max_boxes: usize,
|
||||
}
|
||||
|
||||
impl OcrCfg {
|
||||
@@ -389,10 +425,29 @@ impl OcrCfg {
|
||||
languages: vec!["eng".to_string(), "kor".to_string()],
|
||||
max_pixels: 1600,
|
||||
request_timeout_secs: default_ocr_request_timeout_secs(),
|
||||
det_model: None,
|
||||
rec_model: None,
|
||||
dict: None,
|
||||
score_thresh: default_ocr_score_thresh(),
|
||||
unclip_ratio: default_ocr_unclip_ratio(),
|
||||
max_boxes: default_ocr_max_boxes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// paddle-onnx DBNet box score threshold default. See [`OcrCfg::score_thresh`].
|
||||
fn default_ocr_score_thresh() -> f32 {
|
||||
0.3
|
||||
}
|
||||
/// paddle-onnx unclip ratio default. See [`OcrCfg::unclip_ratio`].
|
||||
fn default_ocr_unclip_ratio() -> f32 {
|
||||
1.5
|
||||
}
|
||||
/// paddle-onnx box-count cap default. See [`OcrCfg::max_boxes`].
|
||||
fn default_ocr_max_boxes() -> usize {
|
||||
1000
|
||||
}
|
||||
|
||||
/// v0.17.2 post-dogfood: matches the legacy hard-coded ceiling so
|
||||
/// existing configs that omit the field keep behaving identically.
|
||||
/// Overridable per config / `KEBAB_IMAGE_OCR_REQUEST_TIMEOUT_SECS`.
|
||||
@@ -512,7 +567,9 @@ pub struct PdfOcrCfg {
|
||||
/// scanned pages only. `true` — vision LLM 호출 on every page
|
||||
/// (vector PDF 의 dual-text confidence boost — doubles chunk count).
|
||||
pub always_on: bool,
|
||||
/// Engine identifier. v1 only ships `"ollama-vision"`.
|
||||
/// Engine identifier: `"ollama-vision"` or `"paddle-onnx"`. When set to
|
||||
/// `"paddle-onnx"`, model paths and tuning knobs are read from
|
||||
/// `[image.ocr]`, not `[pdf.ocr]` — PaddleOCR has no PDF-specific tuning.
|
||||
pub engine: String,
|
||||
/// Vision model id. Default `"qwen2.5vl:3b"` per PoC (§3.5 family
|
||||
/// asymmetry vs image OCR's gemma4:e4b is acknowledged).
|
||||
@@ -532,7 +589,7 @@ pub struct PdfOcrCfg {
|
||||
/// Valid char ratio threshold (0.0..=1.0). Page with ratio below
|
||||
/// this is classified as scanned/mojibake → OCR fallback. Default
|
||||
/// `0.5`.
|
||||
#[serde(default = "default_pdf_ocr_valid_ratio")]
|
||||
#[serde(default = "default_pdf_ocr_valid_ratio", serialize_with = "ser_f32_clean")]
|
||||
pub valid_ratio_threshold: f32,
|
||||
/// Minimum char count per page below which page is auto-scanned.
|
||||
/// Default `20`.
|
||||
@@ -541,6 +598,30 @@ pub struct PdfOcrCfg {
|
||||
/// Single-page lang hint. Default `Some("kor")`. `None` = no hint.
|
||||
#[serde(default = "default_pdf_ocr_lang_hint")]
|
||||
pub lang_hint: Option<String>,
|
||||
|
||||
// ── paddle-onnx engine overrides (v3) ───────────────────────────────
|
||||
// Symmetric with `[ingest.image.ocr]`. v2 의 "pdf paddle 이 image 의
|
||||
// 모델 경로를 빌려쓰던" 비대칭을 제거 — pdf 자체 키로 옮긴다. 마이그레이션
|
||||
// (T5)이 image 값을 이 키로 복사해 signature 바이트 동일 유지. 전부
|
||||
// `#[serde(default)]` 이라 pre-v3 config 도 로드.
|
||||
/// Override path to the detection ONNX model. `None` → bundled.
|
||||
#[serde(default)]
|
||||
pub det_model: Option<String>,
|
||||
/// Override path to the recognition ONNX model. `None` → bundled.
|
||||
#[serde(default)]
|
||||
pub rec_model: Option<String>,
|
||||
/// Override path to the character dictionary. `None` → bundled.
|
||||
#[serde(default)]
|
||||
pub dict: Option<String>,
|
||||
/// DBNet detection box score threshold (0.0..=1.0). Default `0.3`.
|
||||
#[serde(default = "default_ocr_score_thresh", serialize_with = "ser_f32_clean")]
|
||||
pub score_thresh: f32,
|
||||
/// Polygon unclip ratio applied to each detected box. Default `1.5`.
|
||||
#[serde(default = "default_ocr_unclip_ratio", serialize_with = "ser_f32_clean")]
|
||||
pub unclip_ratio: f32,
|
||||
/// Hard cap on detected boxes per page (runaway guard). Default `1000`.
|
||||
#[serde(default = "default_ocr_max_boxes")]
|
||||
pub max_boxes: usize,
|
||||
}
|
||||
|
||||
impl PdfOcrCfg {
|
||||
@@ -557,6 +638,12 @@ impl PdfOcrCfg {
|
||||
valid_ratio_threshold: default_pdf_ocr_valid_ratio(),
|
||||
min_char_count: default_pdf_ocr_min_char_count(),
|
||||
lang_hint: default_pdf_ocr_lang_hint(),
|
||||
det_model: None,
|
||||
rec_model: None,
|
||||
dict: None,
|
||||
score_thresh: default_ocr_score_thresh(),
|
||||
unclip_ratio: default_ocr_unclip_ratio(),
|
||||
max_boxes: default_ocr_max_boxes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,12 +695,47 @@ impl UiCfg {
|
||||
}
|
||||
}
|
||||
|
||||
/// p10-1A-1: top-level ingest configuration wrapper. Contains per-media-type
|
||||
/// sub-sections; currently only `code` is defined.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
/// v3: 모든 미디어 형식 ingest 설정의 우산. 스칼라(병렬도)는 ← 옛 `[indexing]`,
|
||||
/// 미디어별 하위 테이블(chunking/code/image/pdf)은 ← 옛 top-level 섹션.
|
||||
/// 직렬화 순서 = 필드 순서: 스칼라(병렬도) 먼저, 하위 테이블 뒤
|
||||
/// (TOML 의 "bare key 는 sub-table header 앞" 규칙 준수).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IngestCfg {
|
||||
#[serde(default = "default_max_parallel_extractors")]
|
||||
pub max_parallel_extractors: u32,
|
||||
#[serde(default = "default_max_parallel_embeddings")]
|
||||
pub max_parallel_embeddings: u32,
|
||||
#[serde(default)]
|
||||
pub watch_filesystem: bool,
|
||||
#[serde(default = "ChunkingCfg::defaults")]
|
||||
pub chunking: ChunkingCfg,
|
||||
#[serde(default)]
|
||||
pub code: IngestCodeCfg,
|
||||
#[serde(default = "ImageCfg::defaults")]
|
||||
pub image: ImageCfg,
|
||||
#[serde(default = "PdfCfg::defaults")]
|
||||
pub pdf: PdfCfg,
|
||||
}
|
||||
|
||||
impl Default for IngestCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_parallel_extractors: default_max_parallel_extractors(),
|
||||
max_parallel_embeddings: default_max_parallel_embeddings(),
|
||||
watch_filesystem: false,
|
||||
chunking: ChunkingCfg::defaults(),
|
||||
code: IngestCodeCfg::default(),
|
||||
image: ImageCfg::defaults(),
|
||||
pdf: PdfCfg::defaults(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_parallel_extractors() -> u32 {
|
||||
2
|
||||
}
|
||||
fn default_max_parallel_embeddings() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// p10-1A-1: settings for the code ingest pipeline. All fields have
|
||||
@@ -677,17 +799,6 @@ impl Config {
|
||||
runs_dir: "{data_dir}/runs".to_string(),
|
||||
copy_threshold_mb: 100,
|
||||
},
|
||||
indexing: IndexingCfg {
|
||||
max_parallel_extractors: 2,
|
||||
max_parallel_embeddings: 1,
|
||||
watch_filesystem: false,
|
||||
},
|
||||
chunking: ChunkingCfg {
|
||||
target_tokens: 500,
|
||||
overlap_tokens: 80,
|
||||
respect_markdown_headings: true,
|
||||
chunker_version: "md-heading-v1".to_string(),
|
||||
},
|
||||
models: ModelsCfg {
|
||||
embedding: EmbeddingModelCfg {
|
||||
provider: "fastembed".to_string(),
|
||||
@@ -714,6 +825,15 @@ impl Config {
|
||||
},
|
||||
nli: NliCfg::defaults(),
|
||||
},
|
||||
ingest: IngestCfg {
|
||||
max_parallel_extractors: 2,
|
||||
max_parallel_embeddings: 1,
|
||||
watch_filesystem: false,
|
||||
chunking: ChunkingCfg::defaults(),
|
||||
code: IngestCodeCfg::default(),
|
||||
image: ImageCfg::defaults(),
|
||||
pdf: PdfCfg::defaults(),
|
||||
},
|
||||
search: SearchCfg {
|
||||
default_k: 10,
|
||||
hybrid_fusion: "rrf".to_string(),
|
||||
@@ -732,10 +852,7 @@ impl Config {
|
||||
multi_hop_max_pool_chunks: default_multi_hop_max_pool_chunks(),
|
||||
nli_threshold: default_nli_threshold(),
|
||||
},
|
||||
image: ImageCfg::defaults(),
|
||||
ui: UiCfg::defaults(),
|
||||
ingest: IngestCfg::default(),
|
||||
pdf: PdfCfg::defaults(),
|
||||
logging: LoggingCfg::default(),
|
||||
// p9-fb-05: defaults are not loaded from disk, so no
|
||||
// source_dir. Relative `workspace.root` (rare with
|
||||
@@ -847,29 +964,56 @@ impl Config {
|
||||
})
|
||||
})?;
|
||||
|
||||
// p9-fb-25: probe for the legacy `workspace.include` key — if
|
||||
// present, emit a one-shot deprecation warning. Detection uses
|
||||
// raw `toml::Value` lookup; the warning fires via a process-
|
||||
// level OnceLock so a long-running TUI / CLI run doesn't spam
|
||||
// the log on every Config::load.
|
||||
if let Ok(value) = toml::from_str::<toml::Value>(&text) {
|
||||
if value
|
||||
.get("workspace")
|
||||
.and_then(|v| v.get("include"))
|
||||
.is_some()
|
||||
{
|
||||
static DEPRECATION_FIRED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
DEPRECATION_FIRED.get_or_init(|| {
|
||||
// raw `toml::Value` 를 한 번만 파싱해 (1) legacy `workspace.include`
|
||||
// deprecation probe (p9-fb-25) 와 (2) `schema_version` 감지(v3 자동변환)
|
||||
// 에 함께 쓴다 — config load 는 매 CLI 호출마다 일어나므로 파싱 1회로.
|
||||
let probe = toml::from_str::<toml::Value>(&text).ok();
|
||||
|
||||
// p9-fb-25: legacy `workspace.include` 키가 있으면 일회성 deprecation
|
||||
// 경고(process-level OnceLock 로 장기 실행 시 로그 도배 방지).
|
||||
if probe
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("workspace"))
|
||||
.and_then(|v| v.get("include"))
|
||||
.is_some()
|
||||
{
|
||||
static DEPRECATION_FIRED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
DEPRECATION_FIRED.get_or_init(|| {
|
||||
tracing::warn!(
|
||||
target: "kebab-config",
|
||||
config = %path.display(),
|
||||
"deprecated config: `workspace.include` 필드는 더 이상 사용되지 않습니다 (p9-fb-25, v0.2.1+). 처리 가능한 형식 (md / png / jpg / pdf) 은 extractor 가 자동 결정. config 에서 이 필드를 제거해도 안전 — 더 이상 enforce 안 됨."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// v3: 파일의 schema_version 이 CURRENT 보다 낮으면 메모리에서 변환한다
|
||||
// (디스크 미변경 — 파일 갱신은 `kebab config migrate`). 미변환 v2 파일도
|
||||
// 설정 유실 없이 로드(불변식 #3). non-additive relocation(v2→v3) 은
|
||||
// serde default forward-compat 로는 커버 안 되므로 반드시 거쳐야 한다.
|
||||
let parse_text = {
|
||||
let from = probe
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("schema_version").and_then(toml::Value::as_integer))
|
||||
.unwrap_or(1) as u32;
|
||||
if from < crate::migrate::CURRENT_SCHEMA_VERSION {
|
||||
static MIGRATE_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
MIGRATE_WARNED.get_or_init(|| {
|
||||
tracing::warn!(
|
||||
target: "kebab-config",
|
||||
config = %path.display(),
|
||||
"deprecated config: `workspace.include` 필드는 더 이상 사용되지 않습니다 (p9-fb-25, v0.2.1+). 처리 가능한 형식 (md / png / jpg / pdf) 은 extractor 가 자동 결정. config 에서 이 필드를 제거해도 안전 — 더 이상 enforce 안 됨."
|
||||
from,
|
||||
to = crate::migrate::CURRENT_SCHEMA_VERSION,
|
||||
"config 가 옛 스키마입니다 — 이번 실행은 메모리에서 변환됨. 파일 갱신: `kebab config migrate`."
|
||||
);
|
||||
});
|
||||
crate::migrate::migrate_document(&text).new_text
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut cfg: Self = toml::from_str(&text).map_err(|e| {
|
||||
let mut cfg: Self = toml::from_str(&parse_text).map_err(|e| {
|
||||
anyhow::Error::new(ConfigInvalid {
|
||||
path: path.to_path_buf(),
|
||||
cause: format!("parse_failed: {e}"),
|
||||
@@ -912,33 +1056,33 @@ impl Config {
|
||||
// indexing
|
||||
"KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.indexing.max_parallel_extractors = n;
|
||||
self.ingest.max_parallel_extractors = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_INDEXING_MAX_PARALLEL_EMBEDDINGS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.indexing.max_parallel_embeddings = n;
|
||||
self.ingest.max_parallel_embeddings = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_INDEXING_WATCH_FILESYSTEM" => {
|
||||
self.indexing.watch_filesystem = parse_bool(v);
|
||||
self.ingest.watch_filesystem = parse_bool(v);
|
||||
}
|
||||
|
||||
// chunking
|
||||
"KEBAB_CHUNKING_TARGET_TOKENS" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.chunking.target_tokens = n;
|
||||
self.ingest.chunking.target_tokens = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_CHUNKING_OVERLAP_TOKENS" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.chunking.overlap_tokens = n;
|
||||
self.ingest.chunking.overlap_tokens = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_CHUNKING_RESPECT_MARKDOWN_HEADINGS" => {
|
||||
self.chunking.respect_markdown_headings = parse_bool(v);
|
||||
self.ingest.chunking.respect_markdown_headings = parse_bool(v);
|
||||
}
|
||||
"KEBAB_CHUNKING_CHUNKER_VERSION" => self.chunking.chunker_version = v.clone(),
|
||||
"KEBAB_CHUNKING_CHUNKER_VERSION" => self.ingest.chunking.chunker_version = v.clone(),
|
||||
|
||||
// models.embedding
|
||||
"KEBAB_MODELS_EMBEDDING_PROVIDER" => self.models.embedding.provider = v.clone(),
|
||||
@@ -1071,18 +1215,18 @@ impl Config {
|
||||
|
||||
// image.ocr
|
||||
"KEBAB_IMAGE_OCR_ENABLED" => {
|
||||
self.image.ocr.enabled = parse_bool(v);
|
||||
self.ingest.image.ocr.enabled = parse_bool(v);
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_ENGINE" => self.image.ocr.engine = v.clone(),
|
||||
"KEBAB_IMAGE_OCR_MODEL" => self.image.ocr.model = v.clone(),
|
||||
"KEBAB_IMAGE_OCR_ENGINE" => self.ingest.image.ocr.engine = v.clone(),
|
||||
"KEBAB_IMAGE_OCR_MODEL" => self.ingest.image.ocr.model = v.clone(),
|
||||
"KEBAB_IMAGE_OCR_ENDPOINT" => {
|
||||
// Empty env value is treated the same as "fall back
|
||||
// to models.llm.endpoint" — i.e. set None.
|
||||
self.image.ocr.endpoint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.image.ocr.endpoint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_LANGUAGES" => {
|
||||
// Comma-separated list, e.g. "eng,kor".
|
||||
self.image.ocr.languages = v
|
||||
self.ingest.image.ocr.languages = v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -1090,38 +1234,66 @@ impl Config {
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_MAX_PIXELS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.image.ocr.max_pixels = n;
|
||||
self.ingest.image.ocr.max_pixels = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_REQUEST_TIMEOUT_SECS" => {
|
||||
if let Ok(n) = v.parse::<u64>() {
|
||||
self.image.ocr.request_timeout_secs = n;
|
||||
self.ingest.image.ocr.request_timeout_secs = n;
|
||||
}
|
||||
}
|
||||
// paddle-onnx engine overrides (v0.27.0). Empty string → None
|
||||
// (fall back to bundled / KEBAB_IMAGE_OCR_MODEL_DIR).
|
||||
"KEBAB_IMAGE_OCR_DET_MODEL" => {
|
||||
self.ingest.image.ocr.det_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_REC_MODEL" => {
|
||||
self.ingest.image.ocr.rec_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_DICT" => {
|
||||
self.ingest.image.ocr.dict = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_SCORE_THRESH" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.image.ocr.score_thresh = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_UNCLIP_RATIO" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.image.ocr.unclip_ratio = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_MAX_BOXES" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.ingest.image.ocr.max_boxes = n;
|
||||
}
|
||||
}
|
||||
|
||||
// image.caption (P6-3)
|
||||
"KEBAB_IMAGE_CAPTION_ENABLED" => {
|
||||
self.image.caption.enabled = parse_bool(v);
|
||||
self.ingest.image.caption.enabled = parse_bool(v);
|
||||
}
|
||||
"KEBAB_IMAGE_CAPTION_MAX_PIXELS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.image.caption.max_pixels = n;
|
||||
self.ingest.image.caption.max_pixels = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_CAPTION_PROMPT_TEMPLATE_VERSION" => {
|
||||
self.image.caption.prompt_template_version = v.clone();
|
||||
self.ingest.image.caption.prompt_template_version = v.clone();
|
||||
}
|
||||
|
||||
// pdf.ocr (v0.20.0 sub-item 1)
|
||||
"KEBAB_PDF_OCR_ENABLED" => self.pdf.ocr.enabled = parse_bool(v),
|
||||
"KEBAB_PDF_OCR_ALWAYS_ON" => self.pdf.ocr.always_on = parse_bool(v),
|
||||
"KEBAB_PDF_OCR_ENGINE" => self.pdf.ocr.engine = v.clone(),
|
||||
"KEBAB_PDF_OCR_MODEL" => self.pdf.ocr.model = v.clone(),
|
||||
"KEBAB_PDF_OCR_ENABLED" => self.ingest.pdf.ocr.enabled = parse_bool(v),
|
||||
"KEBAB_PDF_OCR_ALWAYS_ON" => self.ingest.pdf.ocr.always_on = parse_bool(v),
|
||||
"KEBAB_PDF_OCR_ENGINE" => self.ingest.pdf.ocr.engine = v.clone(),
|
||||
"KEBAB_PDF_OCR_MODEL" => self.ingest.pdf.ocr.model = v.clone(),
|
||||
"KEBAB_PDF_OCR_ENDPOINT" => {
|
||||
self.pdf.ocr.endpoint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.pdf.ocr.endpoint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_LANGUAGES" => {
|
||||
self.pdf.ocr.languages = v
|
||||
self.ingest.pdf.ocr.languages = v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -1129,26 +1301,54 @@ impl Config {
|
||||
}
|
||||
"KEBAB_PDF_OCR_MAX_PIXELS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.pdf.ocr.max_pixels = n;
|
||||
self.ingest.pdf.ocr.max_pixels = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_REQUEST_TIMEOUT_SECS" => {
|
||||
if let Ok(n) = v.parse::<u64>() {
|
||||
self.pdf.ocr.request_timeout_secs = n;
|
||||
self.ingest.pdf.ocr.request_timeout_secs = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_VALID_RATIO_THRESHOLD" => {
|
||||
if let Ok(n) = v.parse::<f32>() {
|
||||
self.pdf.ocr.valid_ratio_threshold = n.clamp(0.0, 1.0);
|
||||
self.ingest.pdf.ocr.valid_ratio_threshold = n.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_MIN_CHAR_COUNT" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.pdf.ocr.min_char_count = n;
|
||||
self.ingest.pdf.ocr.min_char_count = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_LANG_HINT" => {
|
||||
self.pdf.ocr.lang_hint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.pdf.ocr.lang_hint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
// pdf paddle-onnx engine overrides (v3). image.ocr paddle 패턴 복제.
|
||||
// Empty string → None (fall back to bundled / KEBAB_IMAGE_OCR_MODEL_DIR).
|
||||
"KEBAB_PDF_OCR_DET_MODEL" => {
|
||||
self.ingest.pdf.ocr.det_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_REC_MODEL" => {
|
||||
self.ingest.pdf.ocr.rec_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_DICT" => {
|
||||
self.ingest.pdf.ocr.dict = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_SCORE_THRESH" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.pdf.ocr.score_thresh = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_UNCLIP_RATIO" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.pdf.ocr.unclip_ratio = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_MAX_BOXES" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.ingest.pdf.ocr.max_boxes = n;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown KEBAB_* keys are silently ignored — see
|
||||
@@ -1334,11 +1534,68 @@ theme = "dark"
|
||||
assert_eq!(c, back);
|
||||
}
|
||||
|
||||
/// 불변식 #3: `from_file` 이 v2 파일을 디스크 미변경으로 메모리에서 v3
|
||||
/// 변환 — 미변환 v2 파일도 설정 유실 0.
|
||||
#[test]
|
||||
fn from_file_auto_migrates_v2_in_memory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&p,
|
||||
"\
|
||||
schema_version = 2
|
||||
|
||||
[workspace]
|
||||
root = \"/my/notes\"
|
||||
exclude = []
|
||||
|
||||
[chunking]
|
||||
target_tokens = 777
|
||||
|
||||
[image.ocr]
|
||||
enabled = true
|
||||
engine = \"ollama-vision\"
|
||||
model = \"gemma4:e4b\"
|
||||
languages = [\"kor\"]
|
||||
max_pixels = 1600
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let c = Config::from_file(&p).expect("v2 auto-migrate load");
|
||||
// 사용자 v2 값이 새 경로로 살아있어야(기본값 유실 X).
|
||||
assert_eq!(c.ingest.chunking.target_tokens, 777);
|
||||
assert!(c.ingest.image.ocr.enabled);
|
||||
assert_eq!(c.ingest.image.ocr.languages, vec!["kor"]);
|
||||
// 디스크 파일은 안 바뀜(여전히 schema_version = 2 + [chunking]).
|
||||
let on_disk = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
on_disk.contains("schema_version = 2"),
|
||||
"파일이 변경됨:\n{on_disk}"
|
||||
);
|
||||
assert!(on_disk.contains("[chunking]"), "파일이 변경됨:\n{on_disk}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_layout_nests_media_under_ingest() {
|
||||
let c = Config::defaults();
|
||||
// 새 경로가 컴파일·접근 가능해야 한다.
|
||||
assert_eq!(c.ingest.max_parallel_extractors, 2);
|
||||
assert_eq!(c.ingest.chunking.target_tokens, 500);
|
||||
assert_eq!(c.ingest.code.max_file_bytes, 262_144);
|
||||
assert_eq!(c.ingest.image.ocr.engine, "ollama-vision");
|
||||
assert_eq!(c.ingest.image.caption.max_pixels, 768);
|
||||
assert_eq!(c.ingest.pdf.ocr.model, "qwen2.5vl:3b");
|
||||
// pdf paddle 대칭 키 존재 + 기본값.
|
||||
assert_eq!(c.ingest.pdf.ocr.score_thresh, 0.3);
|
||||
assert_eq!(c.ingest.pdf.ocr.max_boxes, 1000);
|
||||
assert!(c.ingest.pdf.ocr.det_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_64_score_gate() {
|
||||
let c = Config::defaults();
|
||||
assert_eq!(c.rag.score_gate, 0.30);
|
||||
assert_eq!(c.chunking.target_tokens, 500);
|
||||
assert_eq!(c.ingest.chunking.target_tokens, 500);
|
||||
assert_eq!(c.models.embedding.model, "multilingual-e5-large");
|
||||
assert_eq!(c.models.embedding.dimensions, 1024);
|
||||
assert_eq!(c.search.rrf_k, 60);
|
||||
@@ -1366,6 +1623,34 @@ theme = "dark"
|
||||
assert_eq!(c.search.default_k, 25);
|
||||
}
|
||||
|
||||
/// 불변식 #2: env override 이름(LHS) 100% 보존 — struct 경로가 바뀌어도
|
||||
/// 기존 `KEBAB_*` 스크립트가 새 경로로 대입되어 무파손.
|
||||
#[test]
|
||||
fn env_names_preserved_target_new_paths() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_CHUNKING_TARGET_TOKENS".into(), "640".into());
|
||||
env.insert("KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS".into(), "6".into());
|
||||
env.insert("KEBAB_IMAGE_OCR_ENABLED".into(), "true".into());
|
||||
env.insert("KEBAB_PDF_OCR_ENGINE".into(), "paddle-onnx".into());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.ingest.chunking.target_tokens, 640);
|
||||
assert_eq!(c.ingest.max_parallel_extractors, 6);
|
||||
assert!(c.ingest.image.ocr.enabled);
|
||||
assert_eq!(c.ingest.pdf.ocr.engine, "paddle-onnx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_pdf_paddle_symmetric_overrides() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_PDF_OCR_DET_MODEL".into(), "/d.onnx".into());
|
||||
env.insert("KEBAB_PDF_OCR_SCORE_THRESH".into(), "0.4".into());
|
||||
env.insert("KEBAB_PDF_OCR_MAX_BOXES".into(), "500".into());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.ingest.pdf.ocr.det_model.as_deref(), Some("/d.onnx"));
|
||||
assert!((c.ingest.pdf.ocr.score_thresh - 0.4).abs() < 1e-6);
|
||||
assert_eq!(c.ingest.pdf.ocr.max_boxes, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_unknown_key_is_ignored() {
|
||||
let baseline = Config::defaults();
|
||||
@@ -1383,7 +1668,7 @@ theme = "dark"
|
||||
"777".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.chunking.target_tokens, 777);
|
||||
assert_eq!(c.ingest.chunking.target_tokens, 777);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1438,24 +1723,24 @@ theme = "dark"
|
||||
"true".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!(c.indexing.watch_filesystem);
|
||||
assert!(c.ingest.watch_filesystem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ocr_defaults_disabled_with_ollama_vision() {
|
||||
let c = Config::defaults();
|
||||
assert!(!c.image.ocr.enabled);
|
||||
assert_eq!(c.image.ocr.engine, "ollama-vision");
|
||||
assert_eq!(c.image.ocr.model, "gemma4:e4b");
|
||||
assert_eq!(c.image.ocr.languages, vec!["eng", "kor"]);
|
||||
assert_eq!(c.image.ocr.max_pixels, 1600);
|
||||
assert!(!c.ingest.image.ocr.enabled);
|
||||
assert_eq!(c.ingest.image.ocr.engine, "ollama-vision");
|
||||
assert_eq!(c.ingest.image.ocr.model, "gemma4:e4b");
|
||||
assert_eq!(c.ingest.image.ocr.languages, vec!["eng", "kor"]);
|
||||
assert_eq!(c.ingest.image.ocr.max_pixels, 1600);
|
||||
}
|
||||
|
||||
/// v0.17.2 post-dogfood: matches the legacy hard-coded 300s cap so
|
||||
/// existing configs that omit the new field keep behaving identically.
|
||||
#[test]
|
||||
fn default_ocr_request_timeout_secs_is_300() {
|
||||
assert_eq!(Config::defaults().image.ocr.request_timeout_secs, 300);
|
||||
assert_eq!(Config::defaults().ingest.image.ocr.request_timeout_secs, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1466,7 +1751,7 @@ theme = "dark"
|
||||
"900".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.image.ocr.request_timeout_secs, 900);
|
||||
assert_eq!(c.ingest.image.ocr.request_timeout_secs, 900);
|
||||
}
|
||||
|
||||
/// post-v0.17.1 dogfood: a config file written before the OCR
|
||||
@@ -1476,7 +1761,7 @@ theme = "dark"
|
||||
#[test]
|
||||
fn legacy_config_without_ocr_request_timeout_secs_uses_default() {
|
||||
let c: Config = toml::from_str(LEGACY_PRE_TIMEOUT_TOML).expect("parse legacy config");
|
||||
assert_eq!(c.image.ocr.request_timeout_secs, 300);
|
||||
assert_eq!(c.ingest.image.ocr.request_timeout_secs, 300);
|
||||
}
|
||||
|
||||
// ── p9-fb-41: multi-hop RAG knobs ────────────────────────────────────
|
||||
@@ -1628,14 +1913,14 @@ theme = "dark"
|
||||
);
|
||||
env.insert("KEBAB_IMAGE_OCR_MAX_PIXELS".to_string(), "2048".to_string());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!(c.image.ocr.enabled);
|
||||
assert_eq!(c.image.ocr.model, "gemma4:31b");
|
||||
assert!(c.ingest.image.ocr.enabled);
|
||||
assert_eq!(c.ingest.image.ocr.model, "gemma4:31b");
|
||||
assert_eq!(
|
||||
c.image.ocr.endpoint.as_deref(),
|
||||
c.ingest.image.ocr.endpoint.as_deref(),
|
||||
Some("http://192.168.0.47:11434")
|
||||
);
|
||||
assert_eq!(c.image.ocr.languages, vec!["eng", "kor", "jpn"]);
|
||||
assert_eq!(c.image.ocr.max_pixels, 2048);
|
||||
assert_eq!(c.ingest.image.ocr.languages, vec!["eng", "kor", "jpn"]);
|
||||
assert_eq!(c.ingest.image.ocr.max_pixels, 2048);
|
||||
}
|
||||
|
||||
/// Pre-P6 config files don't have an `[image]` section. The
|
||||
@@ -1644,9 +1929,9 @@ theme = "dark"
|
||||
#[test]
|
||||
fn image_caption_defaults_disabled() {
|
||||
let c = Config::defaults();
|
||||
assert!(!c.image.caption.enabled);
|
||||
assert_eq!(c.image.caption.max_pixels, 768);
|
||||
assert_eq!(c.image.caption.prompt_template_version, "caption-v1");
|
||||
assert!(!c.ingest.image.caption.enabled);
|
||||
assert_eq!(c.ingest.image.caption.max_pixels, 768);
|
||||
assert_eq!(c.ingest.image.caption.prompt_template_version, "caption-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1665,9 +1950,9 @@ theme = "dark"
|
||||
"caption-v2".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!(c.image.caption.enabled);
|
||||
assert_eq!(c.image.caption.max_pixels, 1024);
|
||||
assert_eq!(c.image.caption.prompt_template_version, "caption-v2");
|
||||
assert!(c.ingest.image.caption.enabled);
|
||||
assert_eq!(c.ingest.image.caption.max_pixels, 1024);
|
||||
assert_eq!(c.ingest.image.caption.prompt_template_version, "caption-v2");
|
||||
}
|
||||
|
||||
/// `KEBAB_IMAGE_OCR_ENDPOINT=""` (empty value) should map to `None`
|
||||
@@ -1678,7 +1963,7 @@ theme = "dark"
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_IMAGE_OCR_ENDPOINT".to_string(), String::new());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.image.ocr.endpoint, None);
|
||||
assert_eq!(c.ingest.image.ocr.endpoint, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1741,7 +2026,7 @@ explain_default = false
|
||||
max_context_tokens = 8000
|
||||
"#;
|
||||
let c: Config = toml::from_str(toml_text).expect("pre-P6 TOML must still parse");
|
||||
assert_eq!(c.image, ImageCfg::defaults());
|
||||
assert_eq!(c.ingest.image, ImageCfg::defaults());
|
||||
}
|
||||
|
||||
/// p9-fb-25: legacy config with `workspace.include = [...]` must
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec
|
||||
//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`.
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::{DocumentMut, Item};
|
||||
|
||||
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
|
||||
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 3;
|
||||
|
||||
/// 한 번의 마이그레이션에서 발생한 개별 변경.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
@@ -77,19 +77,60 @@ fn section_comment(path: &str) -> Option<&'static str> {
|
||||
"models.nli" => "# NLI(groundedness) 모델.",
|
||||
"search" => "# 검색 기본 k·stale 기준·fusion.",
|
||||
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
|
||||
"image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
||||
"image.ocr" => "# 이미지 OCR(기본 off).",
|
||||
"image.caption" => "# 이미지 캡션(기본 off).",
|
||||
"ui" => "# TUI 팔레트·role 스타일.",
|
||||
"ingest" => "# ingest 정책(code skip 등).",
|
||||
"ingest" => "# 모든 형식 ingest 우산: 병렬도 + chunking/code/image/pdf.",
|
||||
"ingest.chunking" => "# 청크 크기·오버랩·heading 존중(전 형식 공통).",
|
||||
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
|
||||
"pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
|
||||
"pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
|
||||
"ingest.image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
||||
"ingest.image.ocr" => "# 이미지 OCR(기본 off).",
|
||||
"ingest.image.caption" => "# 이미지 캡션(기본 off).",
|
||||
"ingest.pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
|
||||
"ingest.pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
|
||||
"logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// leaf 키 인라인 주석. dotted path(예: `ingest.chunking.target_tokens`) → 한 줄.
|
||||
/// 값 뒤에 ` # ...` suffix 로 부착된다(`#` 없이 본문만 반환).
|
||||
fn key_comment(path: &str) -> Option<&'static str> {
|
||||
Some(match path {
|
||||
"workspace.root" => "색인 루트. 절대/~/${VAR}/상대(=이 파일 기준).",
|
||||
"workspace.exclude" => "denylist glob.",
|
||||
"storage.copy_threshold_mb" => "이 크기(MB) 초과 파일은 사본 대신 참조.",
|
||||
"models.embedding.provider" => "fastembed | candle | ollama | none.",
|
||||
"models.embedding.dimensions" => "모델 출력 차원. 틀리면 검색 0건.",
|
||||
"models.embedding.num_threads" => "candle 전용 CPU 스레드 cap(0=auto).",
|
||||
"models.embedding.endpoint" => "ollama provider 시 HTTP. 비우면 llm.endpoint fallback.",
|
||||
"models.llm.request_timeout_secs" => "단일 HTTP 상한. 0=즉시실패(비활성화 아님).",
|
||||
"ingest.max_parallel_extractors" => "동시 extractor 수.",
|
||||
"ingest.max_parallel_embeddings" => "동시 임베딩 수.",
|
||||
"ingest.chunking.target_tokens" => "청크 목표 토큰(전 형식 공통).",
|
||||
"ingest.chunking.respect_markdown_headings" => "markdown heading 경계 존중.",
|
||||
"ingest.image.ocr.enabled" => "이미지 OCR(기본 off, asset 당 비용).",
|
||||
"ingest.image.ocr.engine" => "ollama-vision | paddle-onnx.",
|
||||
"ingest.image.ocr.model" => "ollama-vision 전용. paddle-onnx 는 번들 모델 사용(이 값 무시).",
|
||||
"ingest.image.ocr.request_timeout_secs" => "0=즉시실패(비활성화 아님).",
|
||||
"ingest.image.ocr.score_thresh" => "DBNet box 점수 하한(paddle).",
|
||||
"ingest.image.ocr.unclip_ratio" => "box 패딩 비율(paddle).",
|
||||
"ingest.image.ocr.max_boxes" => "이미지당 box cap(paddle).",
|
||||
"ingest.image.caption.enabled" => "이미지 캡션(기본 off).",
|
||||
"ingest.pdf.ocr.enabled" => "scanned PDF OCR(기본 off, page 당 비용).",
|
||||
"ingest.pdf.ocr.always_on" => "true=모든 page vision 호출(dual-text).",
|
||||
"ingest.pdf.ocr.engine" => "ollama-vision | paddle-onnx.",
|
||||
"ingest.pdf.ocr.model" => "ollama-vision 전용. paddle-onnx 는 번들 모델 사용.",
|
||||
"ingest.pdf.ocr.valid_ratio_threshold" => "유효문자 비율 < 이면 scanned 판정.",
|
||||
"ingest.pdf.ocr.min_char_count" => "page 문자수 < 이면 auto-scanned.",
|
||||
"ingest.pdf.ocr.request_timeout_secs" => "0=즉시실패(비활성화 아님).",
|
||||
"rag.score_gate" => "검색 점수 게이트.",
|
||||
"rag.nli_threshold" => "0=NLI 게이트 off.",
|
||||
"search.default_k" => "기본 검색 결과 수.",
|
||||
"ui.theme" => "dark | light.",
|
||||
"logging.ingest_log_enabled" => "ingest 로그(기본 on).",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Config::defaults() 를 직렬화 + 주석 부착한 "완전체" 문서.
|
||||
/// init 과 migrate reconciliation 의 단일 참조 원천.
|
||||
pub fn annotated_default_document() -> DocumentMut {
|
||||
@@ -123,6 +164,12 @@ fn annotate_table(table: &mut toml_edit::Table, prefix_path: &str) {
|
||||
sub.decor_mut().set_prefix(format!("\n{c}\n"));
|
||||
}
|
||||
annotate_table(sub, &path);
|
||||
} else if let Some(kc) = key_comment(&path) {
|
||||
// 스칼라/배열 leaf: 값 뒤 인라인 주석 suffix. 배열(exclude 등)은
|
||||
// 멀티라인 직렬화돼도 닫는 `]` 뒤로 가 유효.
|
||||
if let Some(v) = item.as_value_mut() {
|
||||
v.decor_mut().set_suffix(format!(" # {kc}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,12 +238,152 @@ pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `from_path` 의 마지막 키를 통째(decor 포함) remove 해 `to_path` 의 dotted
|
||||
/// 경로에 삽입한다(중간 테이블 자동 생성). 대상 키가 이미 있으면 덮어쓰지
|
||||
/// 않는다(사용자 명시 우선). 원본이 없으면 no-op(멱등).
|
||||
fn move_table(
|
||||
doc: &mut DocumentMut,
|
||||
from_path: &[&str],
|
||||
to_path: &[&str],
|
||||
changes: &mut Vec<MigrationChange>,
|
||||
) {
|
||||
// from 의 부모까지 내려가 마지막 키를 remove.
|
||||
let (from_parent, from_key) = from_path.split_at(from_path.len() - 1);
|
||||
let mut cur = doc.as_table_mut();
|
||||
for k in from_parent {
|
||||
match cur.get_mut(k).and_then(Item::as_table_mut) {
|
||||
Some(t) => cur = t,
|
||||
None => return, // 원본 없음 → no-op.
|
||||
}
|
||||
}
|
||||
let Some(item) = cur.remove(from_key[0]) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// to 경로의 부모 테이블 확보(없으면 생성), 마지막 키에 삽입.
|
||||
let (to_parent, to_key) = to_path.split_at(to_path.len() - 1);
|
||||
let mut cur = doc.as_table_mut();
|
||||
for k in to_parent {
|
||||
if cur.get(k).is_none() {
|
||||
cur.insert(k, Item::Table(toml_edit::Table::new()));
|
||||
}
|
||||
cur = cur
|
||||
.get_mut(k)
|
||||
.and_then(Item::as_table_mut)
|
||||
.expect("just inserted");
|
||||
}
|
||||
if cur.get(to_key[0]).is_none() {
|
||||
cur.insert(to_key[0], item);
|
||||
changes.push(MigrationChange {
|
||||
kind: ChangeKind::AddedSection,
|
||||
path: to_path.join("."),
|
||||
detail: format!("{} → {}", from_path.join("."), to_path.join(".")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 옛 `[indexing]` 의 bare 스칼라 키들을 `[ingest]` 로 옮긴다(테이블 자체가
|
||||
/// 아니라 키 단위). 대상에 이미 있는 키는 덮어쓰지 않는다.
|
||||
fn move_indexing_keys(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
let Some(idx) = doc.as_table_mut().remove("indexing") else {
|
||||
return;
|
||||
};
|
||||
let Some(idx_tbl) = idx.as_table().cloned() else {
|
||||
return;
|
||||
};
|
||||
if doc.get("ingest").is_none() {
|
||||
doc["ingest"] = Item::Table(toml_edit::Table::new());
|
||||
}
|
||||
let ingest = doc["ingest"].as_table_mut().expect("ingest table");
|
||||
for (k, v) in idx_tbl.iter() {
|
||||
if ingest.get(k).is_none() {
|
||||
ingest.insert(k, v.clone());
|
||||
}
|
||||
}
|
||||
changes.push(MigrationChange {
|
||||
kind: ChangeKind::AddedKey,
|
||||
path: "ingest".to_string(),
|
||||
detail: "indexing → ingest (병렬도 키)".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// v3: pdf paddle 동작 보존. v2 는 pdf paddle 이 `[image.ocr]` 의 모델 경로를
|
||||
/// 빌려썼다. relocation 후 image.ocr 의 paddle 6키 실제 값을 pdf.ocr 대칭
|
||||
/// 키로 복사한다(pdf 가 이미 명시한 키는 덮어쓰지 않음, pdf 가 paddle 일 때만).
|
||||
fn copy_image_paddle_to_pdf(doc: &mut DocumentMut) {
|
||||
const PADDLE_KEYS: [&str; 6] = [
|
||||
"det_model",
|
||||
"rec_model",
|
||||
"dict",
|
||||
"score_thresh",
|
||||
"unclip_ratio",
|
||||
"max_boxes",
|
||||
];
|
||||
let img = doc
|
||||
.get("ingest")
|
||||
.and_then(|i| i.get("image"))
|
||||
.and_then(|i| i.get("ocr"))
|
||||
.and_then(Item::as_table)
|
||||
.cloned();
|
||||
let Some(img) = img else {
|
||||
return;
|
||||
};
|
||||
let pdf_is_paddle = doc
|
||||
.get("ingest")
|
||||
.and_then(|i| i.get("pdf"))
|
||||
.and_then(|i| i.get("ocr"))
|
||||
.and_then(|o| o.get("engine"))
|
||||
.and_then(Item::as_str)
|
||||
== Some("paddle-onnx");
|
||||
if !pdf_is_paddle {
|
||||
return;
|
||||
}
|
||||
let Some(pdf) = doc["ingest"]["pdf"]["ocr"].as_table_mut() else {
|
||||
return;
|
||||
};
|
||||
for k in PADDLE_KEYS {
|
||||
if pdf.get(k).is_none() {
|
||||
if let Some(v) = img.get(k) {
|
||||
pdf.insert(k, v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// v2 → v3: 미디어 테이블을 `[ingest.*]` 로 relocation(값·주석 보존) + pdf
|
||||
/// paddle 값 보존. 멱등(이미 v3 면 원본 테이블이 없어 전부 no-op).
|
||||
pub fn step_2_to_3(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
move_indexing_keys(doc, changes);
|
||||
move_table(doc, &["chunking"], &["ingest", "chunking"], changes);
|
||||
move_table(doc, &["image", "ocr"], &["ingest", "image", "ocr"], changes);
|
||||
move_table(
|
||||
doc,
|
||||
&["image", "caption"],
|
||||
&["ingest", "image", "caption"],
|
||||
changes,
|
||||
);
|
||||
move_table(doc, &["pdf", "ocr"], &["ingest", "pdf", "ocr"], changes);
|
||||
|
||||
// 빈 껍데기 [image] / [pdf] 제거.
|
||||
for empty in ["image", "pdf"] {
|
||||
if let Some(t) = doc.get(empty).and_then(Item::as_table) {
|
||||
if t.is_empty() {
|
||||
doc.as_table_mut().remove(empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copy_image_paddle_to_pdf(doc);
|
||||
}
|
||||
|
||||
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
|
||||
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
|
||||
if from < 2 {
|
||||
step_1_to_2(doc, changes);
|
||||
}
|
||||
// 미래 step: if from < 3 { step_2_to_3(...) } ...
|
||||
if from < 3 {
|
||||
step_2_to_3(doc, changes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
|
||||
@@ -250,16 +437,35 @@ pub fn migrate_document(text: &str) -> MigrationOutcome {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn annotated_default_has_per_key_comments() {
|
||||
let text = annotated_default_document().to_string();
|
||||
// 대표 키 인라인 주석 존재.
|
||||
assert!(text.contains("# 색인 루트"), "workspace.root 주석 누락:\n{text}");
|
||||
assert!(text.contains("0=즉시실패"), "request_timeout 주석 누락:\n{text}");
|
||||
assert!(
|
||||
text.contains("paddle-onnx 는 번들 모델"),
|
||||
"ocr.model 주석 누락:\n{text}"
|
||||
);
|
||||
// 주석 추가가 파싱을 깨지 않는다.
|
||||
let back: crate::Config = toml::from_str(&text).expect("parse annotated default");
|
||||
assert_eq!(back, crate::Config::defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_default_has_all_sections_and_parses_back_to_defaults() {
|
||||
let doc = annotated_default_document();
|
||||
let text = doc.to_string();
|
||||
// PdfCfg/ImageCfg/ModelsCfg/IngestCfg 는 스칼라 필드가 없어 bare
|
||||
// `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다.
|
||||
// v3: 미디어 형식 섹션이 전부 `[ingest.*]` 하위로 통합됐다. IngestCfg
|
||||
// 는 스칼라(병렬도) 필드가 있어 bare `[ingest]` + 하위 테이블이 함께
|
||||
// 직렬화된다.
|
||||
for section in [
|
||||
"[workspace]",
|
||||
"[ingest]",
|
||||
"[ingest.chunking]",
|
||||
"[ingest.code]",
|
||||
"[pdf.ocr]",
|
||||
"[ingest.image.ocr]",
|
||||
"[ingest.pdf.ocr]",
|
||||
"[logging]",
|
||||
"[ui]",
|
||||
] {
|
||||
@@ -382,6 +588,66 @@ include = [\"*.md\"]
|
||||
assert_eq!(again.new_text, outcome.new_text);
|
||||
}
|
||||
|
||||
fn changes_after_second_pass(text: &str) -> Vec<MigrationChange> {
|
||||
let mut doc: DocumentMut = text.parse().unwrap();
|
||||
let mut ch = Vec::new();
|
||||
step_2_to_3(&mut doc, &mut ch);
|
||||
ch
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_2_to_3_relocates_media_tables() {
|
||||
let v2 = "\
|
||||
schema_version = 2
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 4
|
||||
watch_filesystem = true
|
||||
|
||||
[chunking]
|
||||
target_tokens = 700
|
||||
|
||||
[image.ocr]
|
||||
enabled = true
|
||||
engine = \"paddle-onnx\"
|
||||
det_model = \"/custom/det.onnx\"
|
||||
|
||||
[image.caption]
|
||||
enabled = true
|
||||
|
||||
[pdf.ocr]
|
||||
enabled = false
|
||||
engine = \"paddle-onnx\"
|
||||
";
|
||||
let mut doc: DocumentMut = v2.parse().unwrap();
|
||||
let mut changes = Vec::new();
|
||||
step_2_to_3(&mut doc, &mut changes);
|
||||
let out = doc.to_string();
|
||||
// 새 위치 존재.
|
||||
assert!(out.contains("[ingest]"), "{out}");
|
||||
assert!(out.contains("max_parallel_extractors = 4"));
|
||||
assert!(out.contains("watch_filesystem = true"));
|
||||
assert!(out.contains("[ingest.chunking]"));
|
||||
assert!(out.contains("target_tokens = 700"));
|
||||
assert!(out.contains("[ingest.image.ocr]"));
|
||||
assert!(out.contains("det_model = \"/custom/det.onnx\""));
|
||||
assert!(out.contains("[ingest.image.caption]"));
|
||||
assert!(out.contains("[ingest.pdf.ocr]"));
|
||||
// 옛 위치 제거.
|
||||
assert!(!out.contains("[indexing]"));
|
||||
assert!(!out.contains("\n[chunking]"));
|
||||
assert!(!out.contains("\n[image.ocr]"));
|
||||
assert!(!out.contains("\n[image.caption]"));
|
||||
assert!(!out.contains("\n[pdf.ocr]"));
|
||||
// pdf paddle 동작 보존: image paddle det_model 이 pdf 대칭 키로 복사.
|
||||
let reparsed: DocumentMut = out.parse().unwrap();
|
||||
let pdf_det = reparsed["ingest"]["pdf"]["ocr"].get("det_model");
|
||||
assert_eq!(pdf_det.and_then(|v| v.as_str()), Some("/custom/det.onnx"));
|
||||
// 멱등.
|
||||
let again = changes_after_second_pass(&out);
|
||||
assert!(again.is_empty(), "not idempotent: {again:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_document_missing_schema_version_treated_as_v1() {
|
||||
let old = "[workspace]\nroot = \"/n\"\n";
|
||||
|
||||
149
crates/kebab-config/tests/fixtures/user_v2_config.toml
vendored
Normal file
149
crates/kebab-config/tests/fixtures/user_v2_config.toml
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
# kebab config — `~/.config/kebab/config.toml`.
|
||||
#
|
||||
## `workspace.root` accepts:
|
||||
# • absolute paths (`/home/me/KnowledgeBase`)
|
||||
# • tilde (`~/KnowledgeBase`) ← default
|
||||
# • env vars (`${XDG_DATA_HOME}/kebab`)
|
||||
# • relative paths (`./notes`, `notes`, `../shared/x`)
|
||||
# — relative paths resolve against the directory of THIS
|
||||
# config file, NOT the user's `cwd` at invocation time.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
|
||||
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
|
||||
# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist.
|
||||
#
|
||||
# Override individual keys at runtime with `KEBAB_*` env vars
|
||||
# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`).
|
||||
schema_version = 2
|
||||
|
||||
[workspace]
|
||||
root = "/Users/user/Obsidian/Default"
|
||||
exclude = [
|
||||
".git/**",
|
||||
"node_modules/**",
|
||||
".obsidian/**",
|
||||
]
|
||||
|
||||
[storage]
|
||||
data_dir = "${XDG_DATA_HOME:-~/.local/share}/kebab"
|
||||
sqlite = "{data_dir}/kebab.sqlite"
|
||||
vector_dir = "{data_dir}/lancedb"
|
||||
asset_dir = "{data_dir}/assets"
|
||||
artifact_dir = "{data_dir}/artifacts"
|
||||
model_dir = "{data_dir}/models"
|
||||
runs_dir = "{data_dir}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
[chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "ollama"
|
||||
endpoint = "http://127.0.0.1:11434"
|
||||
# endpoint = "http://192.168.0.2:11943"
|
||||
model = "snowflake-arctic-embed2"
|
||||
# provider = "candle"
|
||||
# model = "snowflake-arctic-embed-l-v2.0"
|
||||
version = "v1"
|
||||
dimensions = 1024
|
||||
batch_size = 64
|
||||
num_threads = 0
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
model = "gemma4:e4b"
|
||||
context_tokens = 32768
|
||||
# endpoint = "http://127.0.0.1:11434"
|
||||
endpoint = "http://192.168.0.2:11943"
|
||||
temperature = 0.0
|
||||
seed = 0
|
||||
request_timeout_secs = 300
|
||||
|
||||
# NLI(groundedness) 모델.
|
||||
[models.nli]
|
||||
model = "Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
|
||||
provider = "onnx"
|
||||
|
||||
[search]
|
||||
default_k = 10
|
||||
hybrid_fusion = "rrf"
|
||||
rrf_k = 60
|
||||
snippet_chars = 220
|
||||
cache_capacity = 256
|
||||
stale_threshold_days = 30
|
||||
|
||||
[rag]
|
||||
prompt_template_version = "rag-v3"
|
||||
score_gate = 0.30000001192092896
|
||||
explain_default = false
|
||||
max_context_tokens = 8000
|
||||
multi_hop_max_depth = 3
|
||||
multi_hop_max_sub_queries_per_iter = 5
|
||||
multi_hop_max_pool_chunks = 15
|
||||
nli_threshold = 0.0
|
||||
|
||||
[image.ocr]
|
||||
enabled = true
|
||||
engine = "paddle-onnx"
|
||||
# engine = "ollama-vision"
|
||||
model = "gemma4:e4b"
|
||||
languages = [
|
||||
"eng",
|
||||
"kor",
|
||||
]
|
||||
max_pixels = 1600
|
||||
request_timeout_secs = 300
|
||||
|
||||
[image.caption]
|
||||
enabled = true
|
||||
max_pixels = 768
|
||||
prompt_template_version = "caption-v1"
|
||||
|
||||
[ui]
|
||||
theme = "dark"
|
||||
|
||||
# code ingest skip 정책(.gitignore 자동 honor).
|
||||
[ingest.code]
|
||||
skip_generated_header = false
|
||||
max_file_bytes = 262144
|
||||
max_file_lines = 5000
|
||||
extra_skip_globs = []
|
||||
ast_chunk_max_lines = 200
|
||||
fallback_lines_per_chunk = 80
|
||||
fallback_lines_overlap = 20
|
||||
|
||||
# scanned PDF page-단위 OCR(기본 off).
|
||||
[pdf.ocr]
|
||||
enabled = false
|
||||
always_on = false
|
||||
engine = "paddle-onnx"
|
||||
# engine = "ollama-vision"
|
||||
model = "qwen2.5vl:3b"
|
||||
languages = [
|
||||
"eng",
|
||||
"kor",
|
||||
]
|
||||
max_pixels = 2048
|
||||
request_timeout_secs = 180
|
||||
valid_ratio_threshold = 0.5
|
||||
min_char_count = 20
|
||||
lang_hint = "kor"
|
||||
|
||||
# ingest 로그(기본 on, ~/.local/state/kebab/logs).
|
||||
[logging]
|
||||
ingest_log_enabled = true
|
||||
ingest_log_dir = "{state_dir}/logs"
|
||||
keep_recent_runs = 100
|
||||
retention_days = 30
|
||||
48
crates/kebab-config/tests/migrate_v3.rs
Normal file
48
crates/kebab-config/tests/migrate_v3.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! v3 마이그레이션 무손실 골든 — 사용자 실제 v2 config.
|
||||
//!
|
||||
//! 불변식: 사용자가 손본 값·주석·대안(commented) 줄이 [ingest.*] relocation
|
||||
//! 후에도 전부 보존되고, v3 Config 로 파싱했을 때 같은 값을 내며, 재실행이
|
||||
//! 멱등이어야 한다.
|
||||
use kebab_config::migrate::migrate_document;
|
||||
|
||||
const USER_V2: &str = include_str!("fixtures/user_v2_config.toml");
|
||||
|
||||
#[test]
|
||||
fn user_v2_migrates_losslessly() {
|
||||
let out = migrate_document(USER_V2);
|
||||
assert_eq!(out.from_schema_version, 2);
|
||||
assert_eq!(out.to_schema_version, 3);
|
||||
let t = &out.new_text;
|
||||
|
||||
// 사용자 값 보존.
|
||||
assert!(t.contains("root = \"/Users/user/Obsidian/Default\""), "{t}");
|
||||
assert!(t.contains("model = \"snowflake-arctic-embed2\""));
|
||||
assert!(t.contains("endpoint = \"http://192.168.0.2:11943\""));
|
||||
// 사용자 주석/대안 줄 보존.
|
||||
assert!(t.contains("# engine = \"ollama-vision\""), "대안 주석 유실:\n{t}");
|
||||
assert!(t.contains("# provider = \"candle\""));
|
||||
// 새 위치.
|
||||
assert!(t.contains("[ingest.image.ocr]"));
|
||||
assert!(t.contains("[ingest.pdf.ocr]"));
|
||||
assert!(t.contains("[ingest.chunking]"));
|
||||
assert!(t.contains("[ingest.image.caption]"));
|
||||
// 옛 top-level 위치 제거.
|
||||
assert!(!t.contains("\n[chunking]"));
|
||||
assert!(!t.contains("\n[image.ocr]"));
|
||||
assert!(!t.contains("\n[indexing]"));
|
||||
|
||||
// v3 Config 로 parse + 값 동일.
|
||||
let cfg: kebab_config::Config = toml::from_str(t).expect("v3 parse");
|
||||
assert!(cfg.ingest.image.ocr.enabled);
|
||||
assert_eq!(cfg.ingest.image.ocr.engine, "paddle-onnx");
|
||||
assert_eq!(cfg.models.embedding.model, "snowflake-arctic-embed2");
|
||||
assert_eq!(cfg.models.llm.endpoint, "http://192.168.0.2:11943");
|
||||
// pdf paddle 값 보존(v2 비대칭 → pdf 대칭 키로 복사). user 의 pdf.ocr 는
|
||||
// engine=paddle-onnx 이고 자체 det_model 없으므로 번들(None) 유지.
|
||||
assert_eq!(cfg.ingest.pdf.ocr.engine, "paddle-onnx");
|
||||
|
||||
// 멱등.
|
||||
let again = migrate_document(t);
|
||||
assert!(!again.changed(), "재실행 변경: {:?}", again.changes);
|
||||
assert_eq!(again.new_text, *t);
|
||||
}
|
||||
@@ -47,20 +47,20 @@ lang_hint = "kor"
|
||||
#[test]
|
||||
fn pdf_ocr_defaults_off_with_qwen_3b() {
|
||||
let cfg = Config::defaults();
|
||||
assert!(!cfg.pdf.ocr.enabled);
|
||||
assert!(!cfg.pdf.ocr.always_on);
|
||||
assert_eq!(cfg.pdf.ocr.engine, "ollama-vision");
|
||||
assert_eq!(cfg.pdf.ocr.model, "qwen2.5vl:3b");
|
||||
assert!(cfg.pdf.ocr.endpoint.is_none());
|
||||
assert!(!cfg.ingest.pdf.ocr.enabled);
|
||||
assert!(!cfg.ingest.pdf.ocr.always_on);
|
||||
assert_eq!(cfg.ingest.pdf.ocr.engine, "ollama-vision");
|
||||
assert_eq!(cfg.ingest.pdf.ocr.model, "qwen2.5vl:3b");
|
||||
assert!(cfg.ingest.pdf.ocr.endpoint.is_none());
|
||||
assert_eq!(
|
||||
cfg.pdf.ocr.languages,
|
||||
cfg.ingest.pdf.ocr.languages,
|
||||
vec!["eng".to_string(), "kor".to_string()]
|
||||
);
|
||||
assert_eq!(cfg.pdf.ocr.max_pixels, 2048);
|
||||
assert_eq!(cfg.pdf.ocr.request_timeout_secs, 180); // Bug #11: 600 → 60 → 180 (HOTFIXES 2026-05-28)
|
||||
assert!((cfg.pdf.ocr.valid_ratio_threshold - 0.5).abs() < 1e-6);
|
||||
assert_eq!(cfg.pdf.ocr.min_char_count, 20);
|
||||
assert_eq!(cfg.pdf.ocr.lang_hint.as_deref(), Some("kor"));
|
||||
assert_eq!(cfg.ingest.pdf.ocr.max_pixels, 2048);
|
||||
assert_eq!(cfg.ingest.pdf.ocr.request_timeout_secs, 180); // Bug #11: 600 → 60 → 180 (HOTFIXES 2026-05-28)
|
||||
assert!((cfg.ingest.pdf.ocr.valid_ratio_threshold - 0.5).abs() < 1e-6);
|
||||
assert_eq!(cfg.ingest.pdf.ocr.min_char_count, 20);
|
||||
assert_eq!(cfg.ingest.pdf.ocr.lang_hint.as_deref(), Some("kor"));
|
||||
}
|
||||
|
||||
// Test 3: env var override — 4 keys 의 typical override case.
|
||||
@@ -80,12 +80,12 @@ fn pdf_ocr_env_overrides() {
|
||||
|
||||
let cfg = Config::defaults().apply_env(&env);
|
||||
|
||||
assert!(cfg.pdf.ocr.enabled);
|
||||
assert_eq!(cfg.pdf.ocr.model, "qwen2.5vl:7b");
|
||||
assert!(cfg.pdf.ocr.always_on);
|
||||
assert!((cfg.pdf.ocr.valid_ratio_threshold - 0.75).abs() < 1e-6);
|
||||
assert!(cfg.ingest.pdf.ocr.enabled);
|
||||
assert_eq!(cfg.ingest.pdf.ocr.model, "qwen2.5vl:7b");
|
||||
assert!(cfg.ingest.pdf.ocr.always_on);
|
||||
assert!((cfg.ingest.pdf.ocr.valid_ratio_threshold - 0.75).abs() < 1e-6);
|
||||
|
||||
// 다른 env var 가 default 보존
|
||||
assert_eq!(cfg.pdf.ocr.engine, "ollama-vision");
|
||||
assert_eq!(cfg.pdf.ocr.min_char_count, 20);
|
||||
assert_eq!(cfg.ingest.pdf.ocr.engine, "ollama-vision");
|
||||
assert_eq!(cfg.ingest.pdf.ocr.min_char_count, 20);
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ fn build_config_snapshot(cfg: &kebab_config::Config, eval_k: usize) -> Result<se
|
||||
Ok(serde_json::json!({
|
||||
"config": cfg_value,
|
||||
"eval_k": eval_k,
|
||||
"chunker_version": cfg.chunking.chunker_version,
|
||||
"chunker_version": cfg.ingest.chunking.chunker_version,
|
||||
"embedding": {
|
||||
"model": cfg.models.embedding.model,
|
||||
"version": cfg.models.embedding.version,
|
||||
|
||||
@@ -35,6 +35,24 @@ kamadak-exif = "0.6"
|
||||
# transitive tokio runtime is brought in once.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
base64 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# paddle-onnx OCR engine (PP-OCRv5, in-process). We reuse the workspace ort
|
||||
# pin (=2.0.0-rc.9) so the ONNX Runtime native lib stays single-versioned with
|
||||
# fastembed / kebab-nli (oar-ocr is intentionally NOT a dep — it would pull
|
||||
# ort rc.12 + ndarray 0.17, splitting the native `links` and threatening the
|
||||
# embedding stack). `download-binaries` extends the pin the same way
|
||||
# `kebab-nli/Cargo.toml:23` does: this crate isn't in fastembed's build graph,
|
||||
# so a standalone `cargo test -p kebab-parse-image` needs it to link onnxruntime.
|
||||
ort = { workspace = true, features = ["ndarray", "download-binaries"] }
|
||||
ndarray = { workspace = true }
|
||||
# blake3: engine_version hash over the bundled det/rec/dict assets (computed
|
||||
# once at OnnxPaddleOcr construction, cached — `ingest_config_signature` calls
|
||||
# engine_version() per asset).
|
||||
blake3 = { workspace = true }
|
||||
# imageproc: connected-components / contours for DBNet det post-processing.
|
||||
# min-area rotated-rect (rotating calipers) and polygon unclip are implemented
|
||||
# in pure Rust (clipper2 is C++ FFI — would break the single-binary guarantee).
|
||||
imageproc = "0.25"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
33
crates/kebab-parse-image/assets/paddleocr-onnx/NOTICE
Normal file
33
crates/kebab-parse-image/assets/paddleocr-onnx/NOTICE
Normal file
@@ -0,0 +1,33 @@
|
||||
PP-OCRv5 mobile ONNX models bundled with kebab (paddle-onnx OCR engine)
|
||||
=======================================================================
|
||||
|
||||
These model weights and the recognition dictionary are derived from
|
||||
PaddleOCR (https://github.com/PaddlePaddle/PaddleOCR), licensed under the
|
||||
Apache License, Version 2.0.
|
||||
|
||||
Copyright (c) PaddlePaddle Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use these files except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Files
|
||||
-----
|
||||
ppocrv5_mobile_det.onnx PP-OCRv5_mobile detection model (DBNet)
|
||||
korean_ppocrv5_mobile_rec.onnx korean_PP-OCRv5_mobile recognition model (CTC)
|
||||
korean_dict.txt recognition dictionary (11,945 chars: KR + Latin + digits + symbols)
|
||||
|
||||
These were converted from the official PaddlePaddle inference models to ONNX
|
||||
via paddle2onnx for in-process execution with onnxruntime (`ort`). No model
|
||||
architecture or weights were modified; only the serialization format changed.
|
||||
|
||||
The recognition CTC class layout (empirically confirmed, see
|
||||
tests/golden/ctc_rec_golden.json):
|
||||
index 0 = CTC blank
|
||||
index 1..11945 = korean_dict.txt line N -> class N (dict[N-1])
|
||||
index 11946 = space ' '
|
||||
total classes = 11947 (= 11945 dict + blank + space)
|
||||
|
||||
If any post-processing source (min-area-rect / polygon unclip) is later
|
||||
ported verbatim from oar-ocr (Apache-2.0), record the per-file provenance
|
||||
here as required by the Apache-2.0 attribution clause.
|
||||
11945
crates/kebab-parse-image/assets/paddleocr-onnx/korean_dict.txt
Normal file
11945
crates/kebab-parse-image/assets/paddleocr-onnx/korean_dict.txt
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -18,7 +18,7 @@
|
||||
//!
|
||||
//! The original P6-3 spec asked for a cargo feature `caption` (default
|
||||
//! OFF at compile time). We collapse this into a single runtime gate
|
||||
//! (`config.image.caption.enabled = false`, default OFF). Reasoning:
|
||||
//! (`config.ingest.image.caption.enabled = false`, default OFF). Reasoning:
|
||||
//! the captioning module's only extra deps are `base64` + `image` +
|
||||
//! `kebab-llm` trait — all already pulled in by the rest of the
|
||||
//! crate. A cargo feature would only complicate the build matrix
|
||||
@@ -50,13 +50,13 @@ const CAPTION_MAX_TOKENS: usize = 96;
|
||||
|
||||
/// Run a caption pass and return the resulting `ModelCaption`.
|
||||
///
|
||||
/// Pure raw operation — does **not** consult `config.image.caption.enabled`.
|
||||
/// Pure raw operation — does **not** consult `config.ingest.image.caption.enabled`.
|
||||
/// The runtime feature gate lives in [`apply_caption`]; this entry
|
||||
/// always invokes the LM. Tests pinning the produced `ModelCaption`
|
||||
/// shape can call this directly without flipping the config flag.
|
||||
///
|
||||
/// Honours the `[MIN_CAPTION_LONG_EDGE, MAX_CAPTION_LONG_EDGE]` clamp
|
||||
/// on `config.image.caption.max_pixels` so a hostile config cannot
|
||||
/// on `config.ingest.image.caption.max_pixels` so a hostile config cannot
|
||||
/// blow up prompt cost.
|
||||
pub fn caption_image(
|
||||
llm: &dyn LanguageModel,
|
||||
@@ -65,15 +65,16 @@ pub fn caption_image(
|
||||
cfg: &kebab_config::Config,
|
||||
) -> Result<ModelCaption> {
|
||||
let max_pixels = cfg
|
||||
.ingest
|
||||
.image
|
||||
.caption
|
||||
.max_pixels
|
||||
.clamp(MIN_CAPTION_LONG_EDGE, MAX_CAPTION_LONG_EDGE);
|
||||
if max_pixels != cfg.image.caption.max_pixels {
|
||||
if max_pixels != cfg.ingest.image.caption.max_pixels {
|
||||
tracing::warn!(
|
||||
target: "kebab-parse-image",
|
||||
"image.caption.max_pixels = {} clamped to {} (legal range [{}, {}])",
|
||||
cfg.image.caption.max_pixels,
|
||||
cfg.ingest.image.caption.max_pixels,
|
||||
max_pixels,
|
||||
MIN_CAPTION_LONG_EDGE,
|
||||
MAX_CAPTION_LONG_EDGE
|
||||
@@ -129,7 +130,7 @@ pub fn caption_image(
|
||||
let caption_text = text.trim().to_string();
|
||||
|
||||
let model_ref = llm.model_ref();
|
||||
let prompt_v = &cfg.image.caption.prompt_template_version;
|
||||
let prompt_v = &cfg.ingest.image.caption.prompt_template_version;
|
||||
let model_version = format!(
|
||||
"{provider}/{prompt}",
|
||||
provider = model_ref.provider,
|
||||
@@ -151,7 +152,7 @@ pub fn caption_image(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pipeline entry point — gate-checks `config.image.caption.enabled`
|
||||
/// Pipeline entry point — gate-checks `config.ingest.image.caption.enabled`
|
||||
/// then mutates `block.caption` in place via [`caption_image`].
|
||||
///
|
||||
/// When `enabled = false` the function is a clean no-op (returns
|
||||
@@ -167,7 +168,7 @@ pub fn apply_caption(
|
||||
cfg: &kebab_config::Config,
|
||||
events: &mut Vec<ProvenanceEvent>,
|
||||
) -> Result<()> {
|
||||
if !cfg.image.caption.enabled {
|
||||
if !cfg.ingest.image.caption.enabled {
|
||||
tracing::debug!(
|
||||
target: "kebab-parse-image",
|
||||
"captioning skipped — image.caption.enabled = false"
|
||||
|
||||
@@ -30,9 +30,14 @@ mod dims;
|
||||
mod exif_extract;
|
||||
mod image_prep;
|
||||
pub mod ocr;
|
||||
pub mod paddle_onnx;
|
||||
|
||||
pub use caption::{apply_caption, caption_image};
|
||||
pub use ocr::{OcrEngine, OllamaVisionOcr, apply_ocr};
|
||||
pub use ocr::{OLLAMA_VISION_ENGINE, OcrEngine, OllamaVisionOcr, apply_ocr};
|
||||
pub use paddle_onnx::{
|
||||
ModelPaths, OnnxPaddleOcr, PADDLE_ONNX_ENGINE, engine_version_for_config,
|
||||
engine_version_for_paths,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::{
|
||||
|
||||
@@ -39,7 +39,7 @@ use crate::image_prep;
|
||||
/// Engine name written into `OcrText.engine` for the Ollama-vision adapter.
|
||||
pub const OLLAMA_VISION_ENGINE: &str = "ollama-vision";
|
||||
|
||||
/// Lower bound on `config.image.ocr.max_pixels`. Anything below this is
|
||||
/// Lower bound on `config.ingest.image.ocr.max_pixels`. Anything below this is
|
||||
/// silently bumped to keep the model from receiving an unreadable thumbnail.
|
||||
const MIN_LONG_EDGE: u32 = 256;
|
||||
|
||||
@@ -65,6 +65,13 @@ pub trait OcrEngine: Send + Sync {
|
||||
/// through to engines that benefit from it (Tesseract languages,
|
||||
/// LLM prompt steering); ignore otherwise.
|
||||
fn recognize(&self, image_bytes: &[u8], lang_hint: Option<&Lang>) -> Result<OcrText>;
|
||||
|
||||
/// Human-facing model label for the ingest progress display
|
||||
/// (`AssetPhase{phase:"ocr", model}`). Distinct from
|
||||
/// [`engine_version`](Self::engine_version), which is the cache-key
|
||||
/// hash. E.g. `"gemma4:e4b"` (ollama-vision) or `"ppocrv5-mobile-kor"`
|
||||
/// (paddle-onnx).
|
||||
fn model(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Mutate `block.ocr` in place by running `engine` over `image_bytes`,
|
||||
@@ -119,14 +126,14 @@ pub struct OllamaVisionOcr {
|
||||
|
||||
impl OllamaVisionOcr {
|
||||
/// Build an adapter from a workspace [`kebab_config::Config`].
|
||||
/// Reads `config.image.ocr.{model, endpoint, languages, max_pixels}`;
|
||||
/// Reads `config.ingest.image.ocr.{model, endpoint, languages, max_pixels}`;
|
||||
/// when `endpoint` is empty falls back to `config.models.llm.endpoint`
|
||||
/// so the same Ollama host serves both LLM and OCR by default.
|
||||
///
|
||||
/// Construction does NOT touch the network — the first HTTP call
|
||||
/// happens inside [`OcrEngine::recognize`].
|
||||
pub fn new(config: &kebab_config::Config) -> Result<Self> {
|
||||
let ocr = &config.image.ocr;
|
||||
let ocr = &config.ingest.image.ocr;
|
||||
let endpoint = match ocr.endpoint.as_deref() {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => config.models.llm.endpoint.clone(),
|
||||
@@ -209,13 +216,6 @@ impl OllamaVisionOcr {
|
||||
self.max_pixels
|
||||
}
|
||||
|
||||
/// The Ollama model id this engine drives (e.g. `gemma4:e4b`).
|
||||
/// Surfaced so the ingest progress display can name the model
|
||||
/// running a slow OCR phase (`AssetPhase{phase:"ocr", model}`).
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn build_prompt(&self, lang_hint: Option<&Lang>) -> String {
|
||||
let langs = if self.languages.is_empty() {
|
||||
"any".to_string()
|
||||
@@ -247,6 +247,10 @@ impl OcrEngine for OllamaVisionOcr {
|
||||
format!("ollama/{}", self.model)
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn recognize(&self, image_bytes: &[u8], lang_hint: Option<&Lang>) -> Result<OcrText> {
|
||||
let (prepared, w, h) = image_prep::downscale_to_png(image_bytes, self.max_pixels)
|
||||
.context("preparing image for OCR")?;
|
||||
|
||||
1005
crates/kebab-parse-image/src/paddle_onnx.rs
Normal file
1005
crates/kebab-parse-image/src/paddle_onnx.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,8 @@ use crate::common::red_100x50_png;
|
||||
|
||||
fn cfg_with_caption_enabled() -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.image.caption.enabled = true;
|
||||
cfg.image.caption.max_pixels = 512;
|
||||
cfg.ingest.image.caption.enabled = true;
|
||||
cfg.ingest.image.caption.max_pixels = 512;
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ fn mk_mock(canned: &str) -> MockLanguageModel {
|
||||
#[test]
|
||||
fn apply_caption_no_op_when_feature_disabled() {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.image.caption.enabled = false;
|
||||
cfg.ingest.image.caption.enabled = false;
|
||||
let mock = mk_mock("ignored");
|
||||
let mut block = empty_image_block();
|
||||
let mut events: Vec<ProvenanceEvent> = Vec::new();
|
||||
@@ -292,8 +292,8 @@ fn caption_image_deterministic_with_identical_inputs() {
|
||||
#[test]
|
||||
fn caption_image_clamps_oversized_max_pixels() {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.image.caption.enabled = true;
|
||||
cfg.image.caption.max_pixels = 99_999; // way over MAX_CAPTION_LONG_EDGE
|
||||
cfg.ingest.image.caption.enabled = true;
|
||||
cfg.ingest.image.caption.max_pixels = 99_999; // way over MAX_CAPTION_LONG_EDGE
|
||||
let captured_images: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let mock = CapturingMock {
|
||||
captured_system: Arc::new(Mutex::new(None)),
|
||||
@@ -339,8 +339,8 @@ fn caption_integration_real_ollama_describes_image() {
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.image.caption.enabled = true;
|
||||
cfg.image.caption.max_pixels = 768;
|
||||
cfg.ingest.image.caption.enabled = true;
|
||||
cfg.ingest.image.caption.max_pixels = 768;
|
||||
if let Ok(ep) = std::env::var("KEBAB_MODELS_LLM_ENDPOINT") {
|
||||
cfg.models.llm.endpoint = ep;
|
||||
} else {
|
||||
|
||||
516
crates/kebab-parse-image/tests/golden/ctc_rec_golden.json
Normal file
516
crates/kebab-parse-image/tests/golden/ctc_rec_golden.json
Normal file
@@ -0,0 +1,516 @@
|
||||
{
|
||||
"dict_lines": 11945,
|
||||
"rec_classes": 11947,
|
||||
"blank_index": 0,
|
||||
"space_index": 11946,
|
||||
"mapping": "idx0=blank; idx 1..N=dict[idx-1]; idx N+1=space; classes=dict+2",
|
||||
"rec_norm": "RGB, /255 then (x-0.5)/0.5 => [-1,1], height=48 keep-aspect pad",
|
||||
"det_norm": "RGB, ImageNet mean/std *255 then /std, NCHW",
|
||||
"rec_cases": [
|
||||
{
|
||||
"text": "RAG 시스템 검색 결과",
|
||||
"decoded": "RAG시스템 검색 결과",
|
||||
"cer": 0.0769,
|
||||
"cer_nospace": 0.0,
|
||||
"mapping_ok": true,
|
||||
"T": 40,
|
||||
"C": 11947,
|
||||
"argmax_idx": [
|
||||
0,
|
||||
0,
|
||||
11553,
|
||||
0,
|
||||
11536,
|
||||
0,
|
||||
0,
|
||||
11542,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
6185,
|
||||
0,
|
||||
0,
|
||||
6129,
|
||||
0,
|
||||
0,
|
||||
9897,
|
||||
0,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
461,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5654,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
509,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
585,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"collapsed_idx": [
|
||||
11553,
|
||||
11536,
|
||||
11542,
|
||||
6185,
|
||||
6129,
|
||||
9897,
|
||||
11946,
|
||||
461,
|
||||
5654,
|
||||
11946,
|
||||
509,
|
||||
585
|
||||
],
|
||||
"collapsed_conf": [
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002
|
||||
],
|
||||
"fired_timesteps": [
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
11,
|
||||
14,
|
||||
17,
|
||||
20,
|
||||
22,
|
||||
26,
|
||||
28,
|
||||
30,
|
||||
34
|
||||
],
|
||||
"fired_logit_top5": [
|
||||
{
|
||||
"t": 2,
|
||||
"top5_idx": [
|
||||
11553,
|
||||
11583,
|
||||
11551,
|
||||
0,
|
||||
11541
|
||||
],
|
||||
"top5_val": [
|
||||
0.9998,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"top5_idx": [
|
||||
11536,
|
||||
11566,
|
||||
0,
|
||||
11748,
|
||||
11551
|
||||
],
|
||||
"top5_val": [
|
||||
0.9998,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"top5_idx": [
|
||||
11542,
|
||||
0,
|
||||
11572,
|
||||
11946,
|
||||
11585
|
||||
],
|
||||
"top5_val": [
|
||||
0.9994,
|
||||
0.0004,
|
||||
0.0001,
|
||||
0.0001,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"top5_idx": [
|
||||
6185,
|
||||
0,
|
||||
11946,
|
||||
7949,
|
||||
11518
|
||||
],
|
||||
"top5_val": [
|
||||
0.9993,
|
||||
0.0003,
|
||||
0.0001,
|
||||
0.0001,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"top5_idx": [
|
||||
6129,
|
||||
7893,
|
||||
0,
|
||||
9069,
|
||||
11536
|
||||
],
|
||||
"top5_val": [
|
||||
0.9997,
|
||||
0.0002,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 17,
|
||||
"top5_idx": [
|
||||
9897,
|
||||
9882,
|
||||
9889,
|
||||
9785,
|
||||
3429
|
||||
],
|
||||
"top5_val": [
|
||||
0.9999,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 20,
|
||||
"top5_idx": [
|
||||
11946,
|
||||
0,
|
||||
11516,
|
||||
11518,
|
||||
11579
|
||||
],
|
||||
"top5_val": [
|
||||
0.9026,
|
||||
0.0971,
|
||||
0.0002,
|
||||
0.0001,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"top5_idx": [
|
||||
461,
|
||||
462,
|
||||
9281,
|
||||
349,
|
||||
0
|
||||
],
|
||||
"top5_val": [
|
||||
0.9995,
|
||||
0.0003,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 26,
|
||||
"top5_idx": [
|
||||
5654,
|
||||
0,
|
||||
5766,
|
||||
8594,
|
||||
6830
|
||||
],
|
||||
"top5_val": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 28,
|
||||
"top5_idx": [
|
||||
11946,
|
||||
0,
|
||||
11516,
|
||||
11549,
|
||||
11564
|
||||
],
|
||||
"top5_val": [
|
||||
0.9422,
|
||||
0.0576,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"top5_idx": [
|
||||
509,
|
||||
0,
|
||||
453,
|
||||
11946,
|
||||
505
|
||||
],
|
||||
"top5_val": [
|
||||
0.9994,
|
||||
0.0004,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 34,
|
||||
"top5_idx": [
|
||||
585,
|
||||
641,
|
||||
0,
|
||||
10329,
|
||||
589
|
||||
],
|
||||
"top5_val": [
|
||||
0.9999,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "Embedding vector 0123",
|
||||
"decoded": "Embedding vector 0123",
|
||||
"cer": 0.0,
|
||||
"cer_nospace": 0.0,
|
||||
"mapping_ok": true,
|
||||
"T": 41,
|
||||
"C": 11947,
|
||||
"argmax_idx": [
|
||||
0,
|
||||
11540,
|
||||
0,
|
||||
0,
|
||||
11578,
|
||||
0,
|
||||
0,
|
||||
11567,
|
||||
0,
|
||||
11570,
|
||||
0,
|
||||
11569,
|
||||
0,
|
||||
11569,
|
||||
0,
|
||||
11574,
|
||||
0,
|
||||
11579,
|
||||
11572,
|
||||
11572,
|
||||
11946,
|
||||
0,
|
||||
11587,
|
||||
11570,
|
||||
0,
|
||||
11568,
|
||||
0,
|
||||
11585,
|
||||
11580,
|
||||
0,
|
||||
11583,
|
||||
11946,
|
||||
11946,
|
||||
11520,
|
||||
0,
|
||||
11521,
|
||||
0,
|
||||
11522,
|
||||
0,
|
||||
11523,
|
||||
0
|
||||
],
|
||||
"collapsed_idx": [
|
||||
11540,
|
||||
11578,
|
||||
11567,
|
||||
11570,
|
||||
11569,
|
||||
11569,
|
||||
11574,
|
||||
11579,
|
||||
11572,
|
||||
11946,
|
||||
11587,
|
||||
11570,
|
||||
11568,
|
||||
11585,
|
||||
11580,
|
||||
11583,
|
||||
11946,
|
||||
11520,
|
||||
11521,
|
||||
11522,
|
||||
11523
|
||||
],
|
||||
"collapsed_conf": [
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0001,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "한글 OCR 정확도 테스트",
|
||||
"decoded": "한글 OCR 정확도 테스트",
|
||||
"cer": 0.0,
|
||||
"cer_nospace": 0.0,
|
||||
"mapping_ok": true,
|
||||
"T": 41,
|
||||
"C": 11947,
|
||||
"argmax_idx": [
|
||||
0,
|
||||
0,
|
||||
10921,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
845,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
11550,
|
||||
0,
|
||||
0,
|
||||
11538,
|
||||
0,
|
||||
11553,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
7522,
|
||||
0,
|
||||
0,
|
||||
11170,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2321,
|
||||
0,
|
||||
11946,
|
||||
11946,
|
||||
9881,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
6129,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
10245,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"collapsed_idx": [
|
||||
10921,
|
||||
845,
|
||||
11946,
|
||||
11550,
|
||||
11538,
|
||||
11553,
|
||||
11946,
|
||||
7522,
|
||||
11170,
|
||||
2321,
|
||||
11946,
|
||||
9881,
|
||||
6129,
|
||||
10245
|
||||
],
|
||||
"collapsed_conf": [
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002
|
||||
]
|
||||
}
|
||||
],
|
||||
"det_cases": [
|
||||
{
|
||||
"fixture": "clean_paragraph.png",
|
||||
"orig_hw": [
|
||||
192,
|
||||
900
|
||||
],
|
||||
"det_input_hw": [
|
||||
192,
|
||||
896
|
||||
],
|
||||
"prob_shape": [
|
||||
192,
|
||||
896
|
||||
],
|
||||
"prob_max": 1.0,
|
||||
"prob_mean": 0.1139,
|
||||
"positives_at_0.3": 19682,
|
||||
"positive_frac": 0.1144,
|
||||
"box_count": 3,
|
||||
"postproc": "thresh=0.3 -> findContours -> minAreaRect -> unclip(ratio=1.5, area*r/peri); box_thresh=0.5 mean-prob filter; coords scaled back to orig hw"
|
||||
}
|
||||
],
|
||||
"blank_index_confirmed_by_gt": true
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"fixture": "clean_paragraph.png",
|
||||
"orig_hw": [
|
||||
192,
|
||||
900
|
||||
],
|
||||
"det_input_hw": [
|
||||
192,
|
||||
896
|
||||
],
|
||||
"thresh": 0.3,
|
||||
"unclip_ratio": 1.5,
|
||||
"boxes": [
|
||||
{
|
||||
"poly": [
|
||||
[
|
||||
29,
|
||||
135
|
||||
],
|
||||
[
|
||||
615,
|
||||
134
|
||||
],
|
||||
[
|
||||
615,
|
||||
149
|
||||
],
|
||||
[
|
||||
29,
|
||||
150
|
||||
]
|
||||
],
|
||||
"score": 0.8724
|
||||
},
|
||||
{
|
||||
"poly": [
|
||||
[
|
||||
30,
|
||||
92
|
||||
],
|
||||
[
|
||||
597,
|
||||
92
|
||||
],
|
||||
[
|
||||
597,
|
||||
105
|
||||
],
|
||||
[
|
||||
30,
|
||||
105
|
||||
]
|
||||
],
|
||||
"score": 0.9627
|
||||
},
|
||||
{
|
||||
"poly": [
|
||||
[
|
||||
30,
|
||||
47
|
||||
],
|
||||
[
|
||||
509,
|
||||
47
|
||||
],
|
||||
[
|
||||
509,
|
||||
60
|
||||
],
|
||||
[
|
||||
30,
|
||||
60
|
||||
]
|
||||
],
|
||||
"score": 0.9304
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,10 +19,10 @@ use crate::common::red_100x50_png;
|
||||
|
||||
fn cfg_for_endpoint(endpoint: &str) -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.image.ocr.endpoint = Some(endpoint.to_string());
|
||||
cfg.image.ocr.model = "gemma4:e4b".to_string();
|
||||
cfg.image.ocr.languages = vec!["eng".to_string(), "kor".to_string()];
|
||||
cfg.image.ocr.max_pixels = 1024;
|
||||
cfg.ingest.image.ocr.endpoint = Some(endpoint.to_string());
|
||||
cfg.ingest.image.ocr.model = "gemma4:e4b".to_string();
|
||||
cfg.ingest.image.ocr.languages = vec!["eng".to_string(), "kor".to_string()];
|
||||
cfg.ingest.image.ocr.max_pixels = 1024;
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -375,9 +375,9 @@ async fn ocr_integration_real_ollama_transcribes_text() {
|
||||
};
|
||||
let cfg = {
|
||||
let mut c = Config::defaults();
|
||||
c.image.ocr.endpoint = Some(endpoint);
|
||||
c.image.ocr.model = model;
|
||||
c.image.ocr.max_pixels = 1024;
|
||||
c.ingest.image.ocr.endpoint = Some(endpoint);
|
||||
c.ingest.image.ocr.model = model;
|
||||
c.ingest.image.ocr.max_pixels = 1024;
|
||||
c
|
||||
};
|
||||
let text = tokio::task::spawn_blocking(move || run_recognize(cfg, bytes, None))
|
||||
|
||||
145
crates/kebab-parse-image/tests/paddle_e2e.rs
Normal file
145
crates/kebab-parse-image/tests/paddle_e2e.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! T11 e2e accuracy gate for the paddle-onnx OCR engine.
|
||||
//!
|
||||
//! Runs the full `OnnxPaddleOcr` pipeline (det → rectify → rec → CTC) over the
|
||||
//! synthetic OCR benchmark fixtures and asserts the mean character error rate
|
||||
//! (CER) over the clean text set is `<= 0.05`, matching the spec gate.
|
||||
//!
|
||||
//! Model assets come from `KEBAB_TEST_OCR_MODEL_DIR` (default: the crate's
|
||||
//! bundled `assets/paddleocr-onnx/`). Fixtures come from
|
||||
//! `KEBAB_TEST_OCR_FIXTURE_DIR` (default: the dogfood corpus). If either is
|
||||
//! absent the test skips with a warning rather than failing — CI without the
|
||||
//! large models / fixtures stays green (plan T0/M4).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kebab_parse_image::{ModelPaths, OcrEngine, OnnxPaddleOcr};
|
||||
|
||||
/// Collapse all whitespace runs to a single space + trim — matches the Python
|
||||
/// `score_lib.norm` so the Rust gate and the bench harness agree.
|
||||
fn norm(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Character error rate = Levenshtein(gt, pred) / len(gt), both normalized.
|
||||
fn cer(gt: &str, pred: &str) -> f64 {
|
||||
let g: Vec<char> = norm(gt).chars().collect();
|
||||
let p: Vec<char> = norm(pred).chars().collect();
|
||||
if g.is_empty() {
|
||||
return if p.is_empty() { 0.0 } else { 1.0 };
|
||||
}
|
||||
let (m, n) = (g.len(), p.len());
|
||||
let mut prev: Vec<usize> = (0..=n).collect();
|
||||
for i in 1..=m {
|
||||
let mut cur = vec![i; n + 1];
|
||||
for j in 1..=n {
|
||||
let cost = usize::from(g[i - 1] != p[j - 1]);
|
||||
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
prev[n] as f64 / m as f64
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
std::env::var("KEBAB_TEST_OCR_FIXTURE_DIR").map_or_else(
|
||||
|_| PathBuf::from("/build/dogfood/corpus/images/synthetic-ocr-bench"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
/// T10: undecodable image bytes must surface as an error (the kebab-app caller
|
||||
/// then skips the asset + records provenance), not panic or return garbage.
|
||||
#[test]
|
||||
fn paddle_onnx_decode_failure_is_error() {
|
||||
let paths = ModelPaths::from_default_dir();
|
||||
if !paths.det.exists() || !paths.rec.exists() || !paths.dict.exists() {
|
||||
eprintln!("SKIP paddle_onnx_decode_failure_is_error: model assets not found");
|
||||
return;
|
||||
}
|
||||
let engine = OnnxPaddleOcr::from_paths(&paths, 0.3, 1.5, 1000, 1600).unwrap();
|
||||
let err = engine
|
||||
.recognize(b"not a real image", None)
|
||||
.expect_err("garbage bytes must fail to decode");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("decoding image"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paddle_onnx_cer_gate() {
|
||||
let paths = ModelPaths::from_default_dir();
|
||||
if !paths.det.exists() || !paths.rec.exists() || !paths.dict.exists() {
|
||||
eprintln!(
|
||||
"SKIP paddle_onnx_cer_gate: model assets not found (det={}). \
|
||||
Set KEBAB_TEST_OCR_MODEL_DIR or place assets/paddleocr-onnx/.",
|
||||
paths.det.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let fdir = fixture_dir();
|
||||
let gt_path = fdir.join("gt.json");
|
||||
if !gt_path.exists() {
|
||||
eprintln!(
|
||||
"SKIP paddle_onnx_cer_gate: fixtures not found at {}",
|
||||
fdir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let gt: HashMap<String, String> =
|
||||
serde_json::from_str(&std::fs::read_to_string(>_path).unwrap()).unwrap();
|
||||
|
||||
let engine = OnnxPaddleOcr::from_paths(&paths, 0.3, 1.5, 1000, 1600)
|
||||
.expect("build OnnxPaddleOcr from bundled assets");
|
||||
|
||||
// "clean" set used for the gate — the standard, well-formed text fixtures.
|
||||
// low_contrast / small_dense are intentionally hard and tracked but not
|
||||
// part of the hard gate.
|
||||
let gate_set = [
|
||||
"clean_paragraph.png",
|
||||
"title_body.png",
|
||||
"tech_terms.png",
|
||||
"korean_heavy.png",
|
||||
"numbers_table.png",
|
||||
];
|
||||
|
||||
let mut gate_cers = Vec::new();
|
||||
let mut names: Vec<&String> = gt.keys().collect();
|
||||
names.sort();
|
||||
println!("\n=== paddle-onnx CER per fixture ===");
|
||||
for name in names {
|
||||
let img_path = fdir.join(name);
|
||||
if !img_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&img_path).unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
let out = engine.recognize(&bytes, None).expect("recognize");
|
||||
let dt = t0.elapsed();
|
||||
let c = cer(>[name], &out.joined);
|
||||
if std::env::var("KEBAB_OCR_DUMP").is_ok() {
|
||||
println!(" GT [{name}]: {:?}", norm(>[name]));
|
||||
println!(" OUT [{name}]: {:?}", norm(&out.joined));
|
||||
}
|
||||
let gated = gate_set.contains(&name.as_str());
|
||||
println!(
|
||||
"{:<22} CER={:.4} {} ({} regions, {} ms)",
|
||||
name,
|
||||
c,
|
||||
if gated { "[gate]" } else { " " },
|
||||
out.regions.len(),
|
||||
dt.as_millis()
|
||||
);
|
||||
if gated {
|
||||
gate_cers.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!gate_cers.is_empty(), "no gate fixtures were scored");
|
||||
let mean = gate_cers.iter().sum::<f64>() / gate_cers.len() as f64;
|
||||
println!("=== mean gate CER = {mean:.4} (threshold 0.05) ===\n");
|
||||
assert!(
|
||||
mean <= 0.05,
|
||||
"paddle-onnx mean CER {mean:.4} exceeds 0.05 gate"
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
|
||||
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
|
||||
| 음성 ASR | `whisper.cpp` (via `whisper-rs`) — P8 보류, 시스템 dep brainstorm 후 |
|
||||
| OCR (image) | Ollama vision LM (default `gemma4:e4b`) — `OcrEngine` trait 으로 Tesseract / Apple Vision 등 future swap (HOTFIXES P6-2) |
|
||||
| OCR (image) | `OcrEngine` trait, 2 백엔드: **`ollama-vision`** (default, `gemma4:e4b`) / **`paddle-onnx`** (v0.27.0 — PP-OCRv5 ONNX in-process via `ort` =2.0.0-rc.9, DBNet det + CTC rec, 후처리 min-area rect/unclip pure-Rust, Python 런타임 0). engine 선택은 `[image.ocr] engine`, 팩토리는 `kebab-app::build_image_ocr_engine`. e2e CER 0.005 / 큰 페이지 <4초. (HOTFIXES P6-2, 2026-06-04) |
|
||||
| OCR (PDF, v0.20.0+) | Ollama vision LM (default `qwen2.5vl:3b`) — post-extract enrichment via `kebab-app::pdf_ocr_apply` (H-1 resolution). DCTDecode-only v1 (FlateDecode/CCITTFax skip + warning). family asymmetry vs image OCR: PoC alnum 94.79% (qwen2.5vl) >> 27% (gemma4:e4b 받침), 본 단계에서 PDF OCR 만 qwen2.5vl. |
|
||||
| Image caption | Ollama vision LM, runtime gate `image.caption.enabled` (default OFF) |
|
||||
| RAG groundedness 검증 | `kebab-nli` 의 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 (fb-41). `[rag] nli_threshold > 0` (default 0 = disabled, production 권장 0.5) 일 때 활성 — 미달 시 `refusal_reason = nli_verification_failed` (LLM self-judge ceiling 보완). 첫 호출 시 ~280 MB ONNX 자동 다운로드 |
|
||||
@@ -212,7 +212,7 @@ kebab/
|
||||
│ ├── kebab-rag/ # RAG pipeline (P4-3)
|
||||
│ ├── kebab-nli/ # NLI verifier (mDeBERTa-v3 XNLI, fb-41 PR-9a/9b/9c-1)
|
||||
│ ├── kebab-eval/ # golden query runner + metrics (P5-1, P5-2)
|
||||
│ ├── kebab-parse-image/ # ImageExtractor + Ollama OCR + caption (P6)
|
||||
│ ├── kebab-parse-image/ # ImageExtractor + OCR (ollama-vision + paddle-onnx ONNX) + caption (P6)
|
||||
│ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1)
|
||||
│ ├── kebab-parse-code/ # tree-sitter AST extractors: Rust (P10-1A-2), Python + TypeScript + JavaScript (P10-1B), Go (P10-1C-Go), Java + Kotlin (P10-1C-JK — java.rs + kotlin.rs), C + C++ (P10-1D — c.rs + cpp.rs); chunker lives in kebab-chunk
|
||||
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
|
||||
|
||||
@@ -95,12 +95,14 @@ model_dir = "{data_dir}/models"
|
||||
runs_dir = "{data_dir}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
# v0.28.0: 모든 형식 ingest 설정의 우산. 병렬도(← 옛 [indexing])는 [ingest] 스칼라로,
|
||||
# chunking/code/image/pdf 는 [ingest.*] 하위로 통합. 옛 v2 파일은 로드 시 자동 변환됨.
|
||||
[ingest]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
[chunking]
|
||||
[ingest.chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
@@ -329,7 +331,7 @@ MCP tool 동등:
|
||||
[workspace]
|
||||
include = ["**/*.md", "**/*.png", "**/*.jpg"]
|
||||
|
||||
[image.ocr]
|
||||
[ingest.image.ocr]
|
||||
enabled = true # vision LM 으로 이미지 안 텍스트 전사
|
||||
engine = "ollama-vision"
|
||||
model = "gemma4:e4b" # 사용자 환경의 비전 모델
|
||||
@@ -337,12 +339,12 @@ endpoint = "http://192.168.0.47:11434" # 비우면 models.llm.endpoint fallback
|
||||
languages = ["eng", "kor"]
|
||||
max_pixels = 1600 # long-edge cap
|
||||
|
||||
[image.caption]
|
||||
[ingest.image.caption]
|
||||
enabled = true # vision LM 으로 한 문장 객관 설명 생성
|
||||
max_pixels = 768
|
||||
prompt_template_version = "caption-v1"
|
||||
|
||||
[pdf.ocr]
|
||||
[ingest.pdf.ocr]
|
||||
enabled = true # smoke test 의 OCR path 활성화 (manual invoke)
|
||||
always_on = false
|
||||
engine = "ollama-vision"
|
||||
@@ -358,6 +360,24 @@ lang_hint = "kor"
|
||||
|
||||
이미지 자산 한 장당 OCR 1 호출 + Caption 1 호출 → ~3-6초 (`gemma4:e4b` 기준). 다이어그램 / 카메라 사진 / 스크린샷 위주 워크스페이스에 권장. 책 / 스캔본은 P7 PDF 라인으로.
|
||||
|
||||
**v0.27.0 — paddle-onnx 엔진 (오프라인, Ollama 불필요).** `[ingest.image.ocr] engine = "paddle-onnx"` 로 바꾸면 PP-OCRv5 ONNX 를 in-process 로 실행한다 (원격 vision LM 불필요, 큰 페이지 CPU <4초). embedding 까지 끄려면 `[models.embedding] provider = "none"` (lexical-only) 로 두면 Ollama 없이 OCR→FTS5 검색 전체 경로를 스모크할 수 있다:
|
||||
|
||||
```toml
|
||||
[models.embedding]
|
||||
provider = "none" # lexical-only — Ollama 불필요
|
||||
|
||||
[ingest.image.ocr]
|
||||
enabled = true
|
||||
engine = "paddle-onnx" # PP-OCRv5 ONNX in-process (Python/원격 0)
|
||||
model = "ppocrv5-mobile-kor"
|
||||
languages = ["kor", "eng"]
|
||||
max_pixels = 1600
|
||||
# det_model / rec_model / dict 로 번들 모델 경로 override 가능 (생략 시 번들 사용)
|
||||
# score_thresh = 0.3 / unclip_ratio = 1.5 / max_boxes = 1000 으로 검출 튜닝
|
||||
```
|
||||
|
||||
스모크: `kebab ingest --config <cfg>` 후 `kebab search --config <cfg> --mode lexical "<이미지 안 한국어 단어>"` 가 그 image chunk 를 반환하면 OCR→FTS5 wiring 정상. engine 또는 모델을 바꾸면 다음 ingest 가 영향 이미지를 자동 재색인한다.
|
||||
|
||||
## P7-3 PDF ingestion
|
||||
|
||||
`config.toml` 의 `[workspace] include` 에 `**/*.pdf` 를 추가하면 `kebab ingest` 가 텍스트 PDF 자산도 색인합니다. 외부 service 의존 없음 — `kebab-parse-pdf` 가 lopdf 로 페이지 단위 텍스트 추출, `kebab-chunk::PdfPageV1Chunker` 가 페이지 경계를 절대 넘지 않는 chunk 생성.
|
||||
|
||||
66
docs/release-notes/v0.28.0-draft.md
Normal file
66
docs/release-notes/v0.28.0-draft.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: kebab v0.28.0 release notes (draft)
|
||||
created: 2026-06-04
|
||||
status: draft
|
||||
release_trigger:
|
||||
- config 스키마 v2→v3 재편 (config 레이아웃 rename + 신규 env) — pre-1.0 minor bump
|
||||
- frozen 설계 변경 (2026-06-04-config-schema-reorg-design)
|
||||
---
|
||||
|
||||
# kebab v0.28.0 — config 스키마 v2→v3: 미디어 ingest 통합
|
||||
|
||||
v0.27.0(PP-OCRv5 ONNX OCR) 후속 minor release. `config.toml` 에 흩어져
|
||||
있던 미디어 형식 설정을 **`[ingest.*]` 우산** 하나로 모은다. **기존 사용자는
|
||||
아무것도 손대지 않아도 된다** — 옛 v2 파일은 로드 시 메모리에서 자동 변환되고,
|
||||
검색·색인 결과는 바이트 단위로 동일하다(재색인 0).
|
||||
|
||||
---
|
||||
|
||||
## 변경 사실
|
||||
|
||||
미디어 형식 설정이 top-level 에서 `[ingest.*]` 하위로 이동했다.
|
||||
|
||||
| v2 (top-level) | v3 (`[ingest.*]`) |
|
||||
|---|---|
|
||||
| `[indexing]` (스칼라) | `[ingest]` 스칼라 (`max_parallel_extractors` 등) |
|
||||
| `[chunking]` | `[ingest.chunking]` |
|
||||
| `[image.ocr]` | `[ingest.image.ocr]` |
|
||||
| `[image.caption]` | `[ingest.image.caption]` |
|
||||
| `[pdf.ocr]` | `[ingest.pdf.ocr]` |
|
||||
| `[ingest.code]` | `[ingest.code]` (변화 없음) |
|
||||
|
||||
부수적으로 `[ingest.pdf.ocr]` 가 paddle-onnx 모델 경로 키(`det_model`/`rec_model`/
|
||||
`dict`/`score_thresh`/`unclip_ratio`/`max_boxes`)를 PDF 자체적으로 가질 수 있게
|
||||
됐다(v2 는 image.ocr 의 값을 빌려썼다). 신규 env `KEBAB_PDF_OCR_*` 6키.
|
||||
|
||||
## Trade-off
|
||||
|
||||
비-additive **rename** 마이그레이션이라(첫 사례), 옛 섹션 이름을 그대로 두면
|
||||
serde 가 모르는 키로 무시될 수 있다. 이를 피하려고 두 겹의 안전장치를 깔았다:
|
||||
(1) `Config::from_file` 이 load 시 메모리에서 v3 로 자동 변환 — 미변환 v2 파일도
|
||||
설정 유실 0. (2) `kebab config migrate` 가 디스크 파일을 새 레이아웃으로 갱신하되
|
||||
값·주석·대안(commented) 줄을 전부 보존하고 멱등이다.
|
||||
|
||||
env override 이름(예 `KEBAB_CHUNKING_TARGET_TOKENS`,
|
||||
`KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS`)은 **그대로 유지**된다 — 대입 대상만
|
||||
새 경로로 바뀌므로 기존 `KEBAB_*` 스크립트는 손댈 필요가 없다.
|
||||
|
||||
## Mitigation — 재색인 0 보장
|
||||
|
||||
`ingest_config_signature` 는 값 기반이라 struct 경로가 바뀌어도 출력 문자열이
|
||||
v2 와 **바이트 동일**하다. paddle 모델 경로는 미디어별(image/pdf)로 호출자가
|
||||
넘기도록 인자화했고, 마이그레이션이 v2 의 image↔pdf paddle 비대칭을 값 복사로
|
||||
보존한다. 도그푸딩(v0.28.0 release 빌드)에서 v2 config 로 first ingest 후
|
||||
(a) 동일 v2 config 재ingest(자동변환), (b) v3 로 `config migrate` 후 재ingest
|
||||
모두 `new=0 updated=0 unchanged=2` — 업그레이드 시 재색인이 발생하지 않음을
|
||||
실증했다.
|
||||
|
||||
## Upgrade 절차
|
||||
|
||||
1. 아무것도 안 해도 된다 — 옛 `config.toml` 은 그대로 로드된다(자동 변환,
|
||||
디스크 미변경, 일회성 warn 으로 안내).
|
||||
2. 파일을 새 레이아웃으로 정리하려면: `kebab config migrate` (자동 `.bak` 백업,
|
||||
`--dry-run` 으로 미리보기). `kebab doctor` 가 갱신 필요 시 안내한다.
|
||||
3. `kebab init` 으로 새로 만드는 config 는 v3 레이아웃 + per-option 주석 포함.
|
||||
|
||||
검색·RAG 결과와 색인은 변하지 않는다.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Plan: ingest 설정 변경 자동 재색인 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-ocr-toggle-invalidation-spec.md`. 브랜치 `fix/ingest-config-invalidation`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, **테스트 `-j 8`**(절대 `-j 1` 금지), cli 통합테스트용 `target` 심링크 후 정리.
|
||||
|
||||
## Task 1 — ingest_config_signature 헬퍼 (kebab-app)
|
||||
- `fn ingest_config_signature(config: &Config, media: &MediaType) -> String` 추가.
|
||||
- 공통: `[chunking]` target_tokens, overlap_tokens, respect_markdown_headings, chunker_version.
|
||||
- image: + image.ocr.enabled (+model if enabled) + image.caption.enabled (+prompt_template_version if enabled).
|
||||
- pdf: + pdf.ocr.enabled (+model, always_on if enabled).
|
||||
- code: + ingest.code.{skip_generated_header, max_file_bytes, max_file_lines, extra_skip_globs(join), ast_chunk_max_lines, fallback_lines_per_chunk, fallback_lines_overlap}.
|
||||
- markdown: 공통만.
|
||||
- 결정적(필드 순서 고정). 단위테스트: 같은 config→같은 서명, 관련 필드 변경→서명 변경, 무관 필드(search 등)→불변.
|
||||
|
||||
## Task 2 — 4개 ingest 경로에 composite parser_version 적용 (kebab-app/lib.rs)
|
||||
- md(~351), image(~1532), pdf(~2109), code 경로: `*_parser_version` = `ParserVersion(format!("{base}|{}", ingest_config_signature(config, media)))` (base = 각 extractor PARSER_VERSION).
|
||||
- 이 composite 를 (1) `try_skip_unchanged` 의 `current_parser_version` 으로 전달, (2) **persist 전 `canonical.parser_version` override** 로 저장. 두 곳 동일 보장.
|
||||
- doc_id 파생은 손대지 않음(workspace_path 조회).
|
||||
- markdown/code/image/pdf 각 경로에서 동일 패턴 적용 — 누락 없게.
|
||||
|
||||
## Task 3 — 테스트
|
||||
- image.ocr off→on, caption off→on: 재색인(skip 아님). off→off / 동일 설정: skip 유지.
|
||||
- pdf.ocr off→on: 재색인. 동일: skip.
|
||||
- chunking target_tokens 변경: 전 타입 재색인. 무변경: skip.
|
||||
- ingest.code 변경: 코드 자산만 재색인.
|
||||
- **search/rag/ui 변경: 재색인 0** (회귀 가드).
|
||||
- 동일 config 재실행: 전 자산 skip (불필요 재색인 0).
|
||||
- 기존 skip 테스트(markdown unchanged 등) 회귀 0.
|
||||
|
||||
## Task 4 — 검증 + 문서
|
||||
- `cargo clippy --workspace --all-targets -j 8 -- -D warnings` 0.
|
||||
- `cargo test -p kebab-app -p kebab-parse-image -p kebab-parse-pdf -p kebab-parse-code -p kebab-chunk -j 8` 통과(touched 크레이트 타깃; 전체 -j1 금지).
|
||||
- 스모크: 이미지 ocr off 색인 → config ocr on → `kebab ingest`(force 없이) → 그 이미지 재색인 확인.
|
||||
- tasks/HOTFIXES dated entry(일반화 + 업그레이드 1회 재색인 안내), Cargo.toml version **0.26.1 → 0.26.2**(+Cargo.lock), HANDOFF 1줄. README/wire 변화 없음.
|
||||
- 결과 요약 `/tmp/cfginval-result.md`(게이트 + 스모크 캡처).
|
||||
|
||||
## 리뷰 루프
|
||||
완료 → 리더 clippy/타깃테스트(-j8) 독립 재확인 + 토글 스모크 → `gitea-pr`(title `fix(ingest): ingest 설정 변경 시 영향 자산 자동 재색인`) → 리뷰 루프 → 사용자 머지.
|
||||
1096
docs/superpowers/plans/2026-06-04-config-schema-reorg.md
Normal file
1096
docs/superpowers/plans/2026-06-04-config-schema-reorg.md
Normal file
File diff suppressed because it is too large
Load Diff
89
docs/superpowers/plans/2026-06-04-rust-native-ocr-plan.md
Normal file
89
docs/superpowers/plans/2026-06-04-rust-native-ocr-plan.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Plan: Rust 네이티브 OCR 엔진 (PP-OCRv5 ONNX) 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-04-rust-native-ocr-spec.md`. 브랜치 `feat/rust-native-ocr`.
|
||||
빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, 테스트 **`-j 8`**(절대 `-j 1` 금지), touched 크레이트 위주(`-p kebab-parse-image -p kebab-app -p kebab-config`).
|
||||
참조 구현: `oar-ocr`(Apache-2.0) 소스 + Python PaddleOCR + 검증된 PoC `/build/cache/ocr-bench/{rust-poc,onnx,rc9-spike}/`(변환 ONNX + rc.9 동작 확인).
|
||||
|
||||
## Task 0a — 레퍼런스 골든 하네스 (C1 — 최우선 선행, executor 차단 제거)
|
||||
**T3/T5 골든은 oar-ocr 로 못 만든다**(중간 텐서 미노출, PoC 는 최종텍스트만). 먼저 Python `onnxruntime` 직접(oar-ocr X)으로 변환 모델을 돌려 fixture 별 중간 산출을 골든으로 덤프:
|
||||
- 입력: `/build/dogfood/corpus/images/synthetic-ocr-bench/` fixtures + 변환 ONNX(`/build/cache/ocr-bench/onnx/`).
|
||||
- 덤프(JSON/npy, repo `crates/kebab-parse-image/tests/golden/`): (a) det 확률맵 슬라이스, (b) threshold 후 박스 폴리곤, (c) **rec 원시 logits `[T,C]`**, (d) 디코드 문자열, (e) 전처리 텐서 일부값.
|
||||
- **M2 해결**: 알려진 텍스트라인 crop 의 logits + argmax 로 **blank 인덱스 + dict 11,945→클래스 11,947 매핑(+2 정체)을 경험적으로 도출**해 plan/주석에 사실로 기록(추정 금지). 경계문자(dict 첫/끝) 포함 골든.
|
||||
- 도구: 기존 venv `/build/cache/ocr-bench/venv`(onnxruntime 직접 설치) 또는 paddleocr API 의 raw 단계. 하네스 스크립트는 `/build/cache/ocr-bench/` 에 보관(런타임 의존 아님, 골든 생성 전용).
|
||||
- 수용: 각 fixture 골든 파일 생성 + blank 인덱스 문서화. 이후 T3~T5 가 이 골든에 핀.
|
||||
|
||||
## Task 0 — 모델 번들 (결정 C-1: include_bytes, release feature 게이트)
|
||||
- 변환 ONNX(이미 존재: `/build/cache/ocr-bench/onnx/{ppocrv5_mobile_det.onnx, korean_ppocrv5_mobile_rec.onnx, korean_dict.txt}`)를 repo `crates/kebab-parse-image/assets/paddleocr-onnx/` 에 배치(+NOTICE, Apache-2.0).
|
||||
- `bundled-ocr-models` cargo feature: on 이면 `include_bytes!` 로 임베드, off(dev 기본)면 config override 경로 필수. release 빌드는 feature on.
|
||||
- 대안 C-2/C-3 는 빌드/링크 부담 측정 후 폴백(spec §모델 배포). 17MB 임베드의 dev 링크 영향 먼저 측정 — 과하면 C-2(repo 벤더 + OUT_DIR) 전환.
|
||||
- **assets 17MB 커밋 방식 결정(M4/packaging)**: git-LFS 권장(clone/`cargo package` 비대 회피). `.gitattributes` 에 `*.onnx filter=lfs`. NOTICE(Apache-2.0) 동반.
|
||||
- **테스트 모델 출처(M4)**: OCR 단위/e2e 테스트는 `bundled-ocr-models` feature 무관하게 `KEBAB_TEST_OCR_MODEL_DIR`(기본 `assets/paddleocr-onnx/`)에서 로드. 모델 없으면 `#[ignore]` 가 아니라 명확 skip+경고(CI 는 assets 존재 가정). dev 빌드 OCR 테스트가 모델 못 찾아 실패하는 모호함 제거.
|
||||
- 수용: feature on 빌드 임베드 확인, off 빌드 정상, 테스트가 assets 에서 모델 로드.
|
||||
|
||||
## Task 1 — 의존성 (kebab-parse-image/Cargo.toml)
|
||||
- `ort = { workspace = true, features = ["ndarray", "download-binaries"] }`(C1: 단독빌드 링크, nli 선례 주석). `ndarray = { workspace = true }`. `imageproc`(연결요소/윤곽).
|
||||
- `ort-sys` caret 으로 rc.12 끌려가지 않게 Cargo.lock 정합 확인(rc.9 고정). unclip 다각형 offset 은 **pure-Rust 직접 구현**(clipper2 C++ FFI 회피 — spec).
|
||||
- 수용: `cargo build -p kebab-parse-image -j 8` 링크 성공(onnxruntime), `cargo tree` 에 ort 단일 rc.9.
|
||||
|
||||
## Task 2 — OnnxPaddleOcr 골격 + 전처리 (kebab-parse-image)
|
||||
- **선행 사실 확인**: rc.9 `ort::Session` 이 `Send+Sync` 인지 먼저 확인(아니면 Mutex 래핑). 결과를 주석에 기록.
|
||||
- 신규 모듈 `paddle_onnx.rs`. `OcrEngine` 구현. **`engine_version`=생성 시 모델+dict blake3 1회 계산해 String 캐시**(m3: per-asset 재해시 금지 — `ingest_config_signature` 가 자산마다 호출). format 고정(후일 변경 시 mass 재색인 주의).
|
||||
- det/rec `ort::Session` 2개 1회 로드 후 보관. **max_pixels 자체 bounds 적용**(spec 의 ocr.rs MIN/MAX clamp 은 Ollama private — paddle 은 자기 clamp 명시).
|
||||
- 전처리: 디코드(image)→긴변 max_pixels 축소→BGR mean/std 정규화→`Array4<f32>`.
|
||||
- 수용: 단위테스트 — 알려진 이미지→입력텐서 일부 값 골든(T0a).
|
||||
|
||||
## Task 3 — det 후처리 (단계 단위, 골든벡터)
|
||||
- det Session 추론(`[1,1,H,W]` 확률맵, rc.9 `try_extract_tensor`→`ArrayViewD`) → threshold 0.3 이진화 → imageproc 연결요소/윤곽 → **min-area rotated-rect(rotating calipers 직접 구현)** → **unclip(pure-Rust 다각형 offset, ratio 1.5)** → 박스 Vec.
|
||||
- 수용: 합성 fixture 기대 박스 개수/대략 좌표 골든. min-area rect·unclip 각각 단위테스트.
|
||||
|
||||
## Task 4 — crop + rectify
|
||||
- 회전 박스 → perspective/affine warp 로 수평 정렬(oar-ocr 가 제공하던 부분 이식).
|
||||
- 수용: 회전 텍스트 fixture → 정렬 crop 골든.
|
||||
|
||||
## Task 5 — rec + CTC decode
|
||||
- crop→48×W 정규화→rec Session(`[1,T,C]`) → CTC greedy(argmax/timestep→연속중복 제거→blank 제거).
|
||||
- **blank 인덱스 + 11,945→11,947 매핑은 T0a 하네스에서 도출한 사실을 사용**(추정 금지). bounds-check(dict 길이≠클래스 시 생성 에러).
|
||||
- 수용: T0a 골든 logit→문자열 일치(blank/중복/**경계문자 dict 첫·끝** 포함).
|
||||
|
||||
## Task 6 — 조립 + OcrText
|
||||
- 박스 reading-order(상→하,좌→우) → `OcrText{joined, regions:[OcrRegion{bbox,text,confidence}], engine, engine_version}`. per-region 실제 confidence(Ollama 상수1.0 대비 값 변화 — release note).
|
||||
- 수용: e2e — 합성 한/영 fixture **CER ≤ 0.05**, bbox>0. PoC 0.976 baseline 대비 회귀 없음.
|
||||
- **CER 게이트 실패 시 폴백 사다리(M3)**: ① T0a 단계 골든과 diff 해 어느 단계 divergence 인지 국소화 → ② det postproc(unclip/min-area rect)가 원인이면 **oar-ocr 의 해당 함수를 verbatim 이식**(Apache-2.0, NOTICE+파일별 출처 표기 — 코드 파생물) → ③ time-box(예 반나절) 초과 시 리더 escalate. 손수 재유도에 매몰 금지.
|
||||
|
||||
## Task 7 — config (kebab-config)
|
||||
- `OcrCfg`: `engine` 값에 "paddle-onnx" 문서화(기본 "ollama-vision" 유지). 신규 override `det_model`/`rec_model`/`dict`(Option), `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000). `KEBAB_IMAGE_OCR_*` env. serde default(forward-compat) + init 템플릿 노출.
|
||||
- 수용: override 미지정→번들 모델, 지정→그 경로 사용 테스트. config migrate(#198) 무수정 로드 회귀.
|
||||
|
||||
## Task 8 — 엔진 팩토리 (kebab-app/lib.rs) — **4개 site 전부(M1)**
|
||||
구체타입 `OllamaVisionOcr` 가 박힌 곳이 4군데 — 누락 시 타입에러로 막힘:
|
||||
- `:360` image 엔진 생성 → `Box<dyn OcrEngine>` 팩토리(`match engine`: ollama-vision|paddle-onnx|err).
|
||||
- `:379` pdf 엔진 생성 → 동일 팩토리.
|
||||
- `:839` `ImagePipeline.ocr_engine` 필드 → `Option<&dyn OcrEngine>`.
|
||||
- `:1113`, `:2096` `pdf_ocr_engine: Option<&OllamaVisionOcr>` 함수 시그니처 2곳 → `Option<&dyn OcrEngine>`.
|
||||
- `apply_ocr_to_pdf_pages`(`pdf_ocr_apply.rs:93`)는 이미 `&dyn OcrEngine` — 스레딩만 변경, 헬퍼 불변. `--config` facade 스레딩(`OnnxPaddleOcr::new(cfg,…)`).
|
||||
- 수용: 팩토리 단위테스트(선택/미지값 에러). **ollama-vision 경로 출력 동일** 회귀 테스트(구체→dyn 전환 무영향).
|
||||
|
||||
## Task 9 — 서명 cascade (C3, kebab-app)
|
||||
- `ingest_config_signature` image/pdf 브랜치 `|ocr:1:{model}` → `|ocr:1:{engine}:{engine_version}`(engine + 모델/dict blake3).
|
||||
- 수용: (a)ollama↔paddle 동일model→서명다름 (b)engine_version 다름→다름 (c)search 등 무관→불변. → 엔진/모델 변경 시 v0.26.2 자동 재색인.
|
||||
|
||||
## Task 10 — 에러 매트릭스 (spec §에러 처리)
|
||||
- 다운로드/blake3 실패→fail-fast, 디코드불가→skip+provenance, det 0박스→`OcrText{"",[]}` 성공, rec 빈→박스skip, 박스폭증→max_boxes 절단+로그, dict 불일치→생성에러.
|
||||
- 수용: 각 케이스 단위/통합 테스트.
|
||||
|
||||
## Task 11 — 검증 게이트
|
||||
- `cargo clippy --workspace --all-targets -j 8 -- -D warnings` 0.
|
||||
- `cargo test -p kebab-parse-image -p kebab-app -p kebab-config -j 8` 통과(+ `-p kebab-parse-image` 단독 링크 확인).
|
||||
- 스모크: `engine="paddle-onnx"` 이미지 ingest→FTS5 hit, 큰 페이지 CPU <5초.
|
||||
|
||||
## Task 12 — 문서 + 버전 + 도그푸딩
|
||||
- README(Configuration: `image.ocr.engine`+모델 번들), docs/SMOKE(config 예시), HANDOFF 1줄, docs/ARCHITECTURE(OCR 백엔드/그래프), HOTFIXES dated entry.
|
||||
- Cargo.toml workspace version **minor bump**(+Cargo.lock). release notes(엔진 추가/per-region confidence/오프라인).
|
||||
- 도그푸딩: 사용자 실제 이미지·책 스캔 정확도·속도 → HOTFIXES + release notes evidence.
|
||||
- 결과 요약 `/tmp/rust-ocr-result.md`(게이트 + 스모크 + 도그푸딩 캡처).
|
||||
|
||||
## 리뷰 루프
|
||||
완료 → 리더 clippy/타깃테스트(-j8) 독립 재확인 + paddle-onnx 스모크 → `gitea-pr`(title `feat(ocr): PP-OCRv5 ONNX Rust 네이티브 OCR 엔진`) → 리뷰 루프 → 사용자 머지. 모델 ONNX 는 release feature/asset 로 동반.
|
||||
|
||||
## 단계 의존
|
||||
**T0a(레퍼런스 골든+blank 도출) 최우선 선행** → T0(번들),T1(deps) → T2→T3→T4→T5→T6(파이프라인 순차, 각 T0a 골든에 핀) ∥ T7(config) → T8(팩토리 4site)→T9(서명)→T10(에러) → T11 게이트 → T12 문서. T3~T5 가 핵심 난도(직접 이식), T0a 골든+T6 폴백사다리로 회귀·매몰 차단. T8 의 정확한 라인(:1113/:2096 등)은 구현 시점 grep 으로 재확인(코드 이동 가능).
|
||||
@@ -266,3 +266,6 @@ config load 체크 직후 `config_migration` 체크 1개 추가:
|
||||
- kickoff 인계 문서와의 차이: kickoff §4.2 는 "버전별 변환 함수 체인"만 제안했으나,
|
||||
kebab 의 serde-default 특성상 additive 변경은 step 으로 표현하기 부적절(버전 무관) →
|
||||
**reconciliation 을 1급 메커니즘으로 승격**하고 step 은 non-additive 전용으로 한정.
|
||||
- 2026-06-04 v3 재편(첫 non-additive rename)에서 `step_2_to_3`(미디어 테이블
|
||||
`[ingest.*]` relocation) + `Config::from_file` load 시 메모리 자동변환 추가 —
|
||||
`docs/superpowers/specs/2026-06-04-config-schema-reorg-design.md`.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Spec: ingest 출력에 영향 주는 모든 설정 변경 시 자동 재색인 (skip 무효화 일반화)
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: bug fix (patch)
|
||||
**근거**: `[image.ocr]`/`[image.caption]` 를 off→색인→on 으로 바꿔도 증분 skip 이 이미지를 "Unchanged" 로 건너뛴다. 더 일반적으로, `try_skip_unchanged` 가 자산 내용(blake3)+`parser_version`+`chunker_version`+`embedding_version` 만 비교하는데, **ingest 산출물을 바꾸는 다른 설정들**(청킹 파라미터, OCR/caption, pdf.ocr, 코드 ingest 옵션)이 이 셋 중 어디에도 반영되지 않아 변경해도 재색인이 안 된다. 사용자 요구: **OCR/caption 뿐 아니라 ingest 출력에 영향 주는 모든 설정**이 같은 방식으로 동작(변경→영향 자산 자동 재색인). 결과 포맷·인터페이스·새 플래그 변화 없음(내부 skip 판정 정정) → **patch**.
|
||||
|
||||
## 동작 사실 (코드 근거)
|
||||
- `try_skip_unchanged`(lib.rs:866)는 `get_document_by_workspace_path` 로 기존 doc 조회 후 `existing_doc.parser_version != current_parser_version`(line 959) 면 재색인(cascade). **조회는 workspace_path** 이므로 doc_id 파생과 무관 — 비교는 저장된 `parser_version` 필드 대 현재값.
|
||||
- 각 경로가 상수 parser_version 을 넘김: md `md-heading-v1`(351), image `image-meta-v1`(1532), pdf `pdf-text-v1`(2109), code 등. 청킹 파라미터(`target_tokens`/`overlap_tokens`/`respect_markdown_headings`)는 `chunker_version` 상수에 안 들어가 변경해도 재청킹 안 됨(동일 갭).
|
||||
|
||||
## 설계: per-asset-type "ingest config signature" 를 effective parser_version 에 폴딩
|
||||
|
||||
`try_skip_unchanged` 에 넘기는 `current_parser_version` 과 **persist 되는 doc 의 `parser_version` 필드**를, 그 자산 타입의 **ingest 산출물에 영향을 주는 설정 전체의 결정적 서명**을 포함한 composite 로 만든다. 두 값이 같은 함수에서 나오므로, 관련 설정이 바뀌면 다음 run 비교가 mismatch → **영향 받는 자산만** 자동 재색인. doc_id 는 path 조회라 기존대로(안정, orphan churn 회피).
|
||||
|
||||
### 어떤 설정이 어느 자산에 영향 (서명 구성)
|
||||
공통 헬퍼 `ingest_config_signature(config, media_type) -> String`. **ingest 산출물에 영향 주는 것만** 포함(아래 외 search/rag/nli/ui/logging/storage/workspace 는 **제외** — 바뀌어도 재색인 안 함):
|
||||
|
||||
- **공통(모든 타입)**: `[chunking]` target_tokens, overlap_tokens, respect_markdown_headings, chunker_version. (embedding model/dim 은 이미 `embedding_version` cascade 가 담당 — 서명에 중복 포함 불필요, 단 일관성 위해 포함해도 무방.)
|
||||
- **image**: + `[image.ocr]` enabled (+enabled 면 model), `[image.caption]` enabled (+enabled 면 prompt_template_version).
|
||||
- **pdf**: + `[pdf.ocr]` enabled (+enabled 면 model, always_on).
|
||||
- **code**: + `[ingest.code]` skip_generated_header, max_file_bytes, max_file_lines, extra_skip_globs, ast_chunk_max_lines, fallback_lines_per_chunk, fallback_lines_overlap.
|
||||
- **markdown**: 공통만.
|
||||
|
||||
서명 형식: 결정적 문자열 또는 그 blake3-12. 예 `image-meta-v1|chunk:500:80:true|ocr:1:qwen2.5vl:3b|cap:1:caption-v1`. off/미적용 항목은 안정적 표현(빈값)으로 — 동일 설정 재실행은 서명 동일 → **불필요 재색인 0**.
|
||||
|
||||
## 작업 (kebab-app)
|
||||
1. `ingest_config_signature(config, media_type)` 헬퍼 추가(위 매핑). 출력 결정적(필드 순서 고정, Vec 는 join).
|
||||
2. 각 ingest 경로에서 effective parser_version = `format!("{base}|{signature}")` 또는 base 를 서명으로 감싼 값으로:
|
||||
- md(351), image(1532), pdf(2109), code 경로의 `*_parser_version` 계산을 composite 로.
|
||||
- **persist 전 `canonical.parser_version` 을 동일 composite 로 override**(extractor 가 박은 상수 대신). skip-check 와 저장값이 같아야 함.
|
||||
3. doc_id: 변경 불필요(workspace_path 조회). composite 는 비교 필드에만.
|
||||
|
||||
## 동작 / 호환
|
||||
- ingest 영향 설정(청킹/OCR/caption/pdf.ocr/code) 변경 또는 모델·prompt 변경 → effective parser_version 변화 → **영향 자산만** `--force-reingest` 없이 자동 재색인(+UPSERT/purge). 비영향 설정(search/rag/ui/log) 변경 → 재색인 0.
|
||||
- **업그레이드 1회 효과**: 기존 doc 의 저장 parser_version(상수)이 새 composite 와 달라 → 업그레이드 후 첫 ingest 에서 전 자산 1회 재색인(현재 설정대로). 마크다운/코드도 1회 재청킹되나 embedding 은 V012 캐시 히트라 재임베딩 비용 작음. (HOTFIXES/release notes 에 1회 재색인 명시.)
|
||||
- `--force-reingest` 는 전체 강제용으로 그대로 유지.
|
||||
|
||||
## 검증 기준
|
||||
- clippy 0. `cargo test -p kebab-app -p kebab-parse-image -p kebab-parse-pdf -p kebab-parse-code -p kebab-chunk -j 8` 통과 (**전체 워크스페이스 `-j 1` 금지 — `-j 8`**).
|
||||
- 신규 테스트(자산 타입별):
|
||||
- image.ocr off→on / caption off→on → 해당 이미지 재색인(skip 아님). off→off, on→on(동일) → skip 유지.
|
||||
- pdf.ocr off→on → PDF 재색인. 동일 설정 → skip.
|
||||
- chunking target_tokens 변경 → md/code/image/pdf 전부 재색인. 변경 없으면 skip.
|
||||
- ingest.code 옵션 변경 → 코드 자산 재색인, 이미지/md 는 영향 받되 **공통(chunking) 변경 아니면 코드만** (code 전용 설정은 code 서명에만).
|
||||
- search/rag/ui 설정 변경 → 재색인 0 (회귀 가드, 중요).
|
||||
- 동일 config 재실행 → 전 자산 skip(불필요 재색인 0) — 회귀 가드.
|
||||
- 스모크: 이미지 ocr off 색인 → config ocr on → `kebab ingest`(force 없이) → 그 이미지만 재색인 확인.
|
||||
|
||||
## 비범위
|
||||
- 새 config 키/CLI 플래그/wire(없음).
|
||||
- 서명에 max_pixels/languages/timeout 같은 *런타임 비-산출* 파라미터는 **제외**(산출물 불변 → 과도 무효화 회피). 포함 기준 = "그 값이 바뀌면 색인되는 chunk/embedding 내용이 달라지는가".
|
||||
- search/rag/nli/ui/logging/storage/workspace 설정(ingest 산출 무관) 제외.
|
||||
|
||||
## 문서/버전
|
||||
- tasks/HOTFIXES dated entry(일반화 + 1회 재색인 안내). Cargo.toml **patch bump (0.26.1 → 0.26.2)**(+Cargo.lock). README/wire 변화 없음. HANDOFF 1줄(선택).
|
||||
297
docs/superpowers/specs/2026-06-04-config-schema-reorg-design.md
Normal file
297
docs/superpowers/specs/2026-06-04-config-schema-reorg-design.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# config 스키마 재편 (v2 → v3): 미디어별 `[ingest]` 통합 + per-option 주석
|
||||
|
||||
- 상태: 설계 확정 (brainstorming 완료)
|
||||
- 작성일: 2026-06-04
|
||||
- 선행: `docs/superpowers/specs/2026-05-31-config-migration-design.md` (마이그레이션 엔진), `#197`(엔진), `#198`(`kebab config migrate` surface)
|
||||
- 영향 crate: `kebab-config`(스키마+마이그레이션), `kebab-app`(call-site sweep + signature), `kebab-eval`(config_snapshot), `kebab-cli`(`config migrate`/`init` 출력)
|
||||
- contract_sections: design §6 (Config schema / XDG), §9 (versioning cascade — signature 불변 보장)
|
||||
|
||||
## 1. 동기
|
||||
|
||||
옵션이 누적되며 `config.toml`(13 섹션 / ~60 필드)이 다음 군더더기를 갖게 됨:
|
||||
|
||||
1. **OCR 중복·비대칭** — `[image.ocr]` 와 `[pdf.ocr]` 가 `enabled/engine/model/endpoint/languages/max_pixels/request_timeout_secs` 를 거의 그대로 중복. 게다가 paddle-onnx 모델 경로(`det_model`/`rec_model`/`dict`/`score_thresh`/`unclip_ratio`/`max_boxes`)는 `[image.ocr]` 에만 존재하고 PDF paddle 경로가 거기를 참조(`kebab-app/src/lib.rs:3102` `ocr_engine_version_for_sig` 가 `config.image.ocr` 를 읽음) — "pdf 설정인데 image 밑을 봐야 하는" 숨은 비대칭.
|
||||
2. **미디어별 설정 산재** — 이미지 `[image]`, PDF `[pdf]`, 코드 `[ingest.code]`, 청킹 `[chunking]`. "형식 X 설정이 어디 있나"의 규칙이 없음.
|
||||
3. **`endpoint` 4중복** — `models.llm`/`models.embedding`/`image.ocr`/`pdf.ocr`. "비우면 `models.llm.endpoint` fallback" 규칙이 코드에만 있고 파일엔 안 보임. (단, **컴포넌트별 endpoint 는 실사용 중** — embedding 로컬 + llm 원격 — 이므로 통합 금지.)
|
||||
4. **`request_timeout_secs` 3중복** + 각각 "`0` 은 비활성화 아님" 함정.
|
||||
5. **`kebab init` 이 60+ 필드 일괄 방출** — 실제 사용자가 만지는 건 `workspace.root`/endpoint/모델명 정도.
|
||||
6. 사용자 실파일에서 추가 관찰: `score_gate = 0.30000001192092896`(f32→f64 직렬화 찌꺼기), `engine="paddle-onnx"` 인데 `model="gemma4:e4b"` 가 남는 죽은 필드.
|
||||
|
||||
## 2. 목표 / 비목표
|
||||
|
||||
**목표**
|
||||
|
||||
- 미디어 형식 설정을 `[ingest.*]` 한 우산 아래로 일관 배치 (향후 새 형식 = `[ingest.<형식>]` 한 곳 추가).
|
||||
- OCR 비대칭 제거: image·pdf 가 **각자 OCR 전체(paddle 경로 포함)를 독립 보유**(완전 대칭).
|
||||
- **무손실 변환**: 기존 v2 파일의 모든 값·주석·순서·사용자 대안 주석 줄을 보존.
|
||||
- **per-option 주석**: 각 키 옆 한 줄 설명을 `kebab init` 출력과 신규 추가 키에 부착.
|
||||
- 업그레이드 시 **불필요한 재색인 0** (parser_version signature 불변).
|
||||
- env override 이름 **무파손**.
|
||||
|
||||
**비목표 (YAGNI)**
|
||||
|
||||
- config 값 의미 검증(범위 체크 등) — 별개.
|
||||
- 다운그레이드(v3→v2).
|
||||
- 노브 숨기기/축소 — 명시적으로 제외(사용자가 "온전한 변환" 선택). 전 옵션을 잘 문서화한 완전체 유지.
|
||||
- endpoint 통합 — 컴포넌트별 override 유지(실사용).
|
||||
- **load 시 파일 자동 쓰기** — 여전히 비목표(2026-05-31 spec 계승). 단 §5.3 의 *메모리 내* 변환은 쓰기가 아니므로 별개로 허용.
|
||||
|
||||
## 3. 새 스키마 (v3)
|
||||
|
||||
per-option 주석을 부착한 `kebab init` 출력 형태(값은 기본값):
|
||||
|
||||
```toml
|
||||
# kebab config — `~/.config/kebab/config.toml`.
|
||||
# (헤더: workspace.root 경로 규칙 / 지원 형식 / KEBAB_* override — 기존 헤더 계승)
|
||||
schema_version = 3
|
||||
|
||||
# 색인 대상 워크스페이스.
|
||||
[workspace]
|
||||
root = "~/KnowledgeBase" # 색인 루트. 절대/~/${VAR}/상대(=이 파일 기준).
|
||||
exclude = [".git/**", "node_modules/**", ".obsidian/**"] # denylist glob.
|
||||
|
||||
# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).
|
||||
[storage]
|
||||
data_dir = "${XDG_DATA_HOME:-~/.local/share}/kebab" # 모든 산출물 루트.
|
||||
sqlite = "{data_dir}/kebab.sqlite" # 메타·FTS5 DB.
|
||||
vector_dir = "{data_dir}/lancedb" # 임베딩 벡터 스토어.
|
||||
asset_dir = "{data_dir}/assets" # 원본 사본(_external 등).
|
||||
artifact_dir = "{data_dir}/artifacts"
|
||||
model_dir = "{data_dir}/models" # fastembed/candle/nli 모델 캐시.
|
||||
runs_dir = "{data_dir}/runs" # eval run 산출.
|
||||
copy_threshold_mb = 100 # 이 크기 초과 파일은 사본 대신 참조.
|
||||
|
||||
# 다국어 sentence embedding. dim 불일치 시 검색 0건.
|
||||
[models.embedding]
|
||||
provider = "fastembed" # fastembed | candle | ollama | none.
|
||||
model = "multilingual-e5-large"
|
||||
version = "v1" # 모델 정체성 일부(캐시 키). 모델 바꾸면 함께 갱신.
|
||||
dimensions = 1024 # 모델 출력 차원. 틀리면 검색 0건.
|
||||
batch_size = 64
|
||||
num_threads = 0 # candle 전용 CPU 스레드 cap(0=auto). NUMA 회피 레버.
|
||||
# endpoint = "..." # ollama provider 시 HTTP. 비우면 models.llm.endpoint fallback.
|
||||
|
||||
# Ollama host:port + 모델.
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
model = "gemma4:e4b"
|
||||
context_tokens = 32768
|
||||
endpoint = "http://127.0.0.1:11434"
|
||||
temperature = 0.0
|
||||
seed = 0
|
||||
request_timeout_secs = 300 # 단일 HTTP 상한. 0=즉시실패(비활성화 아님). 대형모델 CPU면 ↑.
|
||||
|
||||
# NLI(groundedness) 모델.
|
||||
[models.nli]
|
||||
model = "Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
|
||||
provider = "onnx"
|
||||
|
||||
# 색인 공통(병렬도 + 파일시스템 watch). ← 기존 [indexing]
|
||||
[ingest]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
# 청크 크기·오버랩·heading 존중 (markdown/pdf/code/image 모든 형식 공통). ← 기존 [chunking]
|
||||
[ingest.chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
# code ingest skip 정책(.gitignore 자동 honor).
|
||||
[ingest.code]
|
||||
skip_generated_header = true
|
||||
max_file_bytes = 262144
|
||||
max_file_lines = 5000
|
||||
extra_skip_globs = []
|
||||
ast_chunk_max_lines = 200
|
||||
fallback_lines_per_chunk = 80
|
||||
fallback_lines_overlap = 20
|
||||
|
||||
# 이미지 OCR(기본 off, asset 당 비용). ← 기존 [image.ocr]
|
||||
[ingest.image.ocr]
|
||||
enabled = false
|
||||
engine = "ollama-vision" # ollama-vision | paddle-onnx.
|
||||
model = "gemma4:e4b" # ollama-vision 전용. paddle-onnx 는 번들 모델 사용(이 값 무시).
|
||||
languages = ["eng", "kor"]
|
||||
max_pixels = 1600
|
||||
request_timeout_secs = 300 # 0=즉시실패(비활성화 아님).
|
||||
# --- paddle-onnx 전용(engine=paddle-onnx 일 때만) ---
|
||||
# det_model = "..." # 비우면 번들 ppocrv5_mobile_det.onnx.
|
||||
# rec_model = "..." # 비우면 번들 korean rec.
|
||||
# dict = "..." # 비우면 번들 korean_dict.txt.
|
||||
score_thresh = 0.3 # DBNet box 점수 하한.
|
||||
unclip_ratio = 1.5 # box 패딩 비율.
|
||||
max_boxes = 1000 # 이미지당 box cap(runaway guard).
|
||||
|
||||
# 이미지 캡션(기본 off). ← 기존 [image.caption]
|
||||
[ingest.image.caption]
|
||||
enabled = false
|
||||
max_pixels = 768
|
||||
prompt_template_version = "caption-v1"
|
||||
|
||||
# scanned PDF page-단위 OCR(기본 off, page 당 비용). ← 기존 [pdf.ocr]
|
||||
[ingest.pdf.ocr]
|
||||
enabled = false
|
||||
always_on = false # true=모든 page vision 호출(vector PDF dual-text).
|
||||
engine = "ollama-vision" # ollama-vision | paddle-onnx.
|
||||
model = "qwen2.5vl:3b" # ollama-vision 전용. paddle-onnx 는 번들 모델 사용.
|
||||
languages = ["eng", "kor"]
|
||||
max_pixels = 2048
|
||||
request_timeout_secs = 180 # 0=즉시실패(비활성화 아님).
|
||||
valid_ratio_threshold = 0.5 # 유효문자 비율 < 이면 scanned 로 판정→OCR fallback.
|
||||
min_char_count = 20 # page 문자수 < 이면 auto-scanned.
|
||||
lang_hint = "kor" # 단일 page lang hint(비우면 없음).
|
||||
# --- paddle-onnx 전용(대칭 신규) ---
|
||||
# det_model / rec_model / dict = "..." # 비우면 번들.
|
||||
score_thresh = 0.3
|
||||
unclip_ratio = 1.5
|
||||
max_boxes = 1000
|
||||
|
||||
# 검색 기본 k·stale 기준·fusion.
|
||||
[search]
|
||||
default_k = 10
|
||||
hybrid_fusion = "rrf"
|
||||
rrf_k = 60
|
||||
snippet_chars = 220
|
||||
cache_capacity = 256
|
||||
stale_threshold_days = 30
|
||||
|
||||
# 답변 생성: prompt 템플릿·score gate·multi-hop·NLI.
|
||||
[rag]
|
||||
prompt_template_version = "rag-v3"
|
||||
score_gate = 0.3 # serialize_with 헬퍼로 직렬화 깔끔(기존 f32 찌꺼기 제거).
|
||||
explain_default = false
|
||||
max_context_tokens = 8000
|
||||
multi_hop_max_depth = 3
|
||||
multi_hop_max_sub_queries_per_iter = 5
|
||||
multi_hop_max_pool_chunks = 15
|
||||
nli_threshold = 0.0 # 0=NLI 게이트 off.
|
||||
|
||||
# TUI 팔레트.
|
||||
[ui]
|
||||
theme = "dark"
|
||||
|
||||
# ingest 로그(기본 on).
|
||||
[logging]
|
||||
ingest_log_enabled = true
|
||||
ingest_log_dir = "{state_dir}/logs"
|
||||
keep_recent_runs = 100
|
||||
retention_days = 30
|
||||
```
|
||||
|
||||
## 4. 필드 매핑 (v2 → v3)
|
||||
|
||||
| v2 위치 | v3 위치 | 비고 |
|
||||
|---------|---------|------|
|
||||
| `[workspace]` `[storage]` `[search]` `[rag]` `[ui]` `[logging]` `[models.*]` | 동일 | 변경 없음 |
|
||||
| `[indexing].*` (3키) | `[ingest].*` (bare 키) | `IndexingCfg` 해체 → `IngestCfg` 스칼라 |
|
||||
| `[chunking]` | `[ingest.chunking]` | 이름 의도적으로 `markdown` 아님(전 형식 공통) |
|
||||
| `[ingest.code]` | `[ingest.code]` | 이미 nested — 무이동 |
|
||||
| `[image.ocr]` | `[ingest.image.ocr]` | 키 동일 |
|
||||
| `[image.caption]` | `[ingest.image.caption]` | 키 동일 |
|
||||
| `[pdf.ocr]` | `[ingest.pdf.ocr]` | 키 동일 + **paddle 6키 대칭 신규** (`det_model`/`rec_model`/`dict`/`score_thresh`/`unclip_ratio`/`max_boxes`) |
|
||||
|
||||
신규 키(pdf paddle 대칭)는 모두 `#[serde(default)]` + `Option`/기본값 → v2 파일에 없어도 무해.
|
||||
|
||||
## 5. Rust 구조 변경 (`kebab-config/src/lib.rs`)
|
||||
|
||||
### 5.1 구조체
|
||||
|
||||
```rust
|
||||
pub struct Config {
|
||||
pub schema_version: u32,
|
||||
pub workspace: WorkspaceCfg,
|
||||
pub storage: StorageCfg,
|
||||
pub models: ModelsCfg,
|
||||
pub ingest: IngestCfg, // ← indexing/chunking/image/pdf 흡수
|
||||
pub search: SearchCfg,
|
||||
pub rag: RagCfg,
|
||||
pub ui: UiCfg,
|
||||
pub logging: LoggingCfg,
|
||||
#[serde(skip)] source_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct IngestCfg {
|
||||
// ← 기존 IndexingCfg (스칼라 먼저: toml 직렬화는 스칼라가 테이블보다 앞)
|
||||
pub max_parallel_extractors: u32,
|
||||
pub max_parallel_embeddings: u32,
|
||||
pub watch_filesystem: bool,
|
||||
// 하위 테이블
|
||||
pub chunking: ChunkingCfg,
|
||||
pub code: IngestCodeCfg,
|
||||
pub image: ImageCfg, // { ocr: OcrCfg, caption: CaptionCfg }
|
||||
pub pdf: PdfCfg, // { ocr: PdfOcrCfg }
|
||||
}
|
||||
```
|
||||
|
||||
- `IndexingCfg` 구조체 삭제(스칼라로 흡수). `ChunkingCfg`/`ImageCfg`/`OcrCfg`/`CaptionCfg`/`PdfCfg`/`IngestCodeCfg` **내부 필드 불변**(부모 경로만 이동).
|
||||
- `PdfOcrCfg` 에 paddle 6키 대칭 추가.
|
||||
- 제거된 top-level 필드: `indexing`/`chunking`/`image`/`pdf`.
|
||||
- 스칼라-우선 필드 순서로 `defaults_are_serde_roundtrip_stable` 유지.
|
||||
|
||||
### 5.2 call-site sweep (~65곳, 7 src 파일)
|
||||
|
||||
기계적 치환: `config.chunking.X`→`config.ingest.chunking.X`, `config.image.ocr`→`config.ingest.image.ocr`, `config.pdf.ocr`→`config.ingest.pdf.ocr`, `config.indexing.X`→`config.ingest.X`. 대상: `kebab-app/src/{lib.rs,app.rs,schema.rs}`, `kebab-eval/src/runner.rs`. `kebab-parse-image` 는 leaf 구조체(`&OcrCfg` 등) 직접 수령 → 무영향(확인됨).
|
||||
|
||||
### 5.3 load 시 메모리 내 자동 변환 (정합성 필수)
|
||||
|
||||
v3 는 최초의 **non-additive rename** 이라, 미변환 v2 파일을 v3 struct 로 deserialize 하면 `[chunking]`/`[image]`/`[pdf]`/`[indexing]` 을 못 찾아 **사용자 설정이 조용히 기본값으로 유실**. (이전 마이그레이션은 전부 additive 라 serde default 로 load 호환됐음 — 이 가정이 v3 에서 처음 깨짐.)
|
||||
|
||||
→ `Config::from_file` 변경: 텍스트의 `schema_version < CURRENT` (또는 legacy 테이블 탐지) 시 `migrate::migrate_document(text)` 를 **메모리에서** 적용한 `new_text` 를 deserialize. **디스크 쓰기 없음**(파일 갱신은 여전히 `kebab config migrate` 전용 — 2026-05-31 spec 의 "자동 쓰기 비목표" 계승; 메모리 변환은 쓰기가 아니므로 무충돌). 1회성 `tracing::warn!`: "config 가 schema vN 입니다 — 이번 실행은 메모리에서 v3 로 변환됨. 파일 갱신은 `kebab config migrate`."
|
||||
|
||||
- parse 실패 시 `migrate_document` 는 입력 그대로 반환 → 기존 `ConfigInvalid` 경로 유지.
|
||||
- `source_dir` stamp 는 변환 후 동일하게 `path.parent()`.
|
||||
|
||||
## 6. 마이그레이션 `step_2_to_3` (`kebab-config/src/migrate.rs`)
|
||||
|
||||
`run_steps` 에 `if from < 3 { step_2_to_3(doc, changes) }` 추가. `step_2_to_3` 는 **테이블 relocation**(toml_edit, 값·키주석·순서 보존):
|
||||
|
||||
1. `[indexing]` 의 3키 → `[ingest]` bare 키로 이동. 원 `[indexing]` 제거.
|
||||
2. `[chunking]` 테이블 → `[ingest.chunking]` 로 이동(통째). 원 제거.
|
||||
3. `[image.ocr]`→`[ingest.image.ocr]`, `[image.caption]`→`[ingest.image.caption]`. 원 `[image]` 제거.
|
||||
4. `[pdf.ocr]`→`[ingest.pdf.ocr]`. 원 `[pdf]` 제거.
|
||||
5. **pdf paddle 동작 보존(중요)** — v2 는 pdf 가 paddle 일 때 `image.ocr` 의 paddle 값(`det_model`/`rec_model`/`dict`/`score_thresh`/`unclip_ratio`/`max_boxes`)을 빌려 썼다(§1 비대칭). 따라서 이동 직후 **`[image.ocr]` 의 이 6키 실제 값을 `[ingest.pdf.ocr]` 의 대칭 키로 복사**한다(사용자가 image paddle 을 튜닝한 경우까지 동작 동일 보장). 사용자가 둘 다 기본이면 복사값=기본값이라 무차. 복사는 사용자가 `[pdf.ocr]` 에 해당 키를 이미 명시한 경우엔 덮어쓰지 않음.
|
||||
6. 기존 `[ingest.code]` 는 그대로(이미 올바른 위치). 단 `[ingest]` 가 새로 bare 키를 받으므로 직렬화 순서 정합 확인.
|
||||
|
||||
이동은 **user item 의 decor(값 뒤 인라인 주석 + 사용자 대안 주석 줄)를 동반**해야 함 — toml_edit 에서 `Table::remove` 로 떼어낸 `Item` 을 새 부모에 `insert`. 멱등(이미 v3 형태면 no-op).
|
||||
|
||||
이동 후 기존 `reconcile(annotated_default, doc)` 가:
|
||||
- 빠진 키(특히 pdf paddle 대칭 6키) 를 주석과 함께 추가.
|
||||
- `schema_version` stamp → 3.
|
||||
|
||||
`CURRENT_SCHEMA_VERSION: u32 = 3` 으로 bump.
|
||||
|
||||
## 7. per-option 주석 인프라
|
||||
|
||||
- `key_comment(path: &str) -> Option<&'static str>` 신설 (`section_comment` 자매). dotted leaf 경로(`ingest.chunking.target_tokens` 등) → 한 줄.
|
||||
- `annotate_table` 확장: 스칼라 leaf 에도 `key_comment` 가 있으면 인라인/prefix 주석 부착.
|
||||
- **부착 범위**: `annotated_default_document`(=`kebab init` + reconcile 참조원) 의 모든 키. reconcile 가 **새로 추가하는** 키만 주석 동반(기존 사용자 키는 값 불가침 → 주석 미주입, 사용자 대안 주석 보존).
|
||||
- §3 의 모든 키 주석 텍스트를 `key_comment` 에 등재(구현 시 일괄).
|
||||
|
||||
## 8. 불변식 / 회귀 가드
|
||||
|
||||
1. **signature 불변** — `ingest_config_signature`(lib.rs:3129) 출력 문자열이 v2 바이너리와 **바이트 동일**. 값 기반이라 struct 경로 변경과 무관해야 함. `ocr_engine_version_for_sig` 가 읽는 paddle 경로 소스를 image signature 는 `config.ingest.image.ocr` 로, **pdf signature 는 `config.ingest.pdf.ocr` 의 신규 대칭 키**로 갱신. 동작 보존은 §6.5 의 값 복사(image paddle 값 → pdf 대칭 키)로 성립 — 마이그레이션된 파일에서 pdf 대칭 키 = v2 시절 image 값이므로 signature 동일. 골든 문자열 회귀 테스트 필수.
|
||||
2. **env 이름 보존** — `apply_env` whitelist 의 LHS(키 문자열) 전부 그대로, RHS(대입 대상)만 새 struct 경로. 신규 pdf paddle 키만 `KEBAB_PDF_OCR_{DET_MODEL,REC_MODEL,DICT,SCORE_THRESH,UNCLIP_RATIO,MAX_BOXES}` 추가. 기존 env 테스트 전부 green 유지.
|
||||
3. **무손실 골든** — 사용자 실제 v2 config(첨부본; `score_gate` 찌꺼기·주석 대안 줄 포함)를 fixture 로: `migrate_document` → (a) 모든 사용자 값 보존, (b) 사용자 주석/대안 줄 보존, (c) `[ingest.image.ocr]` 등 신 위치 존재, (d) 결과가 v3 `Config` 로 parse 되고 값이 원 의미와 동일, (e) 재실행 멱등.
|
||||
4. **load 자동변환** — v2 텍스트를 `Config::from_file` 로 읽으면(디스크 미변경) `config.ingest.chunking.target_tokens` 등이 사용자 값으로 채워짐(기본값 유실 없음) 테스트.
|
||||
5. **float 직렬화 정리** — `Config::defaults()` 직렬화에 `0.30000001192092896` 부재, `score_gate = 0.3`. 구현: f32 필드에 `#[serde(serialize_with = "ser_f32_clean")]`(f32 Display 의 shortest round-trip 을 f64 로 재파싱해 직렬화) — struct 타입·호출부 무변경, kebab-config 국소. 사용자 기존 파일의 찌꺼기 값은 toml_edit 보존(값 불가침)이라 그대로 — 재생성 시에만 정리됨(비목표 §2 정합).
|
||||
|
||||
## 9. 버전 / 문서 cascade
|
||||
|
||||
- **minor bump** (인터페이스 변경: config 섹션 rename + 신규 키). `Cargo.toml` workspace version.
|
||||
- **schema_version 2→3** (위).
|
||||
- **도그푸딩 필수**(CLAUDE.md Dogfood trigger: CLI/config surface) — `kebab config migrate` 를 실제 v2 파일(첨부본)에 돌려 무손실 + 자동변환 + 재색인 0 확인. evidence → HOTFIXES + release notes.
|
||||
- **문서 동기화(같은 PR)**: README Configuration 섹션 + `docs/SMOKE.md` config 예시 블록(새 레이아웃) + HOTFIXES dated entry + `2026-05-31-config-migration-design.md` 의 Risks/notes 에 v3 rename 교차링크.
|
||||
|
||||
## 10. 리스크
|
||||
|
||||
| 리스크 | 완화 |
|
||||
|--------|------|
|
||||
| 테이블 이동 시 주석 유실 | toml_edit `remove`→`insert` 로 `Item` 통째 이동, 골든 테스트(§8.3) |
|
||||
| signature 변동→전체 재색인 | 골든 문자열 회귀 테스트(§8.1), 값 포맷 보존 |
|
||||
| pdf paddle 대칭 추가가 기존 pdf paddle 동작 변경 | §6.5 마이그레이션이 image paddle 6키 실제 값을 pdf 대칭 키로 복사 → 동작·signature 동일(§8.1) |
|
||||
| call-site 누락 | 컴파일러가 강제(필드 제거→ 미수정 site 컴파일 에러), clippy gate |
|
||||
| 메모리 자동변환 매 load 비용 | toml_edit parse 1회/실행, 무시 가능 |
|
||||
```
|
||||
192
docs/superpowers/specs/2026-06-04-rust-native-ocr-spec.md
Normal file
192
docs/superpowers/specs/2026-06-04-rust-native-ocr-spec.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Spec: Rust 네이티브 OCR 엔진 (PP-OCRv5 ONNX, in-process)
|
||||
|
||||
**날짜**: 2026-06-04
|
||||
**유형**: feature (minor) — 신규 OCR 엔진 + config 키 + 동작 변화
|
||||
**상태**: draft (self-review 대기)
|
||||
**contract_sections**: design §6 (parse/extract), §8 (deps), §9 (versioning cascade)
|
||||
|
||||
## 동기
|
||||
|
||||
현재 이미지/PDF OCR 은 Ollama Vision LLM(`gemma4:e4b` 8B) 1콜(`crates/kebab-parse-image/src/ocr.rs`, `OllamaVisionOcr`). 사용자 실측 문제:
|
||||
|
||||
- 실제 이미지 한 장당 **~50초**(VLM 은 글자를 토큰 단위로 생성 → 조밀 페이지는 본질적으로 느림). 모델을 바꿔도(qwen2.5vl:3b GPU 20~28초) 사용자 허용치 미달.
|
||||
- 사용자 결정: **배치 ingest 용도 + Python 의존 불가 + Rust 내장**.
|
||||
|
||||
### 근거 벤치 (2026-06-04, `/build/dogfood/logs/2026-06-04-ocr-model-bench.md`)
|
||||
|
||||
| 방식 | 작은 이미지 | 초대형 1757×2644 | 정확도 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| gemma4:e4b 8B VLM (GPU) | 11초 | 43초 | 0.65~0.82 | 현재 |
|
||||
| qwen2.5vl:3b VLM (GPU) | 3.6초 | 20초 | 0.93 | 속도 미달 |
|
||||
| **PP-OCRv5 mobile ONNX, Rust (CPU)** | **0.05초** | **2.75초** | **0.976** | **PoC 검증됨** |
|
||||
|
||||
VLM 은 생성 병목으로 탈락. **검출+인식형 전용 엔진(PP-OCRv5)을 ONNX 로 Rust in-process 실행**이 속도·정확도·한국어·단일바이너리 모두 만족. PoC: `oar-ocr` 0.6.3 + `ort` 로 위 수치 확인(오류는 띄어쓰기뿐, 한국어 오인식 0). PoC 코드/모델: `/build/cache/ocr-bench/{rust-poc,onnx}/`.
|
||||
|
||||
## 핵심 설계 결정: oar-ocr 미채택, 핀 ort 위 직접 구현
|
||||
|
||||
PoC 는 `oar-ocr` 0.6.3 으로 검증했으나 **프로덕션 의존성으로는 쓰지 않는다**. 이유(load-bearing):
|
||||
|
||||
- kebab 은 `ort = "=2.0.0-rc.9"` 를 **의도적 핀**(workspace `Cargo.toml:195-204`): fastembed 4.9 의 ONNX Runtime+tokenizer 스택을 워크스페이스 단일 버전으로 유지. `ndarray = "0.16"` 도 동일.
|
||||
- `oar-ocr` 0.6.3 은 `ort 2.0.0-rc.12` + `ndarray 0.17` 요구. `ort` 는 `ort-sys` 가 onnxruntime 네이티브 라이브러리를 `links` 하므로 **두 버전 공존 불가** → oar-ocr 채택 시 ort/ndarray 를 bump 해야 하고, 이는 fastembed/kebab-nli/kebab-embed-candle 의 임베딩·NLI 스택을 흔든다(사용자 우선순위인 검색 품질 직결, [[search-quality-dogfood]]).
|
||||
|
||||
**→ PaddleOCR 의 전/후처리(검출 DBNet postproc + 인식 CTC decode)를 kebab 의 기존 핀 `ort`(rc.9) 위에 직접 구현.** oar-ocr(Apache-2.0) 소스 + Python PaddleOCR 을 레퍼런스로. 공유 ort 라 새 네이티브 의존성 0, 임베딩 스택 무영향.
|
||||
|
||||
### C2 검증 완료 (rc.9 스파이크, 2026-06-04)
|
||||
|
||||
PoC 는 oar-ocr 경유 ort **rc.12** 로 돌았으므로, 핀 **rc.9** 가 paddle2onnx 산출 모델을 실제 로드/추론하는지 별도 검증함(`/build/cache/ocr-bench/rc9-spike/`). 결과 **PASS**:
|
||||
- `ort = "=2.0.0-rc.9"` + `ort-sys = "=2.0.0-rc.9"`(caret 으로 rc.12 끌려가는 것 방지 — kebab Cargo.lock 과 동일) + `ndarray 0.16` + feature `["ndarray","download-binaries"]` 로 빌드/링크/onnxruntime 다운로드 성공.
|
||||
- det: 입력 `"x"` → 출력 `[1,1,640,640]`(DBNet 확률맵). rec: 출력 `[1,40,11947]`(timestep×클래스; dict 11,945 + blank/특수 = 11,947, CTC 정합 확인).
|
||||
- `try_extract_tensor::<f32>()` 는 rc.9 에서 `ArrayViewD<f32>` 반환(rc.12 의 `(shape,&[T])` 와 다름) — 구현 시 유의.
|
||||
- **함의**: 핀 ort 유지(ort/ndarray bump 불필요)로 임베딩 스택 무영향 확정. opset 호환 OK. 출력 형태가 후처리 설계(det threshold→박스 / rec CTC)와 일치.
|
||||
|
||||
### 추가 의존성
|
||||
|
||||
- `image`(이미 허용), `ndarray`(workspace `=0.16`), `ort`(workspace `=2.0.0-rc.9`, **features `["ndarray","download-binaries"]`**).
|
||||
- **download-binaries 필수**: `kebab-parse-image` 는 fastembed 빌드그래프에 없어, 단독 빌드(`cargo test -p kebab-parse-image`)시 onnxruntime 링크 위해 명시 필요. `kebab-nli/Cargo.toml:23` 의 동일 선례 주석 그대로 따름.
|
||||
- `ort-sys` 가 caret 으로 rc.12 로 끌려가지 않도록 workspace 핀과 Cargo.lock 정합 확인.
|
||||
- `imageproc` — det 확률맵 연결요소/윤곽 추출. **단 min-area rotated-rect 는 imageproc 미제공 → rotating-calipers 직접 구현**.
|
||||
- DBNet unclip(다각형 확장): **`clipper2` 는 C++ FFI 가능성 → single-binary/pure-Rust 위배 위험. 우선 pure-Rust 다각형 offset 직접 구현 또는 검증된 pure-Rust crate.** (plan 에서 clipper2 가 C++ 링크인지 확인 후 택일.)
|
||||
|
||||
## 파이프라인 (OnnxPaddleOcr)
|
||||
|
||||
`crates/kebab-parse-image/src/` 에 신규 모듈. `OcrEngine` trait(`ocr.rs:54`) 구현:
|
||||
|
||||
```rust
|
||||
pub trait OcrEngine: Send + Sync {
|
||||
fn engine_name(&self) -> &'static str; // "paddle-onnx"
|
||||
fn engine_version(&self) -> String; // "ppocrv5-mobile-kor-v1" (+model hash)
|
||||
fn recognize(&self, image_bytes: &[u8], lang_hint: Option<&Lang>) -> Result<OcrText>;
|
||||
}
|
||||
```
|
||||
|
||||
`recognize` 단계 (PoC 와 동일 알고리즘):
|
||||
|
||||
1. **디코드+다운스케일**: `image` 로 디코드 → 긴변 `max_pixels`(기본 1600) 로 축소(기존 `OcrCfg.max_pixels` 재사용, qwen 과 달리 PP-OCRv5 는 원본도 안전하나 속도 위해 유지).
|
||||
2. **검출(det)**: BGR 정규화 → det ONNX(`PP-OCRv5_mobile_det`) → 확률맵 → threshold(0.3) 이진화 → 윤곽(imageproc) → min-area rect → unclip(ratio 1.5) → 텍스트 박스 N개.
|
||||
3. **인식(rec)**: 각 박스 crop+회전보정 → 48×W 리사이즈/정규화 → rec ONNX(`korean_PP-OCRv5_mobile_rec`) → CTC greedy decode(dict 11,945자, blank 처리) → 텍스트+score.
|
||||
4. **조립**: 박스를 reading-order(상→하, 좌→우) 정렬 → `OcrText { joined, regions: Vec<OcrRegion{bbox,text,confidence}>, engine, engine_version }`. **Ollama 와 달리 per-line bbox/confidence 제공**(`OcrRegion` 풍부화).
|
||||
|
||||
배치: PoC 는 박스별 순차 rec. 성능 충분(초대형 2.75초)하나, rec 를 ort 배치 입력으로 묶으면 추가 향상 가능(plan 에서 측정 후 결정).
|
||||
|
||||
### 단계별 분해 (M1 — 각 단계 골든벡터 단위테스트)
|
||||
|
||||
후처리가 실제 난도. "쉽다"로 뭉뚱그리지 않고 **각 단계를 독립 테스트 가능 단위**로 쪼갠다. 각 단위는 oar-ocr/Python PaddleOCR 이 **같은 fixture** 에 내는 출력을 골든벡터로 박아 단계별 회귀(0.976 baseline 대비)를 잡는다:
|
||||
|
||||
1. **전처리**(resize/pad/normalize): det 입력 정규화(mean/std, /255). 골든: 알려진 이미지→텐서 일부 값.
|
||||
2. **det 후처리**: 확률맵(`[1,1,H,W]`)→threshold(0.3)→연결요소(imageproc)→**min-area rotated-rect(rotating calipers 직접 구현)**→**unclip(다각형 offset, ratio 1.5)**→박스. 골든: 합성 이미지의 기대 박스 개수/대략 좌표.
|
||||
3. **crop+rectify**: 회전 박스→perspective/affine warp 로 수평 정렬(oar-ocr 가 공짜 제공하던 부분; 직접 구현 필요). 골든: 회전 텍스트 fixture.
|
||||
4. **rec 전처리+추론**: crop→48×W 정규화→rec ONNX→`[1,T,C]` logits.
|
||||
5. **CTC greedy decode**: argmax per timestep→연속중복 제거→blank(인덱스 0 또는 dict 길이 위치, **PaddleOCR 규약 정확 매칭**) 제거→dict 인덱스→char. dict 길이(11,945) vs rec 출력 클래스(11,947) 정합 + **인덱스 bounds-check**(잘못된 dict 길이/빈 줄 방어). 골든: 알려진 logit→문자열.
|
||||
6. **box reading-order**: 상→하, 좌→우 정렬(가로쓰기 전제; 세로/회전 페이지는 비범위).
|
||||
|
||||
각 단계 divergence 를 end-to-end 가 아니라 단위에서 잡는다(M1 권고).
|
||||
|
||||
## Config
|
||||
|
||||
`OcrCfg`(`kebab-config/src/lib.rs:343`)에 `engine` 필드 **이미 존재**(기본 `"ollama-vision"`). 변경:
|
||||
|
||||
- `engine` 값에 `"paddle-onnx"` 추가(문서화). 기본값은 **당장 바꾸지 않음**(default 변경은 별도 결정 — 아래 "기본 엔진" 참조).
|
||||
- 신규(선택) 필드: `det_model` / `rec_model` / `dict` 경로 override(미지정 시 자동 다운로드 캐시 경로). `score_thresh`(기본 0.3), `unclip_ratio`(기본 1.5) 는 고급 튜닝용(기본값 고정, 노출 최소).
|
||||
- `pdf.ocr` 도 동일 `engine` 분기 적용(같은 trait).
|
||||
|
||||
### 모델 배포 — 결정 C: kebab 와 함께 번들 (HF 미사용, 사용자 확정 2026-06-04)
|
||||
|
||||
제3자(HF) 호스팅 의존 제거. 변환본(det 4.7MB + korean rec 13MB + dict ≈ **17MB**)을 kebab 자체에 번들. **구체 기법은 plan 에서 택1**(모두 HF/외부 네트워크 0):
|
||||
|
||||
- **C-1 바이너리 임베드(`include_bytes!`)**: 모델을 바이너리에 박음. 진정한 single-binary·완전 오프라인·재현성 100%. 비용: 릴리스 바이너리 +17MB, 그리고 dev/test 빌드마다 17MB 링크 부담 → **release feature(`bundled-ocr-models`) 게이트**로 dev 빌드 제외 가능. 로컬-first 철학 최적합.
|
||||
- **C-2 repo 벤더링**: `assets/paddleocr-onnx/`(git 또는 git-LFS) 에 두고 빌드 시 `OUT_DIR` 복사 또는 런타임 상대경로. 바이너리 비대 회피하나 배포 시 파일 동반 필요.
|
||||
- **C-3 gitea 릴리스 에셋 + 첫 실행 다운로드**: `gitea-release --asset` 로 첨부, 첫 실행 시 릴리스 URL 에서 `model_dir/paddleocr-onnx/` 로 받음. 바이너리 lean 하나 첫 실행 시 gitea 네트워크 필요(에어갭 불가) — 로컬-first 와 약간 상충.
|
||||
|
||||
**권장 = C-1(release feature 게이트)**: 오프라인·재현성·single-binary 가 kebab 정체성과 가장 정합. plan 에서 빌드/링크 영향 측정 후 확정.
|
||||
|
||||
- **무결성**: 임베드(C-1)면 빌드 시점 고정이라 별도 해시 불요(바이너리=정본). C-2/C-3 면 blake3 pin 필수.
|
||||
- **라이선스**: PP-OCRv5 가중치 Apache-2.0 — 재배포 가능. 번들에 NOTICE 동반.
|
||||
- **오프라인**: C-1 완전 오프라인. config override(`det_model`/`rec_model`/`dict`)로 로컬 모델 교체 항상 가능.
|
||||
|
||||
## 엔진 선택 (kebab-app 팩토리)
|
||||
|
||||
현재 `OllamaVisionOcr` 하드코딩(`kebab-app/src/lib.rs:360`(image), `379`(pdf)). 변경:
|
||||
|
||||
```rust
|
||||
let ocr_engine: Option<Box<dyn OcrEngine>> = if cfg.image.ocr.enabled {
|
||||
match cfg.image.ocr.engine.as_str() {
|
||||
"ollama-vision" => Some(Box::new(OllamaVisionOcr::new(cfg)?)),
|
||||
"paddle-onnx" => Some(Box::new(OnnxPaddleOcr::new(cfg)?)),
|
||||
other => bail!("unknown image.ocr.engine: {other}"),
|
||||
}
|
||||
} else { None };
|
||||
```
|
||||
|
||||
- `ImagePipeline.ocr_engine` 를 `Option<&'a dyn OcrEngine>` 로(현재 구체타입 `&OllamaVisionOcr`).
|
||||
- pdf 경로 동일. `apply_ocr`/`apply_ocr_to_pdf_pages` 는 이미 `&dyn OcrEngine` 받음 → 변경 불필요.
|
||||
- `OnnxPaddleOcr` 는 한 번 생성(모델 1회 로드) 후 ingest 전체에서 재사용(PoC 모델로드 58ms, 무시 가능).
|
||||
|
||||
## 버전/재색인 cascade
|
||||
|
||||
OCR 엔진 변경 시 **영향 자산 자동 재색인**되어야 함(v0.26.2 메커니즘). 현재 `ingest_config_signature`(`kebab-app/src/lib.rs:3036` 부근)의 image/pdf 브랜치는 `|ocr:1:{ocr.model}` 만 서명.
|
||||
|
||||
**C3 (필수, 권장 아님)**: paddle-onnx 브랜치에서 `model`("gemma4:e4b" 기본) 은 **미사용** — 실제 모델 정체성은 det/rec/dict + engine_version 에 있음. 따라서:
|
||||
- 서명을 `|ocr:1:{engine}:{engine_version}` 로(엔진 + 모델/dict 식별자). `engine_version()`(spec 의 model+dict blake3 해시 포함, 라인 47)을 **반드시** 서명에 사용.
|
||||
- 이유: ① `engine="ollama-vision"→"paddle-onnx"` 전환 시 model 이 기본값 그대로면 `{model}` 만으론 서명 불변 → **재색인 안 됨**(silent stale index, v0.26.2 가 없애려던 바로 그 버그). ② 모델 재변환/dict 수정 시 engine_version 변화로 재색인 트리거.
|
||||
- 단위테스트(필수): (a) `ollama-vision`↔`paddle-onnx` 동일 model → 서명 다름. (b) 동일 engine, engine_version 다름 → 서명 다름. (c) 무관 설정(search 등) → 서명 불변.
|
||||
|
||||
## 기본 엔진 (default) — 별도 결정
|
||||
|
||||
본 spec 은 `paddle-onnx` 를 **선택 가능**하게만 한다. kebab 의 `image.ocr.engine` **기본값을 `paddle-onnx` 로 바꿀지**는 후속 결정:
|
||||
- 바꾸면: 신규 사용자/기본 동작 변화 + 모델 다운로드 기본화. 강력하나 영향 큼.
|
||||
- v1 은 기본 `ollama-vision` 유지, opt-in `paddle-onnx`. 도그푸딩 후 기본 전환을 별 PR 로. (사용자 본인 config 는 즉시 `paddle-onnx`.)
|
||||
|
||||
## 에러 처리 (M3 — 명시 매트릭스)
|
||||
|
||||
배치 ingest 가 미지의 사용자 스캔을 돈다. 각 케이스 동작 확정:
|
||||
|
||||
| 케이스 | 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| 모델 다운로드 실패 | 엔진 생성 시 **fail-fast**(Ollama 와 동일, `lib.rs:360`) | 색인 시작 전 차단 |
|
||||
| blake3 불일치 | fail-fast + 사유 | 무결성 |
|
||||
| 디코드 불가 이미지 | **자산 skip + provenance 노트**(ingest 중단 X) | 기존 `apply_ocr` "skip vs surface" 계약(`ocr.rs:75`) |
|
||||
| det 0 박스(빈 이미지 등) | **성공, `OcrText{joined:"", regions:[]}`**(에러 아님) | Ollama 빈줄 동작(`ocr.rs:290`) 미러 |
|
||||
| rec 빈 출력(한 박스) | 그 박스 skip, 나머지 진행 | |
|
||||
| 박스 폭증(노이즈 스캔) | **`max_boxes` 상한**(기본 예: 1000) 초과분 절단 + 로그 | 메모리/지연 cliff 방지 |
|
||||
| dict 길이 ≠ rec 클래스 | 생성 시 에러(정합 검증) | bounds-check |
|
||||
|
||||
ort `Session` 은 생성 후 1회 로드·재사용. ingest 는 현재 직렬(`lib.rs:460`, rayon 없음)이라 동시접근 없음 — 단 `OcrEngine: Send+Sync` 유지(미래 병렬화 대비, rc.9 Session Send/Sync 확인은 plan).
|
||||
|
||||
## 검증 기준
|
||||
|
||||
- `cargo clippy --workspace --all-targets -j 8 -- -D warnings` 0.
|
||||
- `cargo test -p kebab-parse-image -p kebab-app -j 8` 통과(touched 크레이트; `kebab-parse-image` 단독 빌드가 download-binaries 로 링크되는지 포함).
|
||||
- 신규 단위테스트:
|
||||
- 단계별 골든벡터(전처리/det후처리/CTC/박스정렬) — baseline 0.976 대비 단계 회귀 감지.
|
||||
- OnnxPaddleOcr e2e: 합성 한/영 fixture → **CER ≤ 0.05**(=문자정확도 ≥95%), bbox>0. (단 합성 fixture 는 실코퍼스 회귀 미보장 → 도그푸딩 병행.)
|
||||
- CTC decode: 알려진 logit→문자열(blank/중복 제거, bounds-check).
|
||||
- 엔진 팩토리: `engine="paddle-onnx"`→OnnxPaddleOcr, 미지 값 에러.
|
||||
- 서명(C3): 위 (a)(b)(c) 케이스.
|
||||
- config override(`det_model`/`rec_model`/`dict`) 가 실제 사용됨 + **`--config` facade 스레딩**(CLAUDE.md facade rule, P3-5/P4-3 회귀 전례) — `OnnxPaddleOcr::new(cfg, …)` 가 explicit Config 받음.
|
||||
- 회귀 가드: `engine="ollama-vision"`(기본) 경로 — 팩토리 리팩터(구체타입→`&dyn`) 후에도 **출력 동일** 핀하는 테스트.
|
||||
- 스모크: `engine="paddle-onnx"` 이미지 ingest → OCR 텍스트 FTS5 hit. 큰 페이지 CPU <5초.
|
||||
- 도그푸딩: 사용자 실제 이미지/책 스캔 정확도·속도(HOTFIXES + release notes).
|
||||
|
||||
## 의존성 규칙 (design §8)
|
||||
|
||||
`kebab-parse-image` allowed: kebab-core, kebab-config, serde, image, tracing, thiserror(task p6-2). 추가: `ort`(workspace, features `["ndarray","download-binaries"]`), `ndarray`(workspace), `imageproc`. **clipper2 미추가**(C++ FFI 회피 — unclip pure-Rust 직접). **hf-hub 미추가**(결정 C: 모델 번들, 외부 다운로드 0). **금지 유지**: kebab-store-*/embed-*/llm-* 미import. UI 크레이트 영향 없음.
|
||||
|
||||
## 비범위
|
||||
|
||||
- **OCR 텍스트→임베딩 갭**(현재 OCR 은 FTS5 lexical 전용, 벡터 미포함). 사용자 "OCR 모델만 먼저" → 별도 작업.
|
||||
- **caption** 은 gemma 유지([[project_llm_default]]).
|
||||
- **GPU provider**(ort CUDA/CoreML): CPU 로 충분(2.75초). 후속 옵션.
|
||||
- **기본 엔진 전환**(default `paddle-onnx`): 도그푸딩 후 별 PR.
|
||||
- 다국어 dict 동적 전환(현재 korean dict = 한+영+숫자+기호 11,945자로 한/영 충분).
|
||||
|
||||
## 잔여 노트 (critic minors)
|
||||
|
||||
- **max_pixels(m1)**: 기존 `[256,4096]` clamp 은 VLM 프롬프트 비용 기준. det/rec 엔진은 비용이 latency 라 trade-off 다름. v1 은 기본 1600 **유지(의도적)** — PoC 에서 1600 대 원본 정확도 차 미미, 속도 이점. plan 에서 paddle-onnx 전용 기본 재검토 가능.
|
||||
- **config 마이그레이션(m3)**: 신규 키(`det_model` 등)는 serde default 로 forward-compat(기존 파일 무수정 로드). `kebab config migrate`(#198) 가 주석/순서 보존하며 신규 키 추가 — migration 핸들링 불필요(serde default), 단 init 템플릿에 신규 키 노출.
|
||||
- **per-region confidence(open q)**: Ollama 는 region confidence 상수 1.0, paddle-onnx 는 실제 score. `OcrRegion` 형태 불변이라 wire 호환(값만 의미있어짐) — release note 1줄.
|
||||
- **세로/회전 페이지**: 비범위(가로쓰기 reading-order 전제). 회전 박스 rectify 는 지원하나 페이지 전체 세로조판은 미지원 명시.
|
||||
|
||||
## 버전/문서
|
||||
|
||||
- feature(신규 engine 값 + 동작) → **minor bump**.
|
||||
- README(Configuration: `image.ocr.engine`, 모델 첫 다운로드 안내), docs/SMOKE(config 예시), HANDOFF 1줄, docs/ARCHITECTURE(새 OCR 백엔드 추가 시 그래프/결정), HOTFIXES dated entry(도그푸딩 evidence). wire schema 불변(OcrText 내부, `--json` 표면 동일).
|
||||
@@ -14,6 +14,117 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-06-04 — config 스키마 v2→v3 재편: 미디어 ingest 통합 (v0.28.0)
|
||||
|
||||
**무엇을 바꿨나.** `config.toml` 의 미디어 형식 설정을 `[ingest.*]` 우산 아래로 통합했다. 첫 non-additive rename 마이그레이션.
|
||||
|
||||
rename 매핑:
|
||||
|
||||
| v2 (top-level) | v3 (`[ingest.*]`) |
|
||||
|---|---|
|
||||
| `[indexing]` (스칼라 키) | `[ingest]` 스칼라 (`max_parallel_extractors`/`max_parallel_embeddings`/`watch_filesystem`) |
|
||||
| `[chunking]` | `[ingest.chunking]` |
|
||||
| `[image.ocr]` | `[ingest.image.ocr]` |
|
||||
| `[image.caption]` | `[ingest.image.caption]` |
|
||||
| `[pdf.ocr]` | `[ingest.pdf.ocr]` |
|
||||
| `[ingest.code]` | `[ingest.code]` (불변) |
|
||||
|
||||
**보장한 3가지 불변식.**
|
||||
|
||||
1. **signature 바이트 불변** — `ingest_config_signature` 출력은 값 기반이라 struct 경로 재편 후에도 v2 와 바이트 동일. 업그레이드 시 전체 재색인 발생 안 함. paddle 경로(det/rec/dict)는 미디어별로 호출자가 넘기도록 인자화(`ocr_engine_version_for_sig` + `engine_version_for_paths`); v2 의 "pdf 가 image paddle 을 빌려쓰던" 비대칭은 `step_2_to_3` 의 값 복사(`copy_image_paddle_to_pdf`)로 보존.
|
||||
2. **env override 이름 100% 보존** — `apply_env` whitelist 의 키 문자열(LHS, 예 `KEBAB_CHUNKING_TARGET_TOKENS`/`KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS`)은 불변, 대입 대상(RHS)만 `self.ingest.*` 로. 기존 `KEBAB_*` 스크립트 무파손. 신규 `KEBAB_PDF_OCR_{DET_MODEL,REC_MODEL,DICT,SCORE_THRESH,UNCLIP_RATIO,MAX_BOXES}` 6키 추가(image.ocr paddle 대칭).
|
||||
3. **load 시 메모리 내 자동 변환** — `Config::from_file` 이 `schema_version < 3` 파일을 디스크 미변경으로 메모리에서 v3 변환(`migrate_document` 경유). 미변환 v2 파일도 설정 유실 0. 파일 갱신은 `kebab config migrate` (값·주석·대안 줄 보존, 멱등).
|
||||
|
||||
`PdfOcrCfg` 에 paddle 대칭 6키 추가. `ser_f32_clean` 으로 f32 직렬화 정리(`0.30000001192092896`→`0.3`). per-option 인라인 주석(`key_comment`)을 init/migrate 산출 config 에 부착.
|
||||
|
||||
**도그푸딩 (v0.28.0 release 빌드).**
|
||||
|
||||
1. **사용자 실제 v2 config 변환** (`/build/dogfood/config-v3-test/`): `kebab config migrate` → `v2 → v3 (11 changes)`, `.bak` 백업. schema_version 2→3, 섹션 헤더만 [ingest.*] 로 이동(`[indexing]`/`[chunking]`/`[image.ocr]`/`[image.caption]`/`[pdf.ocr]` → `[ingest.*]`). 사용자 값 보존(`root`, `model = "snowflake-arctic-embed2"`, `endpoint = "http://192.168.0.2:11943"`, `score_gate = 0.30000001192092896` 그대로) + 대안 주석 보존(`# engine = "ollama-vision"`, `# provider = "candle"`). 재실행 멱등(`config 이미 최신입니다`).
|
||||
|
||||
2. **재색인 0 실증** (`/build/dogfood/config-v3-reindex/`, lexical-only `provider = "none"`): v2 config(디스크 schema_version=2)로 first ingest `new=2`. (a) 동일 v2 config 재ingest(메모리 자동변환) → `new=0 updated=0 unchanged=2`. (b) 디스크 파일을 v3 로 `config migrate` 후 재ingest → `new=0 updated=0 unchanged=2`. signature 바이트 불변이 실제 업그레이드 경로(v2 자동변환 ↔ v3 디스크)에서 재색인 0 으로 확인됨 — 불변식 #1 검증.
|
||||
|
||||
## 2026-06-04 — PP-OCRv5 ONNX Rust 네이티브 OCR 엔진 (v0.27.0)
|
||||
|
||||
**무엇을 추가했나.** 이미지 OCR 에 두 번째 백엔드 `paddle-onnx` 를 붙였다. 기존 `ollama-vision`
|
||||
(원격 vision LM, 이미지당 ~50초)은 default 로 유지하고, `[image.ocr] engine = "paddle-onnx"` 로
|
||||
PP-OCRv5(검출 DBNet + 인식 CTC) ONNX 모델을 `ort`(=2.0.0-rc.9) 로 **in-process** 실행한다 —
|
||||
Python 런타임/원격 호출 없이 큰 페이지 CPU <4초. `OcrEngine` trait 의 두 번째 구현
|
||||
`OnnxPaddleOcr`(`crates/kebab-parse-image/src/paddle_onnx.rs`), 팩토리는
|
||||
`kebab-app::build_image_ocr_engine`/`build_pdf_ocr_engine` (`match engine`). 검출 후처리
|
||||
(min-area rect = rotating calipers, unclip = polygon offset)는 clipper2/OpenCV 없이 pure-Rust.
|
||||
|
||||
**T11 e2e 에서 발견·수정한 핵심 버그 (unclip).** 첫 실측 CER 이 0.26(게이트 0.05) 으로 크게
|
||||
초과. 단계 골든(`crates/kebab-parse-image/tests/golden/`) 와 prediction dump 로 국소화한 결과
|
||||
`unclip_rect` 가 corner 를 centroid 기준 **방사(radial) 확장**하고 있었다. 텍스트 박스는
|
||||
wide/short(예 586×15)라 대각선이 거의 수평 → 방사 확장 시 corner 가 수평으로만 ~11px 움직이고
|
||||
**세로로는 거의 안 커져** 글자 윗/아랫부분이 잘렸다(ㄷ→ㄴ 로 `다`→`나`, ascender 손실).
|
||||
PaddleOCR pyclipper 처럼 **edge 별로 바깥으로 offset**(width·height 각각 2·distance 증가) 하도록
|
||||
rect 자체 (u,v) 축 기준 확장으로 재작성. 결과: mean gate CER **0.2585 → 0.0049**
|
||||
(clean_paragraph/korean_heavy/numbers_table/tech_terms = 0.0), PoC 0.024 baseline 보다 우수.
|
||||
큰 페이지 3.9초 < 5초 게이트. **교훈**: 회전 사각형 unclip 은 방사 확장이 아니라 polygon edge
|
||||
offset 이어야 한다.
|
||||
|
||||
**Config / 서명 cascade.** `[image.ocr]` 에 `det_model`/`rec_model`/`dict`(Option, override) +
|
||||
`score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) serde-default 필드 + `KEBAB_IMAGE_OCR_*`
|
||||
env 추가(기존 config 무수정 로드 — forward-compat). `ingest_config_signature` 의 image/pdf 브랜치를
|
||||
`|ocr:1:{model}` → `|ocr:1:{engine}:{engine_version}` 로 바꿔 engine 전환(ollama↔paddle) 또는
|
||||
모델 변경 시 영향 자산 자동 재색인. paddle engine_version 은 모델 3-asset blake3 를 **per-process
|
||||
1회만** 계산(triple 키 memo) — 자산마다 17MB 재해시 회피.
|
||||
|
||||
**모델 배포.** ONNX 2개(det 4.7MB / rec 13MB) + dict + NOTICE 를 `crates/kebab-parse-image/
|
||||
assets/paddleocr-onnx/` 에 둔다(Git LFS). 테스트는 `KEBAB_IMAGE_OCR_MODEL_DIR`(기본 = 번들 dir)
|
||||
에서 로드, e2e(`tests/paddle_e2e.rs`)는 모델/fixture 부재 시 깨끗이 skip(CI green). 자세한 설계:
|
||||
spec/plan `docs/superpowers/{specs,plans}/2026-06-04-rust-native-ocr-*.md`.
|
||||
|
||||
## 2026-06-03 — ingest 출력 영향 설정 변경 시 영향 자산 자동 재색인 (v0.26.2)
|
||||
|
||||
**무엇이 깨졌나.** `[image.ocr]` / `[image.caption]` 를 off→색인→on 으로 바꿔도 증분
|
||||
skip(`try_skip_unchanged`, `kebab-app/src/lib.rs`)이 그 이미지를 "Unchanged" 로 건너뛰어
|
||||
재색인이 안 됐다. 더 일반적으로, skip 판정은 자산 내용(blake3) + `parser_version` +
|
||||
`chunker_version` + `embedding_version` 만 비교하는데, **ingest 산출물을 바꾸는 다른 설정들**
|
||||
(청킹 파라미터, OCR/caption, pdf.ocr, `[ingest.code]` 옵션)이 이 셋 중 어디에도 반영되지
|
||||
않아, 변경해도 재색인이 트리거되지 않았다. 사용자 요구: OCR/caption 뿐 아니라 **ingest 출력에
|
||||
영향 주는 모든 설정**이 변경되면 영향 자산이 자동 재색인.
|
||||
|
||||
**무엇이 바뀌었나 (내부 skip 판정 정정 — 결과 포맷·CLI·wire 불변, patch).**
|
||||
|
||||
- 신규 헬퍼 `ingest_config_signature(config, media_type) -> String` — 그 자산 타입의
|
||||
**ingest 산출물에 영향 주는 설정만** 결정적으로 직렬화. 공통(전 타입): `[chunking]`
|
||||
target_tokens/overlap_tokens/respect_markdown_headings/chunker_version. image: + ocr(enabled,
|
||||
+model) + caption(enabled, +prompt_template_version). pdf: + pdf.ocr(enabled||always_on 이면
|
||||
enabled/always_on/model). code: + `[ingest.code]` 7개 필드. markdown: 공통만.
|
||||
- 각 ingest 경로(md/image/pdf/code)의 effective parser_version 을
|
||||
`format!("{base}|{signature}")` composite 로 만들어 (a) `try_skip_unchanged` 비교값,
|
||||
(b) **persist 전 `canonical.parser_version` override** — 두 값이 같은 함수에서 나오므로
|
||||
설정 변경 시 다음 run 비교가 mismatch → 영향 자산만 자동 재색인.
|
||||
- **doc_id 는 손대지 않음**: base parser_version(extractor 상수)으로 계속 파생 →
|
||||
설정 변경에도 doc_id 안정(orphan churn 회피). composite 는 비교/저장 필드에만.
|
||||
- **제외(재색인 트리거 X)**: search/rag/nli/ui/logging/storage/workspace + 산출 무관
|
||||
런타임 파라미터(max_pixels/languages/*_timeout_secs). "그 값이 바뀌면 색인되는
|
||||
chunk/embedding 내용이 달라지는가" 기준. 과도 무효화 회피.
|
||||
- code 의 Tier-3 fallback 문서는 의도적으로 bare `"none-v1"` sentinel 유지(skip 의
|
||||
`stored_is_tier3_fallback` bypass 가 정확히 그 문자열에 의존) — composite 는 정상 outcome 에만.
|
||||
|
||||
**업그레이드 1회 효과.** 기존 doc 의 저장 parser_version(상수)이 새 composite 와 달라,
|
||||
업그레이드 후 첫 `kebab ingest` 에서 **전 자산이 현재 설정대로 1회 재색인**된다(force 불필요).
|
||||
마크다운/코드도 1회 재청킹되나 embedding 은 V012 derived-cache 히트라 재임베딩 비용은 작다.
|
||||
`--force-reingest` 는 전체 강제용으로 그대로.
|
||||
|
||||
**도그푸딩 evidence (release 바이너리, Ollama down — OCR 호출은 Lenient 실패).**
|
||||
이미지 1장, `[image.ocr] enabled=false` 색인 → New=1. config 에서 `enabled=true` 로 변경 후
|
||||
`kebab ingest`(force 없이) → **Updated=1**(재색인, errors=0). 동일 config 재실행 → **Unchanged=1**
|
||||
(불필요 재색인 0). 저장된 parser_version =
|
||||
`image-meta-v1|chunk:500:80:true:md-heading-v1|ocr:1:gemma4:e4b|cap:0`(base 보존 + OCR on 반영).
|
||||
|
||||
**테스트.** `kebab-app/src/lib.rs::ingest_config_signature_tests`(8 단위: 결정성, 청킹=전타입,
|
||||
이미지 ocr/caption 토글=이미지만, pdf.ocr=pdf만, code 옵션=코드만, search/rag/ui·런타임 파라미터
|
||||
불변 회귀가드) + `kebab-app/tests/config_invalidation.rs`(4 end-to-end: 동일 config=전 skip,
|
||||
청킹 변경=md+code 재색인, `[ingest.code]` 변경=코드만, search 변경=재색인 0). 기존 skip 테스트
|
||||
회귀 0(parser_version exact assert 는 base 접두사 비교로 갱신 — code_ingest_smoke/pdf_pipeline).
|
||||
|
||||
spec/plan: `docs/superpowers/specs/2026-06-03-ocr-toggle-invalidation-spec.md` /
|
||||
`…/plans/2026-06-03-config-invalidation-plan.md`.
|
||||
|
||||
## 2026-06-03 — ingest 진행 로그 개선: 파일명·phase·heartbeat·slowest 요약 (v0.26.1)
|
||||
|
||||
**무엇을 왜 추가했나.** arctic 도그푸딩 중 이미지/PDF 혼재 + OCR/caption on 볼트에서
|
||||
|
||||
Reference in New Issue
Block a user