Compare commits

...

11 Commits

Author SHA1 Message Date
a283e56c5c Merge pull request 'fix(ingest): ingest 설정 변경 시 영향 자산 자동 재색인' (#205) from fix/ingest-config-invalidation into main
Reviewed-on: #205
2026-06-03 14:33:18 +00:00
47ef6532f7 chore(release): v0.26.2 — ingest 설정 변경 자동 재색인 + 문서
- Cargo.toml workspace version 0.26.1 → 0.26.2 (+Cargo.lock cascade).
  결과 포맷·CLI·wire 불변(내부 skip 판정 정정) → patch (CLAUDE.md §Versioning).
- tasks/HOTFIXES.md dated entry: 일반화 + 업그레이드 1회 재색인 안내 + 도그푸딩 evidence.
- HANDOFF.md 1줄.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:14:23 +00:00
03b0745e9d test(ingest): config invalidation e2e + parser_version assert 갱신
- config_invalidation.rs(신규): 동일config=전skip / 청킹변경=md+code재색인 /
  [ingest.code]변경=코드만 / search변경=재색인0 (회귀가드) end-to-end.
- code_ingest_smoke / pdf_pipeline: 저장 parser_version 이 이제
  "{base}|{sig}" composite 라, exact assert 를 base 접두사(split('|').next()) 비교로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:14:15 +00:00
e7cb20990a feat(ingest): ingest 설정 변경 시 영향 자산 자동 재색인 (signature 폴딩)
ingest 산출에 영향 주는 설정(청킹/이미지 OCR·caption/pdf.ocr/[ingest.code])의
결정적 서명을 effective parser_version 에 폴딩 → 변경 시 --force-reingest 없이
영향 자산만 자동 재색인.

- ingest_config_signature(config, media_type): per-type 산출-영향 설정만 직렬화.
  비산출 설정(search/rag/ui/log + max_pixels/languages/timeout)은 제외.
- effective_parser_version(config, asset, base) = "{base}|{signature}".
- md/image/pdf/code 경로: composite 를 (a) try_skip_unchanged 비교값,
  (b) persist 전 canonical.parser_version override 에 사용.
- doc_id 는 base parser_version 으로 계속 파생 → 설정 변경에도 안정(orphan churn 회피).
- code Tier-3 fallback 은 bare "none-v1" sentinel 유지(skip bypass 의존).
- 단위테스트 8: 결정성/청킹=전타입/이미지·pdf·code 토글/무관설정 회귀가드.

spec: docs/superpowers/specs/2026-06-03-ocr-toggle-invalidation-spec.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:14:06 +00:00
bebf6e4ac7 docs(spec): ingest 설정 변경 자동 재색인 spec + plan
증분 skip 이 OCR/caption·청킹·코드 등 ingest 산출 영향 설정 변경을 못 잡아 재색인
안 되던 갭 일반화 수정. per-asset-type "ingest config signature" 를 effective
parser_version 에 폴딩 → 영향 설정 변경 시 영향 자산만 자동 재색인. patch(0.26.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:40:02 +00:00
736d791056 Merge pull request 'feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약' (#204) from feat/ingest-log-improve into main
Reviewed-on: #204
2026-06-03 11:04:19 +00:00
6c9c8df43e chore(version): 0.27.0 → 0.26.1 — 새 bump 규칙상 patch
진행 로그 개선은 검색·색인 결과 불변 + 새 명령/플래그/config 없음 + additive-only
wire(asset_phase)라 CLAUDE.md 신규 규칙(기능/인터페이스 변경=minor, 없으면 patch)상
patch 가 맞음. version·라벨·HOTFIXES 헤더를 0.26.1 로 정정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:02:16 +00:00
0263667684 docs(claude): pre-1.0 버전 bump 규칙 명확화 — 기능/인터페이스 변경=minor, 없으면 patch
minor 과다 사용 교정. 기능(behavior)·인터페이스(CLI/config/breaking wire/결과·동작/
migration) 변경 시 minor, 그 외(bug fix·refactor·관측성/로깅·additive-only wire·문서)
는 patch. 경계 예시 포함(진행로그+additive wire=patch, arctic provider/config=minor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:00:49 +00:00
4918983d9c chore(ingest): PR #204 회차1 리뷰 반영 — 버전 라벨 v0.26.0 → v0.27.0
신규 진행로깅 표면(asset_phase / ocr_ms / caption_ms + progress.rs heartbeat·
slowest 주석)이 v0.26.0 으로 잘못 표기돼 있던 것을 v0.27.0(실제 추가 버전)으로
정정. wire schema 의 "추가 버전" 정확성(외부 통합 참조). 로직 변경 없음(주석/doc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:57:17 +00:00
aeaa18a564 feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약
OCR/caption 켜진 볼트 ingest 가 중간부터 느릴 때 TTY 진행바가 파일명·phase·
모델·경과시간을 안 보여 "멈춤"처럼 보이던 문제 해결.
- 신규 wire AssetPhase{idx,total,phase,model} + AssetTimings.ocr_ms/caption_ms
  (additive, ingest_progress.v1 유지)
- app: apply_ocr/apply_caption/embed 진입 시 AssetPhase emit + ocr/caption 시간 측정
- cli: TTY 진행바에 현재 파일명 + phase(model) + asset 경과초(heartbeat),
  종료 시 최장 소요 파일 top-5 요약(quiet 여도 출력, --json 미출력)
- wire schema / README / HANDOFF / HOTFIXES 동기화, version 0.26.0 → 0.27.0

검증(리더): clippy 0, kebab-app/cli 61그룹·parse-image/tui 14그룹 0실패(-j8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:26 +00:00
c91ff909ce docs(spec): ingest 로그 개선 spec + plan
arctic 도그푸딩에서 OCR/caption 켜진 Obsidian 볼트가 중간부터 느려졌으나
TTY 진행바가 파일명·phase·모델·경과시간을 안 보여 "멈춤"처럼 보인 문제.
파일명 표시 + 느린 phase(OCR/caption/embed)+모델 실시간 + asset 경과 heartbeat
+ 종료 시 최장 소요 top-N 요약. additive wire(asset_phase, ocr_ms/caption_ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:14:15 +00:00
19 changed files with 1376 additions and 104 deletions

View File

@@ -82,7 +82,10 @@ Release 절차:
1. `gitea-release v<X.Y.Z>` (gitea-ops skill) 으로 tag + push + release notes.
2. release notes 는 사용자 도그푸딩에 영향이 가는 surface 변경을 위주로 — wire schema 추가, CLI flag 신규, TUI 키 변경, V00X migration 등 — 다룬다. 이때 추가된 기능과 변경사항은 유저가 이해할 수 있도록 친절하고 자세하게 풀어서 설명해야 하며, 단순히 commit subject 를 나열하는 형태로 끝내면 안 된다. 필요하다면 도그푸딩이나 테스트 결과도 함께 적어 둔다.
3. 프리-1.0 (`0.x.y`) 단계: minor bump 시 wire schema additive / surface 변경 누적, patch bump 시 bug fix only.
3. 프리-1.0 (`0.x.y`) 단계 bump 규칙 — **기능(behavior) 또는 인터페이스(interface) 변경 여부**로 판정:
- **minor bump** (`0.x.0`): 기능 또는 인터페이스에 *실질적* 변경이 있을 때. 인터페이스 = 신규/변경/삭제된 CLI subcommand·flag, config 키, wire schema 의 **breaking** 변경, 임베딩/검색/RAG 등 사용자가 받는 **결과·동작**의 변화, V00X migration, frozen 설계 변경. 기능 = 새 source 형식·검색 모드·백엔드 등 *할 수 있는 일*의 추가/변경.
- **patch bump** (`0.x.y`): 기능·인터페이스 변경이 **없을** 때. bug fix, 내부 refactor, 성능 개선, 로깅/진행표시 등 **관측성(observability) 개선**, **additive-only wire 변경**(backward-compat 신규 필드/이벤트라 기존 소비자 무영향), 문서. ← 즉 "결과가 같고 새 명령/플래그/config 도 없으면 patch".
- 경계 예: 진행 로그에 phase/파일명 추가 + additive wire 이벤트(asset_phase) = **patch** (검색·색인 결과 불변, 새 명령/플래그/config 없음). arctic 임베더 provider + 신규 config 키 = **minor** (인터페이스 추가). 별칭 기능 제거 + migration = **minor** (동작·인터페이스 변경).
**bump 시점 = release 시점 같은 commit**. 즉 commit `chore: bump version 0.x → 0.y` 직후 같은 commit 에 tag. v0.1.0 (`2319206`) 처럼 bump 없이 tag 만 찍는 패턴은 후속 release 가 대상 commit 을 헷갈리게 함 — pre-release snapshot 은 SHA reference 로 충분.

48
Cargo.lock generated
View File

@@ -4724,7 +4724,7 @@ dependencies = [
[[package]]
name = "kebab-app"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -4772,7 +4772,7 @@ dependencies = [
[[package]]
name = "kebab-chunk"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -4790,7 +4790,7 @@ dependencies = [
[[package]]
name = "kebab-cli"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"clap",
@@ -4811,7 +4811,7 @@ dependencies = [
[[package]]
name = "kebab-config"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -4827,7 +4827,7 @@ dependencies = [
[[package]]
name = "kebab-core"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -4841,7 +4841,7 @@ dependencies = [
[[package]]
name = "kebab-embed"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -4855,7 +4855,7 @@ dependencies = [
[[package]]
name = "kebab-embed-candle"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"candle-core",
@@ -4875,7 +4875,7 @@ dependencies = [
[[package]]
name = "kebab-embed-local"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"fastembed",
@@ -4888,7 +4888,7 @@ dependencies = [
[[package]]
name = "kebab-embed-ollama"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"kebab-config",
@@ -4903,7 +4903,7 @@ dependencies = [
[[package]]
name = "kebab-eval"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"kebab-app",
@@ -4922,7 +4922,7 @@ dependencies = [
[[package]]
name = "kebab-llm"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"kebab-core",
@@ -4931,7 +4931,7 @@ dependencies = [
[[package]]
name = "kebab-llm-local"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"kebab-config",
@@ -4948,7 +4948,7 @@ dependencies = [
[[package]]
name = "kebab-mcp"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"kebab-app",
@@ -4966,7 +4966,7 @@ dependencies = [
[[package]]
name = "kebab-nli"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"hf-hub",
@@ -4981,7 +4981,7 @@ dependencies = [
[[package]]
name = "kebab-parse-code"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"gix",
@@ -5004,7 +5004,7 @@ dependencies = [
[[package]]
name = "kebab-parse-image"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"ab_glyph",
"anyhow",
@@ -5028,7 +5028,7 @@ dependencies = [
[[package]]
name = "kebab-parse-md"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"kebab-core",
@@ -5045,7 +5045,7 @@ dependencies = [
[[package]]
name = "kebab-parse-pdf"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -5060,7 +5060,7 @@ dependencies = [
[[package]]
name = "kebab-rag"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -5082,7 +5082,7 @@ dependencies = [
[[package]]
name = "kebab-search"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"globset",
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "kebab-source-fs"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -5119,7 +5119,7 @@ dependencies = [
[[package]]
name = "kebab-store-sqlite"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"blake3",
@@ -5139,7 +5139,7 @@ dependencies = [
[[package]]
name = "kebab-store-vector"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"arrow",
@@ -5163,7 +5163,7 @@ dependencies = [
[[package]]
name = "kebab-tui"
version = "0.26.0"
version = "0.26.2"
dependencies = [
"anyhow",
"crossterm",

View File

@@ -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.0" # v0.26.0arctic-embed-l-v2.0 임베더 통합: kebab-embed-candle 다중 모델 레지스트리(e5 mean + arctic CLS, 모델별 pooling/prefix 분기) + 신규 kebab-embed-ollama 크레이트(provider="ollama", POST /api/embed, L2 정규화, batch+fail-soft). config models.embedding.provider 에 "ollama" 추가 + endpoint: Option<String>. 기본 동작 불변(provider=fastembed e5), arctic 은 opt-in, embedding_version cascade(arctic-cls / ollama:{model} 태그). — CLAUDE.md §Release
version = "0.26.2" # v0.26.2ingest 설정 변경 시 영향 자산 자동 재색인: ingest 산출에 영향 주는 설정(청킹/이미지 OCR·caption/pdf.ocr/[ingest.code])의 결정적 서명을 effective parser_version(skip 비교 + 저장 doc 필드 양쪽)에 폴딩 → 해당 설정 변경 시 `--force-reingest` 없이 영향 자산만 자동 재색인. 비산출 설정(search/rag/ui/log + max_pixels/languages/timeout 등)은 제외(과도 무효화 회피). doc_id 는 base parser_version 으로 안정 유지(orphan churn 회피). 결과 포맷·CLI·wire 불변(내부 skip 판정 정정) → patch. — 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),

View File

@@ -35,6 +35,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.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`.
- **2026-05-31 Phase 2 doc-side expansion 별칭(개별 dense 벡터) + 파생물 캐시(V012)** — v0.21.0 cut. 색인 시 LLM 이 청크별 별칭("같은 의미 다른 표현")을 생성, 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인 (묶음 1벡터는 평균화 희석으로 회귀 → 폐기) + boilerplate 청크 skip. `[ingest.expansion]` default off. 측정(나무위키 ~1000 문서 CS corpus): 변형 일관성 14/18 → **16/18**, spread 0.222→0.111, 대조군 false-positive 별칭 무죄. 비용 병목(별칭 18문서 2.5h)은 **파생물 캐시(V012, 청크 내용 해시 키)**로 해소 — 정답 3개 cold 1879s → warm 13s **≈ 145배**, embedding+별칭 LLM 캐싱, version_key cascade 정합. search/ask 가 `kebab.sqlite`+`lancedb` 만으로 동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능. **결정/known limitation**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류(정직한 거부가 false-positive 로 집계) — 별도 개선 후보. stack·svm 설명형 2개 잔존. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-31), 측정: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`.

View File

@@ -83,7 +83,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
| 명령 | 동작 |
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 문서별 청크 수 · 문서 종료 시 phase별 소요시간(parse/chunk/embed/store)을 표시 (`--json``asset_chunked`/`asset_timings` 이벤트로) |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 현재 **파일명** · 느린 **phase(ocr/caption/embed)+모델명** · **경과초**`(Ns)` · 문서별 청크 수 · phase별 소요시간(parse/chunk/ocr/caption/embed/store)을 표시하고, 종료 시 **최장 소요 파일 top-5** 를 요약한다 (`--json``asset_phase`/`asset_chunked`/`asset_timings` 이벤트로, 사람용 요약은 미출력) |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능 — `_external/` 로 deterministic copy) |
| `kebab ingest-stdin --title <T>` | stdin 의 markdown 본문 ingest |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [flags]` | 검색 (default hybrid = RRF fusion, citation 포함). 필터/budget flag 는 `--help` |

View File

@@ -96,14 +96,33 @@ pub enum IngestEvent {
/// `idx/total` while its per-chunk phases churn. `chunks` is the chunk
/// count for asset `idx`.
AssetChunked { idx: u32, total: u32, chunks: u32 },
/// v0.26.1 (additive): emitted when an asset enters a *slow* internal
/// phase, so the interactive progress bar can show **which** phase
/// (and which model) is currently running instead of looking frozen.
/// `phase` ∈ {`"ocr"`, `"caption"`, `"embed"`}; short phases
/// (parse / chunk / store) are intentionally *not* emitted to avoid
/// noise. `model` is the model performing the phase — the vision LLM
/// id for `ocr` / `caption`, the embedder `model_id` for `embed`
/// (`None` when the phase runs without a configured model, e.g. embed
/// with no embedder wired). Emitted once per (asset, phase); no
/// throttle needed (low frequency). Wire v1 consumers that predate
/// this variant simply ignore the unknown `asset_phase` kind.
AssetPhase {
idx: u32,
total: u32,
phase: String,
model: Option<String>,
},
/// v0.24.0 (additive): per-phase wall-clock (milliseconds) for asset
/// `idx`, emitted once the asset's markdown pipeline finishes. Lets a
/// user see *where* the time went (parse / chunk / embed / store)
/// without parsing logs. Only the markdown path emits this; the
/// image / PDF paths surface `AssetChunked` but skip phase timing (their
/// phase shapes differ — OCR / caption). `expansion_ms` is retained for
/// wire compatibility but is always 0 since doc-side expansion was
/// removed (HOTFIXES 2026-06-03).
/// `idx`, emitted once the asset's pipeline finishes. Lets a user see
/// *where* the time went (parse / chunk / ocr / caption / embed /
/// store) without parsing logs. The markdown path leaves `ocr_ms` /
/// `caption_ms` at 0 (no image analysis); the image / PDF paths fill
/// them so the slowest-asset summary attributes vision-model time
/// correctly. `expansion_ms` is retained for wire compatibility but is
/// always 0 since doc-side expansion was removed (HOTFIXES 2026-06-03).
/// `ocr_ms` / `caption_ms` (v0.26.1) are additive with serde default 0
/// so pre-v0.26.1 consumers deserialize cleanly.
AssetTimings {
idx: u32,
total: u32,
@@ -112,6 +131,10 @@ pub enum IngestEvent {
expansion_ms: u64,
embed_ms: u64,
store_ms: u64,
#[serde(default)]
ocr_ms: u64,
#[serde(default)]
caption_ms: u64,
},
/// Run finished normally. `counts` is the final aggregate.
Completed { counts: AggregateCounts },
@@ -261,19 +284,23 @@ mod tests {
expansion_ms: 45_000,
embed_ms: 800,
store_ms: 20,
ocr_ms: 1_200,
caption_ms: 3_400,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v.get("kind").and_then(|s| s.as_str()),
Some("asset_timings")
);
// All five phase fields are present (plain u64, always serialized).
// All phase fields are present (plain u64, always serialized).
for (field, want) in [
("parse_ms", 12u64),
("chunk_ms", 3),
("expansion_ms", 45_000),
("embed_ms", 800),
("store_ms", 20),
("ocr_ms", 1_200),
("caption_ms", 3_400),
] {
assert_eq!(
v.get(field).and_then(serde_json::Value::as_u64),
@@ -283,6 +310,64 @@ mod tests {
}
}
#[test]
fn asset_timings_ocr_caption_default_to_zero_for_legacy_wire() {
// v0.26.1 additive: a pre-v0.26.1 wire payload omits ocr_ms /
// caption_ms; serde `default` must fill 0 so old producers stay
// compatible.
let legacy = serde_json::json!({
"kind": "asset_timings",
"idx": 1, "total": 1,
"parse_ms": 5, "chunk_ms": 2, "expansion_ms": 0,
"embed_ms": 10, "store_ms": 3
});
let ev: IngestEvent = serde_json::from_value(legacy).unwrap();
match ev {
IngestEvent::AssetTimings {
ocr_ms,
caption_ms,
embed_ms,
..
} => {
assert_eq!(ocr_ms, 0);
assert_eq!(caption_ms, 0);
assert_eq!(embed_ms, 10);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn asset_phase_serializes_with_discriminator() {
// v0.26.1 additive variant — `kind` must be snake_case
// `asset_phase`, `phase` is the slow-phase label, `model` the
// model id (nullable).
let ev = IngestEvent::AssetPhase {
idx: 4,
total: 12,
phase: "ocr".into(),
model: Some("gemma4:e4b".into()),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v.get("kind").and_then(|s| s.as_str()), Some("asset_phase"));
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(4));
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("ocr"));
assert_eq!(v.get("model").and_then(|s| s.as_str()), Some("gemma4:e4b"));
}
#[test]
fn asset_phase_model_none_serializes_as_null() {
let ev = IngestEvent::AssetPhase {
idx: 1,
total: 1,
phase: "embed".into(),
model: None,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("embed"));
assert!(v.get("model").is_some_and(serde_json::Value::is_null));
}
#[test]
fn ingest_event_completed_has_counts() {
let ev = IngestEvent::Completed {

View File

@@ -1242,6 +1242,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 +1257,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 +1303,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);
@@ -1350,6 +1360,17 @@ fn ingest_one_asset(
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
// Embed + vector upsert (only when both sides are configured).
// v0.26.1: surface the embed phase + model so a long embed run reads as
// "embedding(<model>)…" rather than a frozen bar (markdown path too).
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "embed".to_string(),
model: embedder.map(|e| e.model_id().0),
},
);
let t_embed = std::time::Instant::now();
// Stale-vector purge is LanceDB I/O, so it belongs to the embed/vector
// phase — not the SQLite `store` phase. Keeping it here makes `store_ms`
@@ -1414,7 +1435,8 @@ fn ingest_one_asset(
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: phase-timing breakdown for this asset (markdown path only).
// v0.24.0: phase-timing breakdown for this asset (markdown path).
// ocr_ms / caption_ms are 0 — markdown has no image-analysis phases.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
@@ -1425,6 +1447,8 @@ fn ingest_one_asset(
expansion_ms,
embed_ms,
store_ms,
ocr_ms: 0,
caption_ms: 0,
},
);
@@ -1515,11 +1539,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,
@@ -1545,9 +1573,15 @@ fn ingest_one_image_asset(
workspace_root: &workspace_root,
config: &extract_config,
};
let t_parse = std::time::Instant::now();
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
// Lenient — failure is captured into Provenance Warning,
@@ -1562,44 +1596,74 @@ fn ingest_one_image_asset(
let lang_hint = lang_hint_from_doc(&canonical);
let now = time::OffsetDateTime::now_utc();
let mut warning_notes: Vec<String> = Vec::new();
// v0.26.1: vision phases (OCR / caption) are the usual bottleneck on an
// image-heavy vault and emitted no progress before — so the bar looked
// frozen. Surface each as an `AssetPhase` and measure its wall-clock for
// the slowest-asset summary.
let mut ocr_ms = 0_u64;
let mut caption_ms = 0_u64;
match canonical.blocks.first_mut() {
Some(Block::ImageRef(block)) => {
if let Some(engine) = ocr_engine
&& let Err(e) = apply_ocr(
if let Some(engine) = ocr_engine {
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "ocr".to_string(),
model: Some(engine.model().to_string()),
},
);
let t_ocr = std::time::Instant::now();
let res = apply_ocr(
engine,
&bytes,
block,
lang_hint.as_ref(),
&mut canonical.provenance.events,
)
{
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"OcrFailed",
e,
now,
);
ocr_ms = u64::try_from(t_ocr.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(e) = res {
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"OcrFailed",
e,
now,
);
}
}
if let Some(llm) = caption_llm
&& let Err(e) = apply_caption(
if let Some(llm) = caption_llm {
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "caption".to_string(),
model: Some(llm.model_ref().id),
},
);
let t_caption = std::time::Instant::now();
let res = apply_caption(
llm,
&bytes,
block,
lang_hint.as_ref(),
&app.config,
&mut canonical.provenance.events,
)
{
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"CaptionFailed",
e,
now,
);
caption_ms = u64::try_from(t_caption.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(e) = res {
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"CaptionFailed",
e,
now,
);
}
}
}
// P6-1 contract: image documents always have exactly one
@@ -1634,12 +1698,13 @@ fn ingest_one_image_asset(
// `Block::ImageRef` arm already produces a single chunk per
// image (P1-5). The chunk text now follows the (β) plain-concat
// contract per the kebab-chunk render_block_text update.
let t_chunk = std::time::Instant::now();
let chunks = MdHeadingV1Chunker
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk (image)")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: surface chunk count for the image path too (phase timing is
// markdown-only, but AssetChunked is consistent across media).
// v0.24.0: surface chunk count for the image path too.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetChunked {
@@ -1656,6 +1721,7 @@ fn ingest_one_image_asset(
if let Some(emb) = embedder {
canonical.last_embedding_version = Some(emb.model_version());
}
let t_store = std::time::Instant::now();
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
app.sqlite
.put_asset_with_bytes(asset, &bytes)
@@ -1669,7 +1735,18 @@ fn ingest_one_image_asset(
app.sqlite
.put_chunks(&canonical.doc_id, &chunks)
.context("DocumentStore::put_chunks (image)")?;
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "embed".to_string(),
model: embedder.map(|e| e.model_id().0),
},
);
let t_embed = std::time::Instant::now();
if let (Some(emb), Some(vec_store)) = (embedder, vector_store)
&& !chunks.is_empty()
{
@@ -1710,6 +1787,25 @@ fn ingest_one_image_asset(
.upsert(&records)
.context("VectorStore::upsert (image)")?;
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.26.1: per-phase timing for the image path — ocr_ms / caption_ms
// carry the vision-model cost so the slowest-asset summary attributes
// an image-heavy run's bottleneck correctly.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms: 0,
embed_ms,
store_ms,
ocr_ms,
caption_ms,
},
);
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
kebab_core::IngestItemKind::Updated
@@ -2028,11 +2124,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,
@@ -2053,9 +2152,14 @@ fn ingest_one_pdf_asset(
workspace_root: &workspace_root,
config: &extract_config,
};
let t_parse = std::time::Instant::now();
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).
@@ -2191,9 +2295,11 @@ fn ingest_one_pdf_asset(
// validates every block carries `SourceSpan::Page`; failure here
// means the parser drifted from its contract.
let chunker = PdfPageV1Chunker;
let t_chunk = std::time::Instant::now();
let chunks = chunker
.chunk(&canonical, chunk_policy)
.context("kb-chunk::PdfPageV1Chunker::chunk")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: surface chunk count for the PDF path too.
crate::ingest_progress::emit(
@@ -2212,6 +2318,7 @@ fn ingest_one_pdf_asset(
canonical.last_embedding_version = Some(emb.model_version());
}
let t_store = std::time::Instant::now();
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
app.sqlite
.put_asset_with_bytes(asset, &bytes)
@@ -2225,7 +2332,18 @@ fn ingest_one_pdf_asset(
app.sqlite
.put_chunks(&canonical.doc_id, &chunks)
.context("DocumentStore::put_chunks (pdf)")?;
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "embed".to_string(),
model: embedder.map(|e| e.model_id().0),
},
);
let t_embed = std::time::Instant::now();
if let (Some(emb), Some(vec_store)) = (embedder, vector_store)
&& !chunks.is_empty()
{
@@ -2264,6 +2382,25 @@ fn ingest_one_pdf_asset(
.upsert(&records)
.context("VectorStore::upsert (pdf)")?;
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.26.1: per-phase timing for the PDF path. `ocr_ms` reuses the
// page-OCR total already computed above so a scanned-PDF run's OCR cost
// shows up in the slowest-asset summary; caption is markdown/image-only.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms: 0,
embed_ms,
store_ms,
ocr_ms: pdf_ocr_ms_total.unwrap_or(0),
caption_ms: 0,
},
);
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
kebab_core::IngestItemKind::Updated
@@ -2397,10 +2534,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,
@@ -2565,6 +2711,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());
@@ -2838,6 +2998,102 @@ fn chunk_policy_from_config(config: &kebab_config::Config) -> ChunkPolicy {
}
}
/// 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.
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.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.image.ocr;
if ocr.enabled {
sig.push_str(&format!("|ocr:1:{}", ocr.model));
} else {
sig.push_str("|ocr:0");
}
let cap = &config.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.pdf.ocr;
if ocr.enabled || ocr.always_on {
sig.push_str(&format!(
"|pdfocr:{}:{}:{}",
ocr.enabled, ocr.always_on, ocr.model
));
} 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>> {
@@ -3316,3 +3572,248 @@ 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.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.chunking.overlap_tokens += 10;
assert_ne!(
ingest_config_signature(&base, &md()),
ingest_config_signature(&overlap, &md())
);
let mut headings = base.clone();
headings.chunking.respect_markdown_headings = !base.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.image.ocr.enabled, "default OCR is off");
let mut on = base.clone();
on.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.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.image.ocr.enabled = true;
let mut on_b = off_a.clone();
on_b.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.image.caption.enabled = true;
assert_ne!(
ingest_config_signature(&base, &img()),
ingest_config_signature(&on, &img())
);
let mut prompt = on.clone();
prompt.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.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.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.image.ocr.max_pixels += 100;
other.image.ocr.languages.push("jpn".to_string());
other.image.ocr.request_timeout_secs += 10;
// pdf runtime-only knobs
other.pdf.ocr.max_pixels += 100;
other.pdf.ocr.request_timeout_secs += 10;
other.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:?}"
);
}
}
}

View File

@@ -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"
);

View File

@@ -0,0 +1,148 @@
//! 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.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);
}

View File

@@ -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 {

View File

@@ -19,16 +19,23 @@
//! `Sender` end is dropped (i.e. when `ingest_with_config_progress`
//! returns).
use std::collections::HashMap;
use std::io::{IsTerminal, Write};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
use kebab_app::IngestEvent;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::wire;
/// v0.26.1: number of slowest assets surfaced in the end-of-run summary.
/// Constant for now (spec defers the config knob).
const SLOWEST_TOP_N: usize = 5;
/// Rendering mode for `ProgressDisplay`. The mode is fixed at
/// construction — each `kebab ingest` invocation is a single mode
/// (chosen from `--json` plus `IsTerminal` detection).
@@ -65,11 +72,33 @@ impl ProgressMode {
pub struct ProgressDisplay {
mode: ProgressMode,
bar: Option<ProgressBar>,
/// v0.26.1 heartbeat: start `Instant` of the asset currently in
/// flight, shared with the bar's steady-tick custom template key so
/// the `(Ns)` elapsed counter advances *between* events (the drain
/// loop blocks on `recv()`, so without the ticker the counter would
/// freeze). `None` while scanning / between assets / after completion.
asset_start: Arc<Mutex<Option<Instant>>>,
/// v0.26.1: workspace path of the asset currently in flight — set on
/// `AssetStarted`, reused by `AssetPhase` to render `{path} · {phase}…`.
current_path: Option<String>,
/// v0.26.1 slowest summary: idx → path, captured from `AssetStarted`
/// so `AssetTimings` (which only carries `idx`) can name the asset.
asset_paths: HashMap<u32, String>,
/// v0.26.1 slowest summary: (path, total_ms) per asset that reported
/// `AssetTimings`. Sorted + truncated to top-N on `Completed`.
timings: Vec<(String, u64)>,
}
impl ProgressDisplay {
pub fn new(mode: ProgressMode) -> Self {
Self { mode, bar: None }
Self {
mode,
bar: None,
asset_start: Arc::new(Mutex::new(None)),
current_path: None,
asset_paths: HashMap::new(),
timings: Vec::new(),
}
}
/// Block until `rx` returns `Err` (sender dropped). Renders one
@@ -120,15 +149,43 @@ impl ProgressDisplay {
}
IngestEvent::ScanCompleted { total } => {
if let Some(bar) = self.bar.as_mut() {
bar.disable_steady_tick();
bar.set_length(u64::from(*total));
bar.set_position(0);
// v0.26.1: a custom `{asset_elapsed}` key reads the shared
// per-asset start `Instant` and appends ` (Ns)`. Combined
// with the steady tick below, the elapsed counter advances
// even while the drain loop is blocked on `recv()` waiting
// for the next (possibly very slow) phase event.
let asset_start = Arc::clone(&self.asset_start);
bar.set_style(
ProgressStyle::with_template("ingest [{bar:30}] {pos}/{len} {wide_msg}")
.unwrap()
.progress_chars("=> "),
ProgressStyle::with_template(
"ingest [{bar:30}] {pos}/{len} {wide_msg}{asset_elapsed}",
)
.unwrap()
.with_key(
"asset_elapsed",
move |_: &ProgressState, w: &mut dyn std::fmt::Write| {
if let Ok(guard) = asset_start.lock()
&& let Some(started) = *guard
{
let secs = started.elapsed().as_secs();
// Only show once the asset has been running
// a moment — avoids `(0s)` flicker on fast
// assets.
if secs >= 1 {
let _ = write!(w, " ({secs}s)");
}
}
},
)
.progress_chars("=> "),
);
bar.set_message("");
if tty && !quiet {
bar.enable_steady_tick(std::time::Duration::from_secs(1));
} else {
bar.disable_steady_tick();
}
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
@@ -141,11 +198,22 @@ impl ProgressDisplay {
path,
media,
} => {
// v0.26.1: remember the path so AssetPhase can render it and
// the slowest summary (keyed by idx in AssetTimings) can name
// the asset.
self.current_path = Some(path.clone());
self.asset_paths.insert(*idx, path.clone());
// v0.26.1: (re)start the per-asset heartbeat clock.
if let Ok(mut guard) = self.asset_start.lock() {
*guard = Some(Instant::now());
}
if let Some(bar) = self.bar.as_ref() {
// One draw per file: position only. set_message() would
// trigger a second independent draw and pollute TTY scrollback.
// Filename is visible in the non-TTY plain-line path below.
bar.set_position(u64::from(idx.saturating_sub(1)));
// v0.26.1: show the current filename on the bar (TTY).
// Previously position-only — the interactive user couldn't
// tell which file was in flight. The steady tick redraws
// in place, so this no longer pollutes scrollback.
bar.set_message(abbreviate_path(path));
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
@@ -154,8 +222,35 @@ impl ProgressDisplay {
}
IngestEvent::AssetFinished { .. } => {
// Position is advanced in AssetStarted; bar.finish_and_clear()
// in Completed handles the final state. No per-asset bar update
// here avoids the duplicate-frame artifact in TTY scrollback.
// in Completed handles the final state. v0.26.1: stop the
// heartbeat clock so the bar doesn't show a stale `(Ns)` in the
// gap before the next AssetStarted.
if let Ok(mut guard) = self.asset_start.lock() {
*guard = None;
}
self.current_path = None;
}
// v0.26.1: an asset entered a slow internal phase (ocr / caption /
// embed). Surface which phase + model is running so a multi-second
// vision-model call no longer looks frozen.
IngestEvent::AssetPhase {
idx,
total,
phase,
model,
} => {
let label = match model {
Some(m) => format!("{phase}({m})"),
None => phase.clone(),
};
if let Some(bar) = self.bar.as_ref() {
let path = self.current_path.as_deref().unwrap_or("");
bar.set_message(format!("{} · {label}", abbreviate_path(path)));
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "ingest: {idx}/{total} · {label}…");
}
}
// v0.24.0: asset-internal phase visibility. AssetChunked uses the
// bar *message* (live sub-progress for the current asset) —
@@ -172,31 +267,50 @@ impl ProgressDisplay {
}
}
IngestEvent::AssetTimings {
idx,
parse_ms,
chunk_ms,
embed_ms,
store_ms,
ocr_ms,
caption_ms,
..
} => {
// v0.26.1: accumulate (path, total_ms) for the slowest summary.
// total = every measured phase (expansion_ms is always 0).
let total_ms = parse_ms + chunk_ms + embed_ms + store_ms + ocr_ms + caption_ms;
if let Some(path) = self.asset_paths.get(idx) {
self.timings.push((path.clone(), total_ms));
}
if let Some(bar) = self.bar.as_ref() {
bar.set_message("");
}
if !quiet {
let mut err = std::io::stderr().lock();
let _ = writeln!(
err,
" ⏱ parse {} · chunk {} · embed {} · store {}",
fmt_ms(*parse_ms),
fmt_ms(*chunk_ms),
fmt_ms(*embed_ms),
fmt_ms(*store_ms),
);
// v0.26.1: only print ocr / caption when they actually ran
// (markdown leaves them 0) so the text path stays uncluttered.
let mut parts = vec![
format!("parse {}", fmt_ms(*parse_ms)),
format!("chunk {}", fmt_ms(*chunk_ms)),
];
if *ocr_ms > 0 {
parts.push(format!("ocr {}", fmt_ms(*ocr_ms)));
}
if *caption_ms > 0 {
parts.push(format!("caption {}", fmt_ms(*caption_ms)));
}
parts.push(format!("embed {}", fmt_ms(*embed_ms)));
parts.push(format!("store {}", fmt_ms(*store_ms)));
let _ = writeln!(err, " ⏱ {}", parts.join(" · "));
}
}
IngestEvent::Completed { counts } => {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
}
if let Ok(mut guard) = self.asset_start.lock() {
*guard = None;
}
// Always emit summary in both TTY and non-TTY (unless quiet).
// Bug fix: previously TTY had no summary line after bar.finish_and_clear().
if !quiet {
@@ -206,6 +320,10 @@ impl ProgressDisplay {
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
counts.scanned, counts.new, counts.updated, counts.skipped, counts.errors,
);
// v0.26.1: slowest-asset summary. Useful in both TTY and
// non-TTY (it pinpoints the bottleneck file), so it prints
// unless --quiet. --json mode never reaches here (emit_json).
let _ = write_slowest_summary(&mut err, &self.timings, SLOWEST_TOP_N);
}
}
IngestEvent::Aborted { counts } => {
@@ -286,6 +404,48 @@ fn fmt_ms(ms: u64) -> String {
}
}
/// v0.26.1: shorten an over-long workspace path for the progress-bar
/// message so the live `(Ns)` heartbeat suffix stays visible on a narrow
/// terminal. Keeps the tail (filename + a couple of parents) — that's the
/// distinguishing part — and prefixes `…` when truncated. Paths up to the
/// budget pass through verbatim.
fn abbreviate_path(path: &str) -> String {
const MAX: usize = 48;
let char_count = path.chars().count();
if char_count <= MAX {
return path.to_string();
}
// Keep the last MAX-1 chars (1 reserved for the leading ellipsis).
let tail: String = path
.chars()
.skip(char_count - (MAX - 1))
.collect::<String>();
format!("{tail}")
}
/// v0.26.1: render the end-of-run "slowest assets" summary. Sorts
/// `(path, total_ms)` descending by time, takes the top `n`, and writes a
/// compact table to `w`. No-op (writes nothing) when `timings` is empty so
/// a run with no per-asset timing (e.g. all-skipped) prints no stray header.
fn write_slowest_summary(
w: &mut impl Write,
timings: &[(String, u64)],
n: usize,
) -> std::io::Result<()> {
if timings.is_empty() {
return Ok(());
}
let mut sorted: Vec<&(String, u64)> = timings.iter().collect();
// desc by ms; ties broken by path for deterministic output.
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let top = &sorted[..sorted.len().min(n)];
writeln!(w, "⏱ 최장 소요 top-{}:", top.len())?;
for (rank, (path, ms)) in top.iter().enumerate() {
writeln!(w, " {}. {} — {}", rank + 1, path, fmt_ms(*ms))?;
}
Ok(())
}
/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress`
/// so every emitted event carries an `ts` field per §2.4a / the wire schema.
pub(crate) fn now_rfc3339() -> anyhow::Result<String> {
@@ -348,4 +508,61 @@ mod tests {
// well-formed RFC 3339 string.
OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip");
}
#[test]
fn abbreviate_path_passes_short_paths_through() {
assert_eq!(abbreviate_path("notes/foo.md"), "notes/foo.md");
}
#[test]
fn abbreviate_path_keeps_tail_with_ellipsis() {
let long = "a/very/deeply/nested/directory/structure/that/exceeds/the/budget/file.md";
let out = abbreviate_path(long);
assert!(out.starts_with('…'), "should be prefixed with ellipsis: {out}");
assert!(out.ends_with("file.md"), "should keep the filename tail: {out}");
// 48-char budget: 1 ellipsis + 47 tail chars.
assert_eq!(out.chars().count(), 48);
}
#[test]
fn write_slowest_summary_empty_writes_nothing() {
let mut buf = Vec::new();
write_slowest_summary(&mut buf, &[], 5).unwrap();
assert!(buf.is_empty());
}
#[test]
fn write_slowest_summary_sorts_desc_and_truncates() {
let timings = vec![
("a.md".to_string(), 100),
("b.png".to_string(), 5_000),
("c.pdf".to_string(), 2_000),
("d.md".to_string(), 50),
];
let mut buf = Vec::new();
write_slowest_summary(&mut buf, &timings, 2).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("top-2:"), "{out}");
// b (5s) ranks first, c (2s) second; a/d excluded.
let b_pos = out.find("b.png").expect("b.png present");
let c_pos = out.find("c.pdf").expect("c.pdf present");
assert!(b_pos < c_pos, "b before c: {out}");
assert!(!out.contains("a.md"), "a.md excluded by top-2: {out}");
assert!(out.contains("5.0s"), "b renders as 5.0s: {out}");
}
#[test]
fn write_slowest_summary_tie_breaks_by_path() {
let timings = vec![
("z.md".to_string(), 1_000),
("a.md".to_string(), 1_000),
];
let mut buf = Vec::new();
write_slowest_summary(&mut buf, &timings, 5).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
out.find("a.md").unwrap() < out.find("z.md").unwrap(),
"equal ms ties break alphabetically: {out}"
);
}
}

View File

@@ -209,6 +209,13 @@ 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()

View File

@@ -160,7 +160,11 @@ fn apply_event(state: &mut IngestState, event: IngestEvent) {
// per-asset counters, not sub-asset phase progress, so these are
// no-ops here (the CLI / --json surfaces render them).
| IngestEvent::AssetChunked { .. }
| IngestEvent::AssetTimings { .. } => {}
| IngestEvent::AssetTimings { .. }
// v0.26.1 slow-phase hint (ocr / caption / embed): the CLI bar uses
// it for a live phase message; the TUI status-bar reducer tracks only
// per-asset counters, so it's a no-op here.
| IngestEvent::AssetPhase { .. } => {}
}
}

View File

@@ -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 설정 변경 시 영향 자산 자동 재색인`) → 리뷰 루프 → 사용자 머지.

View File

@@ -0,0 +1,33 @@
# Plan: ingest 로그 개선 구현
spec: `docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md`. 브랜치 `feat/ingest-log-improve`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target -j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 후 정리.
## Task 1 — wire 이벤트 (kebab-app/src/ingest_progress.rs)
- `IngestEvent``AssetPhase { idx: u32, total: u32, phase: String, model: Option<String> }` variant 추가(serde tag 규약 기존과 동일, snake `asset_phase`).
- `AssetTimings``ocr_ms: u64`, `caption_ms: u64` 필드 추가(기존 필드 뒤, serde default 0 → 구 소비자 호환).
- 직렬화 테스트 추가(asset_phase, 확장 timings).
## Task 2 — emit 지점 (kebab-app/src/lib.rs)
- 이미지 경로: `apply_ocr` 직전 `AssetPhase{phase:"ocr", model: <ocr model>}`, `apply_caption` 직전 `AssetPhase{phase:"caption", model: <llm model>}` emit. 각 호출 시간 측정 → `ocr_ms`/`caption_ms`.
- 임베딩 루프 진입 직전 `AssetPhase{phase:"embed", model: embedder.model_id}` emit(텍스트 포함 전 asset).
- `AssetTimings` 생성부에 ocr_ms/caption_ms 전달.
- 짧은 phase(parse/chunk/store)는 emit 안 함.
## Task 3 — CLI 렌더 (kebab-cli/src/progress.rs)
- **파일명**: AssetStarted TTY 핸들러 `bar.set_message(<path>)` (현재 위치-only 주석/로직 교체; path 길면 말미 축약). 비-TTY 줄 유지.
- **phase+모델**: AssetPhase 수신 → `bar.set_message(format!("{path} · {phase}({model})…"))`. 현재 path 를 핸들러 상태로 보관(AssetStarted 에서 저장).
- **heartbeat**: AssetStarted 에서 `Instant::now()` 보관 + `bar.enable_steady_tick(1s)` + 메시지 렌더에 경과초 `(Ns)`. AssetFinished/다음 AssetStarted 에서 리셋. (indicatif steady-tick + 커스텀 메시지.)
- **slowest 요약**: 핸들러에 `Vec<(path, total_ms)>` 누적 — AssetStarted 로 idx→path, AssetTimings 로 idx→sum(parse+chunk+embed+store+ocr+caption). `Completed` 수신 시 상위 5개 stderr 표 출력(`⏱ 최장 소요:`). `--json` 모드 미출력, quiet 여도 요약은 출력.
- `fmt_ms`(기존) 재사용.
## Task 4 — wire schema + 문서
- `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind(phase enum, model) + `ocr_ms`/`caption_ms` 필드 추가(additive). verbatim 일치.
- README(있으면 진행 표시 한 줄), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump(+Cargo.lock).
## Task 5 — 검증
- clippy 0, 전체 test 통과(기존 progress 테스트 갱신).
- 스모크: 이미지/PDF 포함 임시 폴더 ingest → TTY 파일명+phase+모델+경과, 종료 top-N. 비-TTY 줄+요약. `--json` ndjson(asset_phase/ocr_ms) 확인, 사람텍스트 미혼입.
- 결과 요약 `/tmp/ingestlog-result.md`(게이트 + 스모크 캡처).
## 리뷰 루프
완료 → 리더 clippy/test 독립 재확인 → `gitea-pr`(title `feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약`) → 리뷰 루프 → 사용자 머지. 머지 후 Obsidian 볼트 도그푸딩.

View File

@@ -0,0 +1,60 @@
# Spec: ingest 로그 개선 (파일명·phase·heartbeat·slowest 요약)
**날짜**: 2026-06-03
**유형**: feature (관측성/UX, additive wire)
**근거**: arctic 도그푸딩 중 Obsidian 볼트(이미지/PDF 혼재 + OCR/caption on)에서 ingest 가 중간부터 느려졌는데, **TTY 진행바가 파일명·현재 phase·모델·경과시간을 안 보여줘** "멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 로그만으로 파악 불가. v0.24.0 상세 진행 로깅의 후속 — 느린 phase(특히 이미지 OCR/caption)와 병목 파일을 가시화한다.
## 현재 한계 (코드 근거)
- `kebab-cli/src/progress.rs:145` — TTY 에서 AssetStarted 는 **위치만 갱신, 파일명 메시지 미설정**(의도적; 비-TTY 줄에만 파일명). → 인터랙티브 실행 시 현재 파일 안 보임.
- 이미지 **OCR/caption 진행 이벤트 없음**`PdfOcrStarted/Finished`(PDF 페이지)만 존재. 이미지 OCR/caption(gemma 비전, 느림)은 무이벤트 → 진행바 정지처럼 보임(`lib.rs` apply_ocr/apply_caption 호출 주변).
- 한 asset 이 오래 걸려도 **경과시간 heartbeat 없음**(완료 후 `AssetTimings` ⏱ 한 번).
- 병목 파일을 **사후 파악할 요약 없음**.
## 목표 (사용자 결정: 1+2+3+4)
1. **파일명**을 TTY 진행바 메시지에 표시.
2. 느린 **phase(OCR/caption/embed) + 모델명** 실시간 표시.
3. 현재 asset **경과시간 heartbeat**.
4. 종료 시 **가장 오래 걸린 파일 top-N 요약**.
## 작업
### A. wire 이벤트 (additive, ingest_progress.v1)
- **신규 `AssetPhase { idx, total, phase, model }`** — asset 이 느린 phase 진입 시 emit. `phase: &str` ∈ {`"ocr"`,`"caption"`,`"embed"`}; `model: Option<String>`(그 phase 를 수행하는 모델 — OCR/caption=비전 LLM 모델 id, embed=임베더 model_id). 짧은 phase(parse/chunk/store)는 emit 안 함(노이즈 방지).
- **`AssetTimings` 확장**: `ocr_ms`, `caption_ms` 필드 추가(additive, 기본 0). 기존 parse/chunk/embed/store/expansion_ms 유지. → top-N 요약의 정확한 per-asset 총시간 계산 근거.
- `PdfOcrStarted/Finished`(기존) 유지 — PDF 페이지 단위 진행은 이미 있음.
- wire schema `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind + `phase`/`model` + `ocr_ms`/`caption_ms` 필드 문서화(additive, v1 유지).
### B. emit 지점 (kebab-app)
- `ingest_one_asset` / 이미지·미디어 경로(`apply_ocr`/`apply_caption` 호출 직전, `lib.rs:~1568/1586`): 각각 `AssetPhase{phase:"ocr"|"caption", model}` emit. 임베딩 루프 진입 시 `AssetPhase{phase:"embed", model:embedder.model_id}` emit(텍스트 asset 도 적용).
- OCR/caption 소요를 측정해 `AssetTimings.ocr_ms`/`caption_ms` 채움.
### C. CLI 렌더 (kebab-cli/src/progress.rs)
1. **파일명**: AssetStarted TTY 핸들러에서 `bar.set_message(<path 축약>)`(현재 위치-only 주석 제거). 비-TTY 줄은 그대로.
2. **phase+모델**: AssetPhase 수신 시 `bar.set_message("{path} · {phase}({model})…")`.
3. **heartbeat**: AssetStarted 에서 현재 asset 시작 시각 기록 + steady-tick(예: 1s)으로 메시지 끝에 `(Ns)` 경과 갱신. asset 전환/완료 시 리셋.
4. **slowest 요약**: AssetStarted(idx→path) + AssetTimings(idx→총ms=parse+chunk+embed+store+ocr+caption) 를 누적, `Completed` 수신 시 stderr 에 `⏱ 최장 소요 top-N`(기본 N=5) 표 출력. 비-TTY/quiet 에서도 요약은 출력(유용), `--json` 모드는 미출력(ndjson 오염 방지).
### 결정 사항
- 모두 **additive wire**`ingest_progress.v1` 유지(major bump 없음). 신규 소비자는 `asset_phase` 부재 허용.
- AssetPhase 는 **emit 스로틀 불필요**(asset·phase 당 1회, 빈도 낮음). PDF 페이지 OCR 은 기존 PdfOcrStarted 가 담당(페이지 많으면 그쪽 스로틀은 별도 — 본 spec 비범위).
- top-N 의 N: 상수 5(후속에 config 화 가능, 본 spec 비범위).
- `--quiet` 시 진행바·phase 메시지는 억제하되 **slowest 요약은 출력**(짧고 유용). `--json` 은 전부 ndjson 으로만.
## 검증 기준
- clippy 0 / 전체 test 통과(기존 진행 렌더 테스트 갱신 + 신규 이벤트 직렬화 테스트).
- TTY 스모크: 이미지/PDF 포함 폴더 ingest 시 진행바에 **파일명 + OCR/caption/embed phase + 모델 + 경과초** 표시, 종료 시 **top-N 요약**.
- 비-TTY: 기존 줄 로그 유지 + 종료 요약.
- `--json`: `asset_phase`/확장 `asset_timings` ndjson 출력, 사람용 텍스트 미혼입.
- wire schema 문서 동기화 + verbatim 일치(CI diff-check 있으면).
## 도그푸딩 (별도)
사용자 Obsidian 볼트(이미지/PDF + OCR on)로 재현 — 느린 구간에서 어떤 파일·phase·모델인지 즉시 보이는지, 종료 요약이 병목 파일을 짚는지 확인. HOTFIXES + release notes.
## 문서 동기화 (같은 PR)
- `docs/wire-schema/v1/ingest_progress.schema.json` (asset_phase, ocr_ms/caption_ms).
- README(진행 표시 설명 있으면 갱신, 명령표 영향 없음), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump.
## 비범위
- PDF 페이지 OCR 진행 스로틀/요약(기존 이벤트 유지).
- 모델 스와핑 자체 해결(그건 Ollama 설정/OCR off — 본 작업은 가시화만).
- top-N 의 config 화.

View File

@@ -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줄(선택).

View File

@@ -15,6 +15,7 @@
"asset_started",
"asset_finished",
"asset_chunked",
"asset_phase",
"asset_timings",
"embed_batch_started",
"embed_batch_finished",
@@ -36,11 +37,15 @@
"description": "asset_finished: per-asset outcome (mirrors `ingest_report.v1.items[].kind`)."
},
"chunks": { "type": "integer", "minimum": 0, "description": "asset_finished / asset_chunked (v0.24.0): chunk count produced for this asset." },
"parse_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Markdown path only." },
"chunk_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Markdown path only." },
"phase": { "type": "string", "enum": ["ocr", "caption", "embed"], "description": "asset_phase (v0.26.1): the slow internal phase the asset just entered. Short phases (parse/chunk/store) are not emitted." },
"model": { "type": ["string", "null"], "description": "asset_phase (v0.26.1): model performing the phase — vision LLM id for ocr/caption, embedder model_id for embed. null when the phase runs without a configured model." },
"parse_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Emitted by markdown / image / PDF paths." },
"chunk_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Emitted by markdown / image / PDF paths." },
"expansion_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): retained for wire compatibility but always 0 — doc-side expansion was removed (HOTFIXES 2026-06-03)." },
"embed_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): embed + vector phase wall-clock (ms) — embedding, vector upsert, and stale-vector purge. Markdown path only." },
"store_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): SQLite persist phase wall-clock (ms) — put_asset/document/blocks/chunks only. Markdown path only." },
"embed_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): embed + vector phase wall-clock (ms) — embedding, vector upsert, and stale-vector purge." },
"store_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): SQLite persist phase wall-clock (ms) — put_asset/document/blocks/chunks only." },
"ocr_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.26.1, additive, default 0): image/PDF OCR phase wall-clock (ms). 0 on the markdown path (no OCR)." },
"caption_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.26.1, additive, default 0): image caption phase wall-clock (ms). 0 on markdown / PDF paths." },
"n_chunks": { "type": "integer", "minimum": 0, "description": "embed_batch_started / embed_batch_finished: chunks in this embedding batch." },
"ms": { "type": "integer", "minimum": 0, "description": "embed_batch_finished / pdf_ocr_finished: wall-clock duration (ms). pdf_ocr_finished skip path 의 의미는 mixed (DCTDecode 부재 시 0, engine 실패 시 latency-before-bail)." },
"chars": { "type": "integer", "minimum": 0, "description": "pdf_ocr_finished: char count of OCR result. Skip 시 0." },

View File

@@ -14,6 +14,88 @@ 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-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 볼트에서
ingest 가 중간부터 느려졌는데, TTY 진행바가 **파일명·현재 phase·모델·경과시간**을 안 보여
"멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 진행 표시만으로 파악 불가. v0.24.0 상세
진행 로깅의 후속으로 느린 phase 와 병목 파일을 가시화했다. spec/plan:
`docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md` / `…/plans/2026-06-03-ingest-log-improve-plan.md`.
**무엇이 바뀌었나 (additive, `ingest_progress.v1` 유지 — major bump 없음).**
- 신규 wire 이벤트 `asset_phase { idx, total, phase, model }` — asset 이 느린 phase
(`ocr` / `caption` / `embed`) 진입 시 1회 emit. `model` 은 그 phase 의 모델 id
(ocr/caption = 비전 LLM, embed = 임베더 model_id), 없으면 `null`. 짧은 phase
(parse/chunk/store) 는 노이즈 방지로 미emit.
- `asset_timings``ocr_ms` / `caption_ms` 필드 추가 (serde `default` 0 → 구 소비자
호환). 이미지·PDF 경로도 이제 `asset_timings` 를 emit (이전엔 markdown 만) — slowest
요약이 비전-모델 병목을 정확히 집계.
- CLI 렌더(`kebab-cli/src/progress.rs`): AssetStarted 시 진행바 메시지에 파일명(긴 path 는
말미 축약), AssetPhase 시 `{path} · {phase}({model})…`, steady-tick 1s 커스텀 키로
경과초 `(Ns)` 라이브 갱신, `Completed` 시 stderr 에 `⏱ 최장 소요 top-5` 표.
`--quiet` 여도 요약은 출력, `--json` 은 ndjson 만(사람텍스트 미혼입).
**emit 지점.** `kebab-app/src/lib.rs` — 이미지 경로 `apply_ocr`/`apply_caption` 직전
+ ocr/caption 시간 측정, markdown/이미지/PDF 임베딩 루프 직전 `embed` phase, 각 경로
`asset_timings` 에 측정값 채움. PDF `ocr_ms` 는 기존 page-OCR 총합 재사용.
**알려진 한계.** code asset 경로는 진행 이벤트(AssetChunked/Timings) 무emit 이라 slowest
요약에 미포함(기존 동작 유지, 비범위). top-N 의 N=5 상수(config 화 비범위). PDF 페이지
OCR 진행은 기존 `pdf_ocr_started/finished` 가 담당(본 작업 비범위).
**도그푸딩 (별도).** 사용자 Obsidian 볼트(이미지/PDF + OCR on) 재현 — 느린 구간의
파일·phase·모델 즉시 가시 + 종료 요약이 병목 파일을 짚는지. release notes + 본 entry 갱신.
## 2026-06-03 — arctic-embed-l-v2.0 임베더 통합 (candle + Ollama) (v0.26.0)
**무엇을 왜 추가했나.** 별칭(doc-side expansion) 제거(v0.25.0) 후 설명형 query 의