feat(p3-5): app-wiring — kb-app facade bodies for ingest / search / list / inspect #19

Merged
altair823 merged 1 commits from feat/p3-5-app-wiring into main 2026-05-01 12:32:45 +00:00
Owner

변경 요약

P3-5 app-wiring 작업입니다. P0부터 frozen이었던 kb-app facade의 bail!(\"not yet wired\") stub 5개를 실제 라이브러리 호출로 교체합니다. 이 PR이 머지된 후에는 cargo run -p kb-cli -- index + cargo run -p kb-cli -- search가 실제로 동작합니다 — P1–P3에서 만든 라이브러리들이 처음으로 end-to-end로 연결됩니다. kb-app::ask는 stub 유지 — P4-3 owner.

무엇을 했는가

App lifecycle (crates/kb-app/src/app.rs)

pub(crate) struct AppConfig + Arc<SqliteStore>을 eagerly 보유하고, 비싼 자원 (embedder, LanceVectorStore)은 OnceLock<Arc<...>>으로 메모이즈합니다.

  • 첫 호출에서 ~470MB fastembed init + Lance open 비용을 한 번 지불.
  • 이후 호출은 Arc::clone으로 즉시 반환.
  • OnceLock::set race에서 진 caller는 get().cloned()로 fallback — 동시성 안전.

one-shot CLI invocation은 어차피 한 번만 호출하므로 영향 없지만, P9 TUI가 App을 세션 동안 들고 있을 때는 매번 ONNX 재로드 없이 재사용 가능.

ingest 파이프라인 (lib.rs)

design §1.2 그대로:

  1. FsSourceConnector::scan(&scope) → 워크스페이스 walk.
  2. asset마다: parse_frontmatterparse_blocksbuild_canonical_documentMdHeadingV1Chunker.chunkput_asset_with_bytesput_documentput_blocksput_chunks. design §5.8의 "한 ingest = 한 transaction" 원칙은 kb-store-sqlite의 put_* 메서드들이 자체 transaction으로 보장.
  3. embedding 옵션: provider != \"none\" && dimensions > 0일 때 embedder 한 번 빌드 + ensure_table 한 번 + 각 문서의 청크들을 Document kind로 임베딩 → LanceVectorStore::upsert (P3-3의 두 단계 트랜잭션이 알아서 처리).
  4. asset 단위 parse 실패는 IngestItemKind::Error로 기록되고 run은 계속 — 구조적 실패 (DB 연결 실패 등)만 abort.
  5. aggregate counts (assets_scanned / documents_new / _updated / _skipped / _errors / chunks_indexed / embeddings_indexed / duration_ms)가 JobRepo::update_progressprogress_json별도의 ingest_runs 양쪽에 기록. summary_only=trueitems_json=NULL이지만 count 컬럼은 그대로.

search dispatch

match query.mode {
    SearchMode::Lexical => LexicalRetriever::search(...)
    SearchMode::Vector  => VectorRetriever::search(...)   // requires embedder + vector_store
    SearchMode::Hybrid  => HybridRetriever::search(...)   // composes both
}

Vector/Hybrid 모드를 provider=\"none\" config에서 호출하면 "models.embedding.provider을 fastembed 등으로 바꾸세요"라는 명시적 에���.

list_docs / inspect_doc / inspect_chunk

DocumentStore trait 메서드로 직접 위임. not-found는 actionable 에러 메시지.

kb-store-sqlite 확장

리뷰에서 "JobRepo 페이로드에 aggregate counts가 없다"는 MUST-FIX를 받아, JobRepo::update_progress만으로는 spec의 ingest_runs 행 요구를 충족할 수 없다고 판단해 다음을 추가:

  • pub struct IngestRunRow — aggregate count 필드들 + scope/started_at/finished_at/items_json.
  • pub fn record_ingest_run on SqliteStoreingest_runs 행을 직접 INSERT.

jobs 테이블은 여전히 JobRepo의 create/update_progress/finish trio를 받아서 진행 상태를 표현하고, ingest_runs는 종료 시점의 aggregate를 별도로 저장. design §5.7의 두 테이블이 의도대로 분리됩니다.

테스트

  • default 라인 11건: round-trip, idempotent, summary_only_drops_items, provider_none_skips_lance (lance 디렉토리가 디스크에 생성되지 않는지 검증), records_ingest_runs_row_with_aggregate_counts, tags_any 필터, inspect_*_not_found, lexical 검색 hits with embedding_model=None, empty query short-circuit, vector_mode_with_provider_none clear error.
  • #[ignore] AVX 라인 2건: Hybrid 모드 end-to-end + Vector 모드의 embedding_model = Some(\"multilingual-e5-small\") assertion. AVX-capable VM (host-passthrough)에서 두 테스트 모두 ~21초 만에 통과 (첫 실행 시 fastembed 모델 다운로드).
  • TestEnvworkspace.root + storage.data_dir + storage.model_dir을 모두 TempDir로 pin해서 사용자 ~/.local/share 오염 차단.
  • 픽스처 워크스페이스: intro.md (rust 태그) + notes/cargo.md (rust+cargo) + notes/python.md (python) — tags_any=[\"rust\"] 필터가 "3개 중 2개"라는 non-trivial 결과를 검증.
  • 워크스페이스 전체: 269 passed / 24 ignored / 0 failed (이전 261 / 22). cargo clippy --workspace --all-targets -- -D warnings clean.
  • CLI smoke 수동 검증: cargo run -p kb-cli -- index / search / list docs 모두 실제 출력 반환.

사용 예시

P3-5 머지 후:

# 워크스페이스 인덱싱
$ cargo run -p kb-cli -- index
{ \"schema_version\": \"ingest_report.v1\", \"assets_scanned\": 3, \"chunks_indexed\": 7, ... }

# lexical 검색
$ cargo run -p kb-cli -- search \"cargo\" --mode lexical
[hit 1] notes/cargo.md#L3 (score 0.42) ...

# hybrid 검색 (AVX 필요)
$ cargo run -p kb-cli -- search \"package manager\" --mode hybrid
[hit 1] notes/cargo.md#L3-L7 (fusion 0.032) lex_rank=1 vec_rank=2 ...

# 인덱싱 결과 둘러보기
$ cargo run -p kb-cli -- list docs
intro.md / notes/cargo.md / notes/python.md

의존성

Allowed deps (spec §): kb-source-fs, kb-parse-md, kb-parse-types, kb-normalize, kb-chunk, kb-store-sqlite, kb-search, kb-store-vector, kb-embed, kb-embed-local + 기존 tracing, anyhow, serde, toml, dirs. 추가 forced: blake3 (run_id 생성), time (timestamp).

Forbidden deps (kb-llm*, kb-rag, kb-tui, kb-desktop, kb-parse-{pdf,image,audio}) 어느 것도 cargo tree -p kb-app에 없음.

변경 파일

  • crates/kb-app/src/app.rs (신규 — App lifecycle)
  • crates/kb-app/src/lib.rs (5 stub 본체 교체)
  • crates/kb-app/Cargo.toml (P1–P3 deps + blake3/time)
  • crates/kb-app/tests/{common/mod.rs, ingest_lexical.rs, search_lexical.rs, search_vector.rs} (신규)
  • crates/kb-app/tests/fixtures/workspace/{intro.md, notes/cargo.md, notes/python.md} (신규)
  • crates/kb-store-sqlite/src/jobs.rs (IngestRunRow + record_ingest_run)
  • crates/kb-store-sqlite/src/lib.rs (re-export)
  • Cargo.lock

후속 작업 후보

  • kb-app::ask body — P4-3 (RAG pipeline)이 owner.
  • kb index --resume checkpointing — P+.
  • kb index --watch — P+.
  • parser_version 리터럴을 kb-parse-md::PARSER_VERSION_LABEL로 promote — 현재 kb-app/kb-core/kb-store-sqlite tests��� hardcoded.
  • list_documents 내부의 N+1 태그 fetch — 10k 문서 워크스페이스 시점에 검토.

design §1.2, §1.5, §1.6, §7 참고. 본 PR로 P1–P3가 완전히 wired되어 사용자 검증 가능한 상태가 됩니다.

## 변경 요약 P3-5 app-wiring 작업입니다. P0부터 frozen이었던 `kb-app` facade의 `bail!(\"not yet wired\")` stub 5개를 실제 라이브러리 호출로 교체합니다. 이 PR이 머지된 후에는 **`cargo run -p kb-cli -- index` + `cargo run -p kb-cli -- search`가 실제로 동작**합니다 — P1–P3에서 만든 라이브러리들이 처음으로 end-to-end로 연결됩니다. `kb-app::ask`는 stub 유지 — P4-3 owner. ## 무엇을 했는가 ### `App` lifecycle (`crates/kb-app/src/app.rs`) `pub(crate) struct App`이 `Config` + `Arc<SqliteStore>`을 eagerly 보유하고, 비싼 자원 (`embedder`, `LanceVectorStore`)은 `OnceLock<Arc<...>>`으로 메모이즈합니다. - 첫 호출에서 ~470MB fastembed init + Lance open 비용을 한 번 지불. - 이후 호출은 `Arc::clone`으로 즉시 반환. - `OnceLock::set` race에서 진 caller는 `get().cloned()`로 fallback — 동시성 안전. one-shot CLI invocation은 어차피 한 번만 호출하므로 영향 없지만, P9 TUI가 `App`을 세션 동안 들고 있을 때는 매번 ONNX 재로드 없이 재사용 가능. ### `ingest` 파이프라인 (`lib.rs`) design §1.2 그대로: 1. `FsSourceConnector::scan(&scope)` → 워크스페이스 walk. 2. asset마다: `parse_frontmatter` → `parse_blocks` → `build_canonical_document` → `MdHeadingV1Chunker.chunk` → `put_asset_with_bytes` → `put_document` → `put_blocks` → `put_chunks`. design §5.8의 \"한 ingest = 한 transaction\" 원칙은 kb-store-sqlite의 `put_*` 메서드들이 자체 transaction으로 보장. 3. embedding 옵션: `provider != \"none\" && dimensions > 0`일 때 embedder 한 번 빌드 + `ensure_table` 한 번 + 각 문서의 청크들을 Document kind로 임베딩 → `LanceVectorStore::upsert` (P3-3의 두 단계 트랜잭션이 알아서 처리). 4. asset 단위 parse 실패는 `IngestItemKind::Error`로 기록되고 run은 계속 — 구조적 실패 (DB 연결 실패 등)만 abort. 5. aggregate counts (`assets_scanned` / `documents_new` / `_updated` / `_skipped` / `_errors` / `chunks_indexed` / `embeddings_indexed` / `duration_ms`)가 `JobRepo::update_progress`의 `progress_json`과 **별도의 `ingest_runs` 행** 양쪽에 기록. `summary_only=true`는 `items_json=NULL`이지만 count 컬럼은 그대로. ### `search` dispatch ```rust match query.mode { SearchMode::Lexical => LexicalRetriever::search(...) SearchMode::Vector => VectorRetriever::search(...) // requires embedder + vector_store SearchMode::Hybrid => HybridRetriever::search(...) // composes both } ``` `Vector`/`Hybrid` 모드를 `provider=\"none\"` config에서 호출하면 \"`models.embedding.provider`을 fastembed 등으로 바꾸세요\"라는 명시적 에���. ### `list_docs` / `inspect_doc` / `inspect_chunk` `DocumentStore` trait 메서드로 직접 위임. not-found는 actionable 에러 메시지. ### kb-store-sqlite 확장 리뷰에서 \"`JobRepo` 페이로드에 aggregate counts가 없다\"는 MUST-FIX를 받아, `JobRepo::update_progress`만으로는 spec의 `ingest_runs` 행 요구를 충족할 수 없다고 판단해 다음을 추가: - `pub struct IngestRunRow` — aggregate count 필드들 + scope/started_at/finished_at/items_json. - `pub fn record_ingest_run` on `SqliteStore` — `ingest_runs` 행을 직접 INSERT. `jobs` 테이블은 여전히 `JobRepo`의 create/update_progress/finish trio를 받아서 진행 상태를 표현하고, `ingest_runs`는 종료 시점의 aggregate를 별도로 저장. design §5.7의 두 테이블이 의도대로 분리됩니다. ## 테스트 - **default 라인 11건**: round-trip, idempotent, summary_only_drops_items, provider_none_skips_lance (lance 디렉토리가 디스크에 생성되지 않는지 검증), records_ingest_runs_row_with_aggregate_counts, tags_any 필터, inspect_*_not_found, lexical 검색 hits with `embedding_model=None`, empty query short-circuit, vector_mode_with_provider_none clear error. - **`#[ignore]` AVX 라인 2건**: Hybrid 모드 end-to-end + Vector 모드의 `embedding_model = Some(\"multilingual-e5-small\")` assertion. AVX-capable VM (host-passthrough)에서 두 테스트 모두 ~21초 만에 통과 (첫 실행 시 fastembed 모델 다운로드). - `TestEnv`가 `workspace.root` + `storage.data_dir` + `storage.model_dir`을 모두 `TempDir`로 pin해서 사용자 `~/.local/share` 오염 차단. - 픽스처 워크스페이스: `intro.md` (rust 태그) + `notes/cargo.md` (rust+cargo) + `notes/python.md` (python) — `tags_any=[\"rust\"]` 필터가 \"3개 중 2개\"라는 non-trivial 결과를 검증. - 워크스페이스 전체: **269 passed / 24 ignored / 0 failed** (이전 261 / 22). `cargo clippy --workspace --all-targets -- -D warnings` clean. - CLI smoke 수동 검증: `cargo run -p kb-cli -- index` / `search` / `list docs` 모두 실제 출력 반환. ## 사용 예시 P3-5 머지 후: ```bash # 워크스페이스 인덱싱 $ cargo run -p kb-cli -- index { \"schema_version\": \"ingest_report.v1\", \"assets_scanned\": 3, \"chunks_indexed\": 7, ... } # lexical 검색 $ cargo run -p kb-cli -- search \"cargo\" --mode lexical [hit 1] notes/cargo.md#L3 (score 0.42) ... # hybrid 검색 (AVX 필요) $ cargo run -p kb-cli -- search \"package manager\" --mode hybrid [hit 1] notes/cargo.md#L3-L7 (fusion 0.032) lex_rank=1 vec_rank=2 ... # 인덱싱 결과 둘러보기 $ cargo run -p kb-cli -- list docs intro.md / notes/cargo.md / notes/python.md ``` ## 의존성 Allowed deps (spec §): `kb-source-fs`, `kb-parse-md`, `kb-parse-types`, `kb-normalize`, `kb-chunk`, `kb-store-sqlite`, `kb-search`, `kb-store-vector`, `kb-embed`, `kb-embed-local` + 기존 `tracing`, `anyhow`, `serde`, `toml`, `dirs`. 추가 forced: `blake3` (run_id 생성), `time` (timestamp). Forbidden deps (`kb-llm*`, `kb-rag`, `kb-tui`, `kb-desktop`, `kb-parse-{pdf,image,audio}`) 어느 것도 `cargo tree -p kb-app`에 없음. ## 변경 파일 - `crates/kb-app/src/app.rs` (신규 — `App` lifecycle) - `crates/kb-app/src/lib.rs` (5 stub 본체 교체) - `crates/kb-app/Cargo.toml` (P1–P3 deps + blake3/time) - `crates/kb-app/tests/{common/mod.rs, ingest_lexical.rs, search_lexical.rs, search_vector.rs}` (신규) - `crates/kb-app/tests/fixtures/workspace/{intro.md, notes/cargo.md, notes/python.md}` (신규) - `crates/kb-store-sqlite/src/jobs.rs` (`IngestRunRow` + `record_ingest_run`) - `crates/kb-store-sqlite/src/lib.rs` (re-export) - `Cargo.lock` ## 후속 작업 후보 - `kb-app::ask` body — P4-3 (RAG pipeline)이 owner. - `kb index --resume` checkpointing — P+. - `kb index --watch` — P+. - `parser_version` 리터럴을 `kb-parse-md::PARSER_VERSION_LABEL`로 promote — 현재 kb-app/kb-core/kb-store-sqlite tests��� hardcoded. - `list_documents` 내부의 N+1 태그 fetch — 10k 문서 워크스페이스 시점에 검토. design §1.2, §1.5, §1.6, §7 참고. 본 PR로 P1–P3가 완전히 wired되어 사용자 검증 가능한 상태가 됩니다.
altair823 added 1 commit 2026-05-01 12:13:41 +00:00
Replaces the P0 `bail!("not yet wired")` stubs in kb-app with real
bodies that compose the libraries shipped through P3-4. After this
commit, `cargo run -p kb-cli -- index` actually walks the workspace
and persists chunks (SQLite + optionally LanceDB), and
`cargo run -p kb-cli -- search --mode {lexical,vector,hybrid}` returns
real SearchHits with citations. `kb-app::ask` stays stubbed; P4-3
owns it.

App lifecycle (crates/kb-app/src/app.rs):
- Internal pub(crate) struct App holds the Config plus
  Arc<SqliteStore> eagerly, with embedder + LanceVectorStore behind
  OnceLock<Arc<...>> for memoization. First call pays the ~470MB
  fastembed init / Lance open; subsequent calls return the cached
  Arc::clone. OnceLock::set race losers fall back to get().cloned()
  so the lazy-init is concurrent-safe.
- One-shot CLI invocations pay the cost once at most. The P9 TUI
  (which holds an App for the session) gets memoization for free.

ingest pipeline (lib.rs):
- FsSourceConnector::scan(&scope) → per asset:
  parse_frontmatter → parse_blocks → build_canonical_document →
  MdHeadingV1Chunker.chunk → put_asset_with_bytes → put_document →
  put_blocks → put_chunks. One transaction per document per design
  §5.8 (kb-store-sqlite's put_* methods own the transactions).
- When provider != "none" and dimensions > 0: build embedder once,
  embed each doc's chunks as Document kind, ensure_table once at the
  top of the run, then upsert the VectorRecord batch. Lexical-only
  config (provider == "none") skips both — verified by
  ingest_provider_none_skips_lance test.
- Per-asset parse failures recorded as IngestItemKind::Error with
  the warning attached; the run continues. Only structural failures
  (DB unreachable etc.) abort.
- Aggregate counts (assets_scanned / new / updated / skipped /
  errors / chunks_indexed / embeddings_indexed / duration_ms) flow
  into both the JobRepo progress_json AND a dedicated ingest_runs
  row written via SqliteStore::record_ingest_run (new
  pub(crate) helper added to kb-store-sqlite — see below).
  summary_only=true writes items_json=NULL but still populates the
  count columns.

search dispatch:
- SearchMode::Lexical → LexicalRetriever directly.
- SearchMode::Vector → VectorRetriever with embedder + LanceVectorStore.
- SearchMode::Hybrid → HybridRetriever composing the two.
- Vector / Hybrid with provider=none returns a clear error naming the
  config key to flip ("models.embedding.provider").

list_docs / inspect_doc / inspect_chunk delegate straight to
DocumentStore trait methods. Returns Err with actionable message on
not-found.

Test seam: each public free function has a matching
#[doc(hidden)] pub fn *_with_config(cfg, ...) companion that
integration tests invoke directly (the public form internally calls
load_config()). pub(crate) would not reach across the integration-
tests crate boundary; #[doc(hidden)] keeps it out of rustdoc and the
function comment flags it as test-only.

kb-store-sqlite additions:
- pub struct IngestRunRow + pub fn record_ingest_run on SqliteStore
  for the kb-app aggregate-counts persistence path. Helper writes
  the ingest_runs row directly with all aggregate columns; jobs
  table still gets a JobRepo create/update_progress/finish trio in
  parallel.

Tests (11 default, 2 #[ignore] AVX-gated):
- ingest_lexical: round-trip, idempotent, summary_only_drops_items,
  provider_none_skips_lance (asserts no .lance dir on disk),
  records_ingest_runs_row_with_aggregate_counts, tags_any filter,
  inspect_doc_not_found, inspect_chunk_not_found.
- search_lexical: lexical hits with embedding_model=None,
  empty_query_returns_empty, vector_mode_with_provider_none returns
  clear error.
- search_vector: hybrid mode end-to-end (#[ignore], AVX), Vector
  mode embedding_model assertion (#[ignore], AVX). Both run on the
  AVX VM in ~21s combined (first run pays the model download).
- TestEnv pins workspace.root + storage.{data_dir,model_dir} to a
  TempDir so tests don't touch the user's $HOME/.local/share.
- Fixture workspace at crates/kb-app/tests/fixtures/workspace/ has
  three small markdown files with varied frontmatter (rust+cargo+
  python tags) so the tags_any filter test exercises a non-trivial
  predicate.

Workspace 269 passed / 24 ignored / 0 failed (was 261/22). cargo
clippy --workspace --all-targets -- -D warnings clean. CLI smoke
verified manually: `cargo run -p kb-cli -- index` returns a real
IngestReport JSON; `cargo run -p kb-cli -- search "..."` returns
hits with citations; `cargo run -p kb-cli -- list docs` lists the
indexed documents.

Allowed deps respected: kb-source-fs, kb-parse-md, kb-parse-types,
kb-normalize, kb-chunk, kb-store-sqlite, kb-search, kb-store-vector,
kb-embed, kb-embed-local plus existing tracing / anyhow / serde /
toml / dirs and now blake3 (run_id) + time. Forbidden (kb-llm*,
kb-rag, kb-tui, kb-desktop, kb-parse-{pdf,image,audio}) absent from
cargo tree -p kb-app.

Out of scope per spec: ask body (P4-3), --rebuild-fts wiring,
--resume checkpointing (P+), --watch (P+), TUI / desktop integration
(P9 consumes this facade).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 reviewed 2026-05-01 12:14:39 +00:00
claude-reviewer-01 left a comment
Member

P3-5 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.

이번 PR은 P1–P3 라이브러리들을 처음으로 end-to-end 연결하는 작업이라 spec compliance + code quality 양쪽 리뷰에서 일정량의 MUST-FIX가 떴습니다 (BLOCKER는 없었음): JobRepo 페이로드 aggregate counts 누락, Vector 모드 테스트 누락, provider=none이 Lance를 실제로 건드리지 않는지 검증 부재, App.embedder/vector 비메모이즈, kb-cli의 wire wrapping 확인.

5개 MUST-FIX 모두 + 6개 NIT 반영했습니다:

  • aggregate counts: JobRepo progress_json + 별도 ingest_runs 행 (kb-store-sqlite에 IngestRunRow + record_ingest_run 헬퍼 추가) 양쪽에 기록.
  • Vector 모드 ignored 테스트 추가 — embedding_model = Some("multilingual-e5-small") 검증.
  • provider_none_skips_lance 테스트 — ".lance 디렉토리가 디스크에 안 만들어졌는지"로 부재 증명.
  • App을 OnceLock<Arc<...>>로 메모이즈 — P9 TUI 진입 시 footgun 차단.
  • kb-cli/src/wire.rs:36의 wire_ingest\"ingest_report.v1\" 라벨로 wrapping하고 있음을 cross-check 확인.

NITs: test-seam 코멘트, JobRepo failure 시 tracing::warn, per-asset tracing::debug, TestEnv의 model_dir override, AVX 테스트 runtime cost 코멘트, &emb.model_id() 임시값 정리.

워크스페이스 default 269 passed / 24 ignored / 0 failed. CLI smoke 수동 검증 완료 — cargo run -p kb-cli -- index + search가 실제 동작합니다.

inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다.

P3 단계가 본 PR로 완전히 닫힙니다. P4-1 진입 시점에 kb-app::ask는 stub 그대로 — P4-3이 owner.

P3-5 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only. 이번 PR은 P1–P3 라이브러리들을 처음으로 end-to-end 연결하는 작업이라 spec compliance + code quality 양쪽 리뷰에서 일정량의 MUST-FIX가 떴습니다 (BLOCKER는 없었음): JobRepo 페이로드 aggregate counts 누락, Vector 모드 테스트 누락, provider=none이 Lance를 실제로 건드리지 않는지 검증 부재, App.embedder/vector 비메모이즈, kb-cli의 wire wrapping 확인. 5개 MUST-FIX 모두 + 6개 NIT 반영했습니다: - aggregate counts: JobRepo progress_json + 별도 ingest_runs 행 (kb-store-sqlite에 `IngestRunRow` + `record_ingest_run` 헬퍼 추가) 양쪽에 기록. - Vector 모드 ignored 테스트 추가 — embedding_model = Some(\"multilingual-e5-small\") 검증. - provider_none_skips_lance 테스트 — \".lance 디렉토리가 디스크에 안 만들어졌는지\"로 부재 증명. - App을 OnceLock<Arc<...>>로 메모이즈 — P9 TUI 진입 시 footgun 차단. - kb-cli/src/wire.rs:36의 `wire_ingest`가 `\"ingest_report.v1\"` 라벨로 wrapping하고 있음을 cross-check 확인. NITs: test-seam 코멘트, JobRepo failure 시 tracing::warn, per-asset tracing::debug, TestEnv의 model_dir override, AVX 테스트 runtime cost 코멘트, &emb.model_id() 임시값 정리. 워크스페이스 default 269 passed / 24 ignored / 0 failed. CLI smoke 수동 검증 완료 — `cargo run -p kb-cli -- index` + `search`가 실제 동작합니다. inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. P3 단계가 본 PR로 완전히 닫힙니다. P4-1 진입 시점에 `kb-app::ask`는 stub 그대로 — P4-3이 owner.
@@ -0,0 +47,4 @@
pub(crate) struct App {
pub(crate) config: kb_config::Config,
pub(crate) sqlite: Arc<SqliteStore>,
/// Memoized embedder — built lazily on first `embedder()` call when

OnceLock 메모이즈 패턴이 정확합니다. 첫 호출에서 fastembed ~470MB ONNX init + Lance open 비용을 한 번 지불하고, 이후 호출은 Arc::clone으로 즉시 반환. set race에서 진 caller가 get().cloned()로 fallback하는 처리도 깔끔합니다. one-shot CLI에는 영향 없지만 P9 TUI가 App을 세션 동안 보유할 때 매번 ONNX 재로드되는 footgun을 미리 차단.

OnceLock 메모이즈 패턴이 정확합니다. 첫 호출에서 fastembed ~470MB ONNX init + Lance open 비용을 한 번 지불하고, 이후 호출은 Arc::clone으로 즉시 반환. set race에서 진 caller가 get().cloned()로 fallback하는 처리도 깔끔합니다. one-shot CLI에는 영향 없지만 P9 TUI가 App을 세션 동안 보유할 때 매번 ONNX 재로드되는 footgun을 미리 차단.
@@ -113,1 +150,3 @@
bail!("not yet wired (P1-5)")
/// Test-only seam — kb-cli must call the public free function
/// ([`ingest`]), not this. See module docs.
#[doc(hidden)]

#[doc(hidden)] pub fn *_with_config test seam — pub(crate)는 integration test 크레이트 경계를 못 넘어가므로 표준 Rust 우회. 각 함수에 "Test-only seam — kb-cli must call the public free function" 코멘트를 박아둬서 미래 누군가가 실수로 *_with_config를 직접 호출해 load_config()를 우회하는 시나리오를 차단.

`#[doc(hidden)] pub fn *_with_config` test seam — pub(crate)는 integration test 크레이트 경계를 못 넘어가므로 표준 Rust 우회. 각 함수에 "Test-only seam — kb-cli must call the public free function" 코멘트를 박아둬서 미래 누군가가 실수로 *_with_config를 직접 호출해 load_config()를 우회하는 시나리오를 차단.
@@ -114,0 +277,4 @@
}
items.push(item);
}

aggregate counts가 두 곳에 기록됩니다: JobRepo의 progress_json (jobs 테이블)과 별도 ingest_runs 행. 처음 리뷰에서 "JobRepo 페이로드에 aggregate counts가 빠졌다"는 MUST-FIX를 받고 나서 design §5.7의 두 테이블 분리 의도를 다시 보고 정리한 결정 — jobs는 진행 상태, ingest_runs는 종료 시점 aggregate. 두 표를 의도대로 활용합니다.

aggregate counts가 두 곳에 기록됩니다: JobRepo의 progress_json (jobs 테이블)과 별도 ingest_runs 행. 처음 리뷰에서 "JobRepo 페이로드에 aggregate counts가 빠졌다"는 MUST-FIX를 받고 나서 design §5.7의 두 테이블 분리 의도를 다시 보고 정리한 결정 — jobs는 진행 상태, ingest_runs는 종료 시점 aggregate. 두 표를 의도대로 활용합니다.
@@ -118,0 +412,4 @@
/// Mint a stable 32-hex-char `run_id` for an `ingest_runs` row.
/// `(scope, started_at_nanos)` is enough to make two runs with the
/// same scope started a nanosecond apart distinguish — same shape as
/// the JobId recipe in `kb-store-sqlite::jobs`.

asset 단위 parse 실패를 IngestItemKind::Error로 묶어 run을 계속 진행하는 처리가 정확합니다 — 한 파일이 망가져서 전체 ingest가 abort되면 부분 진행이 손실되니까. 구조적 실패(DB 연결 등)만 abort 경로로 분리한 것도 적절합니다. error count가 IngestReport의 aggregate에 정확히 한 번씩 increment되는지도 검증되어 있습니다.

asset 단위 parse 실패를 IngestItemKind::Error로 묶어 run을 계속 진행하는 처리가 정확합니다 — 한 파일이 망가져서 전체 ingest가 abort되면 부분 진행이 손실되니까. 구조적 실패(DB 연결 등)만 abort 경로로 분리한 것도 적절합니다. error count가 IngestReport의 aggregate에 정확히 한 번씩 increment되는지도 검증되어 있습니다.
@@ -122,0 +597,4 @@
asset_id: Some(asset.asset_id.clone()),
byte_len: Some(asset.byte_len),
block_count: u32::try_from(canonical.blocks.len()).ok(),
chunk_count: u32::try_from(chunks.len()).ok(),

Vector/Hybrid 모드를 provider=none config에서 호출하면 명시적 에러 + 어떤 config 키를 바꿔야 하는지 가이드. "feature unavailable" 같은 모호한 메시지가 아니라 caller가 즉시 행동 가능한 형태로 surface.

Vector/Hybrid 모드를 provider=none config에서 호출하면 명시적 에러 + 어떤 config 키를 바꿔야 하는지 가이드. "feature unavailable" 같은 모호한 메시지가 아니라 caller가 즉시 행동 가능한 형태로 surface.
@@ -0,0 +57,4 @@
config.workspace.exclude.clear();
config.storage.data_dir = data_dir.to_string_lossy().into_owned();
// Pin model_dir to the TempDir so a future fastembed-touching
// test can't accidentally write to the user's `~/.local/share`.

TestEnv가 workspace.root + storage.data_dir + storage.model_dir 세 가지 모두 TempDir로 pin. model_dir까지 override하는 게 핵심 — 향후 fastembed-touching 테스트가 추가될 때 사용자 ~/.local/share에 470MB을 silent하게 쌓아두는 사고 차단. P3-2에서 학습한 것을 재적용한 형태.

TestEnv가 workspace.root + storage.data_dir + storage.model_dir 세 가지 모두 TempDir로 pin. model_dir까지 override하는 게 핵심 — 향후 fastembed-touching 테스트가 추가될 때 사용자 ~/.local/share에 470MB을 silent하게 쌓아두는 사고 차단. P3-2에서 학습한 것을 재적용한 형태.
@@ -0,0 +1,220 @@
//! Integration tests for `kb-app::ingest` + `list_docs` + `inspect_*`

ingest_provider_none_skips_lance가 "lance 디렉토리가 디스크에 생성되지 않았는지"를 직접 검증. IngestReport에 embeddings_indexed 같은 필드가 없는 상황에서 "실제로 Lance 코드 경로가 안 탔다"를 어떻게 증명하느냐의 답이 됨 — 부재 증명이 가장 강력한 음성 결과.

ingest_provider_none_skips_lance가 "lance 디렉토리가 디스크에 생성되지 않았는지"를 직접 검증. IngestReport에 embeddings_indexed 같은 필드가 없는 상황에서 "실제로 Lance 코드 경로가 안 탔다"를 어떻게 증명하느냐의 답이 됨 — 부재 증명이 가장 강력한 음성 결과.

JobRepo trait가 jobs 테이블을 쓰도록 설계되어 있는데 ingest_runs는 별도 테이블 (design §5.7). spec이 "ingest_runs 행을 JobRepo로 기록"을 ambiguous하게 표현했지만 실제 schema에서는 두 표가 분리되어 있어서, kb-store-sqlite에 직접 pub fn record_ingest_run 헬퍼를 추가하는 쪽이 정확. spec의 fallback path ("private SqliteStore helper")도 같은 결정을 시사했습니다.

JobRepo trait가 jobs 테이블을 쓰도록 설계되어 있는데 ingest_runs는 별도 테이블 (design §5.7). spec이 "ingest_runs 행을 JobRepo로 기록"을 ambiguous하게 표현했지만 실제 schema에서는 두 표가 분리되어 있어서, kb-store-sqlite에 직접 `pub fn record_ingest_run` 헬퍼를 추가하는 쪽이 정확. spec의 fallback path ("private SqliteStore helper")도 같은 결정을 시사했습니다.
altair823 merged commit 18bb32caef into main 2026-05-01 12:32:45 +00:00
altair823 deleted branch feat/p3-5-app-wiring 2026-05-01 12:33:06 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#19