feat(p3-5): app-wiring — kb-app facade bodies for ingest / search / list / inspect #19
Reference in New Issue
Block a user
Delete Branch "feat/p3-5-app-wiring"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
변경 요약
P3-5 app-wiring 작업입니다. P0부터 frozen이었던
kb-appfacade의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.무엇을 했는가
Applifecycle (crates/kb-app/src/app.rs)pub(crate) struct App이Config+Arc<SqliteStore>을 eagerly 보유하고, 비싼 자원 (embedder,LanceVectorStore)은OnceLock<Arc<...>>으로 메모이즈합니다.Arc::clone으로 즉시 반환.OnceLock::setrace에서 진 caller는get().cloned()로 fallback — 동시성 안전.one-shot CLI invocation은 어차피 한 번만 호출하므로 영향 없지만, P9 TUI가
App을 세션 동안 들고 있을 때는 매번 ONNX 재로드 없이 재사용 가능.ingest파이프라인 (lib.rs)design §1.2 그대로:
FsSourceConnector::scan(&scope)→ 워크스페이스 walk.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으로 보장.provider != \"none\" && dimensions > 0일 때 embedder 한 번 빌드 +ensure_table한 번 + 각 문서의 청크들을 Document kind로 임베딩 →LanceVectorStore::upsert(P3-3의 두 단계 트랜잭션이 알아서 처리).IngestItemKind::Error로 기록되고 run은 계속 — 구조적 실패 (DB 연결 실패 등)만 abort.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 컬럼은 그대로.searchdispatchVector/Hybrid모드를provider=\"none\"config에서 호출하면 "models.embedding.provider을 fastembed 등으로 바꾸세요"라는 명시적 에���.list_docs/inspect_doc/inspect_chunkDocumentStoretrait 메서드로 직접 위임. 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_runonSqliteStore—ingest_runs행을 직접 INSERT.jobs테이블은 여전히JobRepo의 create/update_progress/finish trio를 받아서 진행 상태를 표현하고,ingest_runs는 종료 시점의 aggregate를 별도로 저장. design §5.7의 두 테이블이 의도대로 분리됩니다.테스트
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 결과를 검증.cargo clippy --workspace --all-targets -- -D warningsclean.cargo run -p kb-cli -- index/search/list docs모두 실제 출력 반환.사용 예시
P3-5 머지 후:
의존성
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(신규 —Applifecycle)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::askbody — P4-3 (RAG pipeline)이 owner.kb index --resumecheckpointing — 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되어 사용자 검증 가능한 상태가 됩니다.
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>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 반영했습니다:
IngestRunRow+record_ingest_run헬퍼 추가) 양쪽에 기록.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 whenOnceLock 메모이즈 패턴이 정확합니다. 첫 호출에서 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_configtest 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. 두 표를 의도대로 활용합니다.
@@ -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되는지도 검증되어 있습니다.
@@ -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.
@@ -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에서 학습한 것을 재적용한 형태.
@@ -0,0 +1,220 @@//! Integration tests for `kb-app::ingest` + `list_docs` + `inspect_*`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")도 같은 결정을 시사했습니다.