Compare commits

..

14 Commits

Author SHA1 Message Date
cf3acfc136 Merge pull request 'chore: bump version 0.4 → 0.5' (#130) from chore/bump-v0.5.0 into main
Reviewed-on: #130
2026-05-10 08:08:06 +00:00
th-kim0823
668e1174cc chore: bump version 0.4 → 0.5
v0.5.0 batches fb-32 (stale doc indicator) + fb-33 (streaming ask)
+ fb-34 (output budget controls) + fb-35 (verbatim fetch) + fb-36
(search filter args) + fb-37 (trace + stats).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:04:51 +09:00
745a75a82b Merge pull request 'feat(fb-37): trace + stats — search debug + KB health surface' (#129) from feat/fb-37-trace-and-stats into main
Reviewed-on: #129
2026-05-10 07:59:56 +00:00
th-kim0823
6a33d08aea fix(fb-37): address PR #129 round 1 review
- doc TraceFusionInput.fusion_score semantics (single-mode vs hybrid)
- comment why total_ms vs stage sum can drift (millis truncation)
- TODO marker on TUI trace popup filter passthrough

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:26:34 +09:00
th-kim0823
a40593590b docs(fb-37): wire schema + README + SMOKE + INDEX + SKILL 2026-05-10 14:13:47 +09:00
th-kim0823
5687cbc0e2 feat(tui): search pane t-key opens TracePopup (fb-37) 2026-05-10 13:39:11 +09:00
th-kim0823
653e432a30 feat(mcp): kebab__search trace input + output mirror (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:32:30 +09:00
th-kim0823
f7e2072d66 test(cli): integration tests for --trace + schema breakdowns (fb-37)
Also fixes App::search_with_opts trace branch to use NoopRetriever
for SearchMode::Lexical, removing the embeddings requirement when
the user only wants lexical-mode trace.
2026-05-10 13:21:33 +09:00
th-kim0823
72c227af23 feat(cli): kebab search --trace flag + wire trace + pretty print (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:08:48 +09:00
th-kim0823
69037c313a feat(app): SearchResponse.trace + opts.trace threading (fb-37)
Adds the `trace: Option<SearchTrace>` field to `SearchResponse` and
threads `SearchOpts.trace` through `App::search_with_opts`. When the
caller sets `opts.trace = true` the path bypasses the LRU search cache
and runs through `HybridRetriever::search_with_trace`, which dispatches
all 3 SearchModes internally; this means `--trace` requires embeddings
(same constraint as `--mode hybrid`). The non-trace path keeps its
exact prior behavior with `trace: None` stamped on the response.

Picked up Task 1 / Task 3 follow-ups in the same commit so the
workspace compiles: SearchOpts struct-literals in kebab-cli/main.rs +
kebab-mcp/tools/search.rs default the new `trace` field to false, and
the schema-wrapper test in kebab-cli/wire.rs fills the new
media_breakdown / lang_breakdown / index_bytes / stale_doc_count fields
on Stats with `Default::default()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:01:18 +09:00
th-kim0823
6a067e3ab1 feat(search): HybridRetriever::search_with_trace (fb-37) 2026-05-10 12:38:53 +09:00
th-kim0823
231d80e82d feat(stats): media/lang/bytes/stale fields on schema.v1.stats (fb-37)
Extends CountSummary with media_breakdown, lang_breakdown, stale_doc_count
fields populated via stats_ext::breakdowns(). Adds count_summary_with_threshold
for callers that need real stale counts. Mirrors all new fields onto the
wire-bound Stats struct in kebab-app::schema with #[serde(default)] for
backwards-compat. Also fixes search_budget_integration.rs for the trace field
added to SearchOpts in Task 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:34:57 +09:00
th-kim0823
69c6e23432 feat(store): breakdowns + index_bytes helpers (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:24:43 +09:00
th-kim0823
1e943f21dc feat(core): SearchTrace + IndexBytes types + SearchOpts.trace (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:17:04 +09:00
34 changed files with 1447 additions and 57 deletions

44
Cargo.lock generated
View File

@@ -3525,7 +3525,7 @@ dependencies = [
[[package]]
name = "kebab-app"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "kebab-chunk"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3584,7 +3584,7 @@ dependencies = [
[[package]]
name = "kebab-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"clap",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "kebab-config"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -3620,7 +3620,7 @@ dependencies = [
[[package]]
name = "kebab-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3634,7 +3634,7 @@ dependencies = [
[[package]]
name = "kebab-embed"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3648,7 +3648,7 @@ dependencies = [
[[package]]
name = "kebab-embed-local"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"fastembed",
@@ -3661,7 +3661,7 @@ dependencies = [
[[package]]
name = "kebab-eval"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -3680,7 +3680,7 @@ dependencies = [
[[package]]
name = "kebab-llm"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -3689,7 +3689,7 @@ dependencies = [
[[package]]
name = "kebab-llm-local"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-config",
@@ -3706,7 +3706,7 @@ dependencies = [
[[package]]
name = "kebab-mcp"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -3724,7 +3724,7 @@ dependencies = [
[[package]]
name = "kebab-normalize"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -3739,7 +3739,7 @@ dependencies = [
[[package]]
name = "kebab-parse-image"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"ab_glyph",
"anyhow",
@@ -3763,7 +3763,7 @@ dependencies = [
[[package]]
name = "kebab-parse-md"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -3780,7 +3780,7 @@ dependencies = [
[[package]]
name = "kebab-parse-pdf"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3793,7 +3793,7 @@ dependencies = [
[[package]]
name = "kebab-parse-types"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"kebab-core",
"serde",
@@ -3801,7 +3801,7 @@ dependencies = [
[[package]]
name = "kebab-rag"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3822,7 +3822,7 @@ dependencies = [
[[package]]
name = "kebab-search"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"globset",
@@ -3841,7 +3841,7 @@ dependencies = [
[[package]]
name = "kebab-source-fs"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3858,7 +3858,7 @@ dependencies = [
[[package]]
name = "kebab-store-sqlite"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3879,7 +3879,7 @@ dependencies = [
[[package]]
name = "kebab-store-vector"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"arrow",
@@ -3903,7 +3903,7 @@ dependencies = [
[[package]]
name = "kebab-tui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"crossterm",

View File

@@ -30,7 +30,7 @@ edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/altair823/kebab"
version = "0.4.0"
version = "0.5.0"
[workspace.dependencies]
anyhow = "1"

View File

@@ -71,7 +71,7 @@ kebab doctor
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media``,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min``primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md``markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media``,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min``primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md``markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). |
| `kebab list docs` | 색인된 문서 목록 |
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab fetch chunk <id> [--context N]` / `kebab fetch doc <id> [--max-tokens N]` / `kebab fetch span <doc_id> <ls> <le> [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. |
@@ -80,7 +80,7 @@ kebab doctor
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i``o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v<version> │ <pane> │ <docs> docs │ <state>` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). |
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
| `kebab eval run / compare` | golden query 회귀 측정 |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json``schema.v1` wire; 사람 모드는 서식 출력. |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json``schema.v1` wire; 사람 모드는 서식 출력. **stats 에 (p9-fb-37) `media_breakdown` (5 keys: markdown / pdf / image / audio / other) + `lang_breakdown` (BCP-47 코드, NULL 은 literal `"null"`) + `index_bytes` (sqlite + lancedb on-disk 합계) + `stale_doc_count` (`config.search.stale_threshold_days` 초과 doc 수) 추가.** |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |

View File

@@ -70,6 +70,9 @@ pub struct SearchResponse {
pub hits: Vec<SearchHit>,
pub next_cursor: Option<String>,
pub truncated: bool,
/// p9-fb-37: present when caller passed `SearchOpts.trace = true`.
/// Consumers that ignore trace should leave this `None`.
pub trace: Option<kebab_core::SearchTrace>,
}
/// Facade state — see module docs for lifetime rules.
@@ -341,6 +344,75 @@ impl App {
k: fetch_k,
..query.clone()
};
// p9-fb-37: when --trace is requested, bypass the LRU cache and
// run through `HybridRetriever::search_with_trace`, which
// dispatches by mode internally. Vector / hybrid modes require
// embeddings (same as `--mode hybrid`); lexical mode skips
// embedder construction via `NoopRetriever` so lexical-only
// workspaces (provider = "none") can use `--trace` without
// surfacing the "switch to --mode lexical" error.
if opts.trace {
let lex = Arc::new(LexicalRetriever::with_settings(
self.sqlite.clone(),
lexical_index_version(&self.config),
self.config.search.snippet_chars,
)) as Arc<dyn Retriever>;
let vec_retr: Arc<dyn Retriever> = if matches!(query.mode, SearchMode::Lexical) {
// `HybridRetriever::search_with_trace` never invokes the
// vector retriever for `SearchMode::Lexical` (Task 4).
// A no-op stand-in lets us avoid the ~470 MB embedder
// load when the user only asked for lexical trace.
Arc::new(NoopRetriever)
} else {
let (emb, vec_store) = self.require_embeddings()?;
let vec_iv = vector_index_version(emb.as_ref());
let vec_dyn: Arc<dyn VectorStore + Send + Sync> = vec_store;
let emb_dyn: Arc<dyn Embedder> = emb;
Arc::new(VectorRetriever::with_settings(
vec_dyn,
emb_dyn,
self.sqlite.clone(),
vec_iv,
self.config.search.snippet_chars,
)) as Arc<dyn Retriever>
};
let hybrid = HybridRetriever::new(&self.config, lex, vec_retr);
let (mut traced_hits, trace) = hybrid.search_with_trace(&fetch_query)?;
// Stamp staleness — same as search_uncached.
let now = time::OffsetDateTime::now_utc();
crate::staleness::mark_stale_in_place(
&mut traced_hits,
now,
self.config.search.stale_threshold_days,
);
// Apply offset + k_effective truncation (mirrors non-trace path).
let drop_n = offset.min(traced_hits.len());
traced_hits.drain(..drop_n);
let mut hits: Vec<SearchHit> =
traced_hits.into_iter().take(k_effective).collect();
// Snippet truncation if opts.snippet_chars set (mirror non-trace path).
if opts.snippet_chars.is_some() {
for h in hits.iter_mut() {
if h.snippet.chars().count() > snippet_chars {
h.snippet = trim_to_chars(&h.snippet, snippet_chars);
}
}
}
// Trace path skips the budget loop. Caller will inspect
// `hits.len()` and `trace.timing` rather than paginate.
return Ok(SearchResponse {
hits,
next_cursor: None,
truncated: false,
trace: Some(trace),
});
}
let mut all_hits = self.search(fetch_query)?;
// Skip offset.
@@ -421,6 +493,7 @@ impl App {
hits,
next_cursor,
truncated,
trace: None,
})
}
@@ -737,6 +810,24 @@ fn lexical_index_version(config: &kebab_config::Config) -> IndexVersion {
IndexVersion(format!("lex:{}", config.chunking.chunker_version))
}
/// p9-fb-37: stand-in for the vector retriever in the trace path when
/// `query.mode == SearchMode::Lexical`. `HybridRetriever::search_with_trace`'s
/// Lexical branch never calls `vector.search()`, so returning an empty
/// hit list here is safe and lets lexical-only workspaces (embedding
/// `provider = "none"`) use `--trace` without paying the ~470 MB
/// embedder load.
struct NoopRetriever;
impl Retriever for NoopRetriever {
fn search(&self, _q: &kebab_core::SearchQuery) -> anyhow::Result<Vec<kebab_core::SearchHit>> {
Ok(Vec::new())
}
fn index_version(&self) -> kebab_core::IndexVersion {
kebab_core::IndexVersion("noop:trace".into())
}
}
/// Compose a stable `IndexVersion` for the vector retriever. Tracks
/// `(embedding_model, embedding_version, dimensions)` so a model swap
/// flags drift via the existing index_version mismatch warning in
@@ -847,3 +938,59 @@ mod tests {
assert_ne!(a, d, "different session_id → different hash");
}
}
#[cfg(test)]
mod tests_trace {
use super::*;
use kebab_core::{SearchMode, SearchOpts, SearchQuery};
fn open_app_with_temp_dir() -> (tempfile::TempDir, App) {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
// Bring up migrations.
let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
drop(store);
let app = App::open_with_config(cfg).unwrap();
(dir, app)
}
#[test]
fn search_response_trace_none_when_opts_trace_false() {
let (_dir, app) = open_app_with_temp_dir();
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 1,
filters: Default::default(),
};
let resp = app.search_with_opts(q, SearchOpts::default()).unwrap();
assert!(resp.trace.is_none());
}
#[test]
fn search_response_trace_some_when_opts_trace_true_lexical_mode() {
// Lexical mode doesn't require embeddings — the trace path
// builds HybridRetriever with a `NoopRetriever` stand-in for
// the vector side, since `HybridRetriever::search_with_trace`'s
// Lexical branch never invokes `vector.search()`. Default
// Config has embedding `provider = "none"`, and lexical-mode
// trace must succeed under that config (no embedder load).
let (_dir, app) = open_app_with_temp_dir();
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 1,
filters: Default::default(),
};
let opts = SearchOpts {
trace: true,
..Default::default()
};
let resp = app
.search_with_opts(q, opts)
.expect("lexical-mode trace must succeed without embeddings");
assert!(resp.trace.is_some(), "trace populated when opts.trace=true");
}
}

View File

@@ -50,6 +50,18 @@ pub struct Stats {
pub chunk_count: u64,
pub asset_count: u64,
pub last_ingest_at: Option<String>,
/// p9-fb-37: per-media-kind doc count (5 keys, zero-padded).
#[serde(default)]
pub media_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: per-language doc count, NULL keyed as `"null"`.
#[serde(default)]
pub lang_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: on-disk byte sums.
#[serde(default)]
pub index_bytes: kebab_core::IndexBytes,
/// p9-fb-37: docs whose `updated_at` exceeds the staleness threshold.
#[serde(default)]
pub stale_doc_count: u64,
}
const KEBAB_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -85,7 +97,7 @@ const WIRE_SCHEMAS: &[&str] = &[
#[doc(hidden)]
pub fn schema_with_config(cfg: &Config) -> anyhow::Result<SchemaV1> {
let store = open_store_for_stats(cfg)?;
let stats = collect_stats(&store)?;
let stats = collect_stats(cfg, &store)?;
let models = collect_models(cfg, &store);
Ok(SchemaV1 {
schema_version: SCHEMA_V1_ID.to_string(),
@@ -124,13 +136,24 @@ fn open_store_for_stats(cfg: &Config) -> anyhow::Result<kebab_store_sqlite::Sqli
kebab_store_sqlite::SqliteStore::open_existing(&db_path)
}
fn collect_stats(store: &kebab_store_sqlite::SqliteStore) -> anyhow::Result<Stats> {
let counts = store.count_summary()?;
fn collect_stats(
cfg: &Config,
store: &kebab_store_sqlite::SqliteStore,
) -> anyhow::Result<Stats> {
let counts = store
.count_summary_with_threshold(cfg.search.stale_threshold_days as u64)?;
let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
let index_bytes = kebab_store_sqlite::stats_ext::index_bytes(&data_dir)
.map_err(|e| anyhow::anyhow!("index_bytes: {e}"))?;
Ok(Stats {
doc_count: counts.doc_count,
chunk_count: counts.chunk_count,
asset_count: counts.asset_count,
last_ingest_at: counts.last_ingest_at,
media_breakdown: counts.media_breakdown,
lang_breakdown: counts.lang_breakdown,
index_bytes,
stale_doc_count: counts.stale_doc_count,
})
}
@@ -150,3 +173,31 @@ fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> Mode
corpus_revision: store.corpus_revision(),
}
}
#[cfg(test)]
mod tests_stats_ext {
use super::*;
#[test]
fn stats_includes_breakdowns_and_bytes_on_fresh_corpus() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
// Bring up migrations so the sqlite file is created.
let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
drop(store);
let s = schema_with_config(&cfg).unwrap();
// 5 keys padded.
assert_eq!(s.stats.media_breakdown.len(), 5);
assert_eq!(s.stats.media_breakdown.get("markdown"), Some(&0));
assert_eq!(s.stats.media_breakdown.get("pdf"), Some(&0));
// lang map empty on empty corpus.
assert!(s.stats.lang_breakdown.is_empty());
// sqlite bytes positive after migrations, lancedb 0.
assert!(s.stats.index_bytes.sqlite > 0);
assert_eq!(s.stats.index_bytes.lancedb, 0);
assert_eq!(s.stats.stale_doc_count, 0);
}
}

View File

@@ -47,6 +47,7 @@ fn budget_truncates_snippets_when_below_threshold() {
max_tokens: Some(50),
snippet_chars: None,
cursor: None,
trace: false,
},
)
.unwrap();
@@ -78,6 +79,7 @@ fn cursor_paginates_to_next_page() {
max_tokens: None,
snippet_chars: None,
cursor: Some(cursor),
trace: false,
},
)
.unwrap();
@@ -114,6 +116,7 @@ fn cursor_rejected_after_corpus_revision_bump() {
max_tokens: None,
snippet_chars: None,
cursor: Some(c),
trace: false,
},
);
let err = result.unwrap_err();
@@ -147,6 +150,7 @@ fn max_tokens_zero_returns_one_hit_truncated() {
max_tokens: Some(0),
snippet_chars: None,
cursor: None,
trace: false,
},
)
.unwrap();

View File

@@ -163,6 +163,14 @@ enum Cmd {
/// p9-fb-36: filter to a single doc by id.
#[arg(long)]
doc_id: Option<String>,
/// p9-fb-37: emit pre-fusion lexical / vector / RRF candidate
/// lists + per-stage timing in the response. Bypasses cache
/// (debug intent — fresh run guaranteed). Requires embeddings
/// when `--mode hybrid` or `--mode vector`; lexical mode runs
/// without embeddings via a no-op vector stub.
#[arg(long)]
trace: bool,
},
/// Retrieval-augmented question answering.
@@ -669,6 +677,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
media,
ingested_after,
doc_id,
trace,
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
@@ -732,6 +741,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
max_tokens: *max_tokens,
snippet_chars: *snippet_chars,
cursor: cursor.clone(),
trace: *trace,
};
// p9-fb-34: budget-aware path. --no-cache still bypasses the
// App-level LRU; wire wrapper applies regardless.
@@ -789,6 +799,22 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
let next = resp.next_cursor.as_deref().unwrap_or("(none)");
eprintln!("[truncated; use --cursor {next} for the next page]");
}
if *trace {
if let Some(t) = &resp.trace {
eprintln!();
eprintln!("Trace:");
eprintln!(" lexical ({} hits, {}ms):", t.lexical.len(), t.timing.lexical_ms);
for c in t.lexical.iter().take(3) {
eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0);
}
eprintln!(" vector ({} hits, {}ms):", t.vector.len(), t.timing.vector_ms);
for c in t.vector.iter().take(3) {
eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0);
}
eprintln!(" fusion ({} inputs, {}ms)", t.rrf_inputs.len(), t.timing.fusion_ms);
eprintln!(" total: {}ms", t.timing.total_ms);
}
}
}
Ok(())
}

View File

@@ -81,11 +81,17 @@ pub fn wire_search_hit(h: &SearchHit) -> Value {
/// array (`wire_search_hits`) — see HOTFIXES / fb-34 for the
/// breaking shape change.
pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value {
let v = serde_json::json!({
let mut v = serde_json::json!({
"hits": r.hits.iter().map(wire_search_hit).collect::<Vec<_>>(),
"next_cursor": r.next_cursor,
"truncated": r.truncated,
});
if let Some(trace) = &r.trace {
let trace_v = serde_json::to_value(trace).expect("SearchTrace serializes");
if let Value::Object(ref mut map) = v {
map.insert("trace".to_string(), trace_v);
}
}
tag_object(v, "search_response.v1")
}
@@ -264,6 +270,7 @@ mod tests {
hits: vec![],
next_cursor: Some("opaque-cursor-abc".to_string()),
truncated: true,
trace: None,
};
let v = wire_search_response(&r);
assert_eq!(schema_of(&v), Some("search_response.v1"));
@@ -303,6 +310,10 @@ mod tests {
stats: Stats {
doc_count: 1, chunk_count: 2, asset_count: 1,
last_ingest_at: None,
media_breakdown: Default::default(),
lang_breakdown: Default::default(),
index_bytes: Default::default(),
stale_doc_count: 0,
},
};
let v = wire_schema(&schema);
@@ -343,4 +354,49 @@ mod tests {
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].as_str(), Some("/tmp/x"));
}
#[test]
fn search_response_with_trace_serializes_trace_field() {
use kebab_core::{SearchTrace, TraceCandidate, TraceFusionInput,
TraceTiming, ChunkId, DocumentId, WorkspacePath};
let r = kebab_app::SearchResponse {
hits: vec![],
next_cursor: None,
truncated: false,
trace: Some(SearchTrace {
lexical: vec![TraceCandidate {
chunk_id: ChunkId("c1".into()),
doc_id: DocumentId("d1".into()),
doc_path: WorkspacePath::new("a.md".into()).unwrap(),
rank: 1,
score: 0.42,
}],
vector: vec![],
rrf_inputs: vec![TraceFusionInput {
chunk_id: ChunkId("c1".into()),
lexical_rank: Some(1),
vector_rank: None,
fusion_score: 0.0,
}],
timing: TraceTiming { lexical_ms: 5, vector_ms: 0, fusion_ms: 1, total_ms: 7 },
}),
};
let v = wire_search_response(&r);
assert_eq!(schema_of(&v), Some("search_response.v1"));
assert!(v["trace"].is_object());
assert_eq!(v["trace"]["timing"]["lexical_ms"], 5);
assert_eq!(v["trace"]["lexical"][0]["chunk_id"], "c1");
}
#[test]
fn search_response_without_trace_omits_field() {
let r = kebab_app::SearchResponse {
hits: vec![],
next_cursor: None,
truncated: false,
trace: None,
};
let v = wire_search_response(&r);
assert!(v.get("trace").is_none(), "trace field absent when None");
}
}

View File

@@ -0,0 +1,57 @@
//! p9-fb-37: integration tests for `kebab schema --json` extended stats.
mod common;
use serde_json::Value;
use std::fs;
use std::process::Command;
fn run_schema(cfg: &std::path::Path) -> Value {
let bin = env!("CARGO_BIN_EXE_kebab");
let out = Command::new(bin)
.args(["--config", cfg.to_str().unwrap(), "schema", "--json"])
.output()
.expect("run kebab schema");
assert!(
out.status.success(),
"schema failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("valid JSON")
}
#[test]
fn schema_stats_includes_breakdowns_on_fresh_corpus() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
// Run a no-op ingest to bring up migrations + create the SQLite file.
fs::write(workspace.join("placeholder.md"), "# placeholder\n").unwrap();
common::ingest(&cfg, &workspace);
let v = run_schema(&cfg);
let stats = &v["stats"];
let m = stats["media_breakdown"].as_object().unwrap();
assert_eq!(m.len(), 5, "5 media keys padded");
for k in &["markdown", "pdf", "image", "audio", "other"] {
assert!(m[*k].is_number(), "media[{k}] is integer");
}
assert!(stats["lang_breakdown"].is_object());
assert!(stats["index_bytes"]["sqlite"].is_number());
assert!(stats["index_bytes"]["lancedb"].is_number());
assert!(stats["stale_doc_count"].is_number());
}
#[test]
fn schema_stats_breakdowns_after_ingest() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("a.md"), "---\nlang: en\n---\nhello\n").unwrap();
fs::write(workspace.join("b.md"), "---\nlang: ko\n---\n안녕\n").unwrap();
common::ingest(&cfg, &workspace);
let v = run_schema(&cfg);
let stats = &v["stats"];
assert_eq!(stats["media_breakdown"]["markdown"], 2);
assert!(stats["lang_breakdown"].is_object());
assert!(stats["index_bytes"]["sqlite"].as_u64().unwrap() > 0);
}

View File

@@ -0,0 +1,58 @@
//! p9-fb-37: integration tests for `kebab search --trace --json`.
mod common;
use serde_json::Value;
use std::fs;
#[test]
fn search_trace_json_includes_trace_block() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--trace", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v["trace"].is_object(), "trace block present");
assert!(v["trace"]["timing"].is_object());
assert!(v["trace"]["timing"]["total_ms"].is_number());
assert!(v["trace"]["lexical"].is_array());
assert!(v["trace"]["vector"].is_array());
assert!(v["trace"]["rrf_inputs"].is_array());
}
#[test]
fn search_without_trace_omits_trace_field() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(v.get("trace").is_none(), "trace field absent without --trace");
}
#[test]
fn search_trace_lexical_mode_vector_list_empty() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--trace", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(v["trace"]["vector"].as_array().unwrap().len(), 0);
assert_eq!(v["trace"]["timing"]["vector_ms"], 0);
}

View File

@@ -51,8 +51,9 @@ pub use metadata::{
TrustLevel,
};
pub use search::{
DocFilter, DocSummary, RetrievalDetail, SearchFilters, SearchHit,
SearchMode, SearchOpts, SearchQuery,
DocFilter, DocSummary, IndexBytes, MEDIA_KINDS, RetrievalDetail, SearchFilters, SearchHit,
SearchMode, SearchOpts, SearchQuery, SearchTrace, TraceCandidate, TraceFusionInput,
TraceTiming,
};
pub use answer::{
Answer, AnswerCitation, AnswerRetrievalSummary, ModelRef, RefusalReason, TokenUsage,

View File

@@ -124,6 +124,60 @@ pub struct SearchOpts {
pub snippet_chars: Option<usize>,
/// Opaque base64 cursor from a previous response. None = first page.
pub cursor: Option<String>,
/// p9-fb-37: when true, capture pipeline trace (cache bypassed,
/// lex / vec pre-fusion lists + timing populated on the response).
#[serde(default)]
pub trace: bool,
}
/// p9-fb-37: search retrieval pipeline trace. Populated only when
/// `SearchOpts.trace = true`; `None` on the wrapping `SearchResponse`
/// otherwise. `lexical` / `vector` are pre-fusion candidate lists
/// (each retriever's full output for the fanout query). `rrf_inputs`
/// is the union (chunk_id) used by RRF, with each side's rank
/// captured. `timing` is wall-clock per stage.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SearchTrace {
pub lexical: Vec<TraceCandidate>,
pub vector: Vec<TraceCandidate>,
pub rrf_inputs: Vec<TraceFusionInput>,
pub timing: TraceTiming,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TraceCandidate {
pub chunk_id: ChunkId,
pub doc_id: DocumentId,
pub doc_path: WorkspacePath,
pub rank: u32,
pub score: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TraceFusionInput {
pub chunk_id: ChunkId,
pub lexical_rank: Option<u32>,
pub vector_rank: Option<u32>,
/// Hybrid mode: normalized RRF score in `[0, 1]`.
/// Lexical / Vector mode: equals the underlying retriever's score
/// (no fusion ran). 0.0 for chunks dropped past `target_k`.
pub fusion_score: f32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceTiming {
pub lexical_ms: u64,
pub vector_ms: u64,
pub fusion_ms: u64,
pub total_ms: u64,
}
/// p9-fb-37: on-disk index size breakdown. Mirrored on the
/// wire `schema.v1.stats.index_bytes` block.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexBytes {
pub sqlite: u64,
pub lancedb: u64,
}
#[cfg(test)]
@@ -193,4 +247,51 @@ mod tests {
assert!(old.ingested_after.is_none());
assert!(old.doc_id.is_none());
}
#[test]
fn search_trace_serde_roundtrip() {
let t = SearchTrace {
lexical: vec![TraceCandidate {
chunk_id: ChunkId("c1".into()),
doc_id: DocumentId("d1".into()),
doc_path: WorkspacePath::new("a.md".into()).unwrap(),
rank: 1,
score: 0.42,
}],
vector: vec![],
rrf_inputs: vec![TraceFusionInput {
chunk_id: ChunkId("c1".into()),
lexical_rank: Some(1),
vector_rank: None,
fusion_score: 0.0234,
}],
timing: TraceTiming {
lexical_ms: 12,
vector_ms: 0,
fusion_ms: 1,
total_ms: 14,
},
};
let v = serde_json::to_value(&t).unwrap();
assert_eq!(v["timing"]["lexical_ms"], 12);
assert_eq!(
v["lexical"][0]["score"].as_f64().unwrap() as f32,
0.42_f32
);
let back: SearchTrace = serde_json::from_value(v).unwrap();
assert_eq!(back, t);
}
#[test]
fn index_bytes_default_is_zero() {
let b = IndexBytes::default();
assert_eq!(b.sqlite, 0);
assert_eq!(b.lancedb, 0);
}
#[test]
fn search_opts_trace_default_false() {
let opts = SearchOpts::default();
assert!(!opts.trace);
}
}

View File

@@ -47,6 +47,10 @@ pub struct SearchInput {
pub ingested_after: Option<String>,
/// p9-fb-36: filter to a single doc.
pub doc_id: Option<String>,
/// p9-fb-37: when true, include a `trace` field on the response
/// with pre-fusion lexical/vector candidate lists + per-stage timing.
/// Bypasses cache (debug intent — fresh run guaranteed). Default false.
pub trace: Option<bool>,
}
pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
@@ -118,6 +122,7 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
max_tokens: input.max_tokens,
snippet_chars: input.snippet_chars,
cursor: input.cursor,
trace: input.trace.unwrap_or(false),
};
let cfg_clone = (*state.config).clone();
match kebab_app::search_with_opts_with_config(cfg_clone, query, opts) {
@@ -138,12 +143,19 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
v
})
.collect();
let envelope = serde_json::json!({
let mut envelope = serde_json::json!({
"schema_version": "search_response.v1",
"hits": tagged,
"next_cursor": resp.next_cursor,
"truncated": resp.truncated,
});
if let Some(trace) = &resp.trace {
let trace_v =
serde_json::to_value(trace).unwrap_or(serde_json::Value::Null);
if let serde_json::Value::Object(ref mut map) = envelope {
map.insert("trace".to_string(), trace_v);
}
}
match serde_json::to_string(&envelope) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),

View File

@@ -69,6 +69,7 @@ async fn fetch_tool_chunk_returns_fetch_result_v1() {
media: None,
ingested_after: None,
doc_id: None,
trace: None,
},
);
let search_text = match &search_result.content.first().unwrap().raw {

View File

@@ -65,6 +65,7 @@ async fn search_tool_returns_search_response_v1() {
media: None,
ingested_after: None,
doc_id: None,
trace: None,
},
);
@@ -166,6 +167,7 @@ async fn search_with_doc_id_filter_returns_only_target() {
media: None,
ingested_after: None,
doc_id: None,
trace: None,
},
);
assert!(
@@ -204,6 +206,7 @@ async fn search_with_doc_id_filter_returns_only_target() {
media: None,
ingested_after: None,
doc_id: Some(target_doc_id.clone()),
trace: None,
},
);
assert!(
@@ -260,6 +263,7 @@ async fn search_with_invalid_ingested_after_returns_invalid_input() {
media: None,
ingested_after: Some("garbage".to_string()),
doc_id: None,
trace: None,
},
);

View File

@@ -0,0 +1,104 @@
//! p9-fb-37: integration test for `mcp__kebab__search` trace input/output.
use std::fs;
use kebab_config::Config;
use kebab_core::SourceScope;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
let mut cfg = Config::defaults();
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
cfg.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned();
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
cfg.workspace.exclude.clear();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
cfg
}
fn setup() -> (tempfile::TempDir, KebabHandler) {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let workspace_root = dir.path().join("notes");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&workspace_root).unwrap();
let config = minimal_config(&data_dir, &workspace_root);
fs::write(
workspace_root.join("a.md"),
"# Alpha\n\nThis document mentions kebab and bread.",
)
.unwrap();
let scope = SourceScope {
root: workspace_root.clone(),
include: vec![],
exclude: vec![],
};
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
let state = KebabAppState::new(config, None);
let handler = KebabHandler::new(state);
(dir, handler)
}
fn make_input(trace: Option<bool>) -> kebab_mcp::tools::search::SearchInput {
kebab_mcp::tools::search::SearchInput {
query: "kebab".to_string(),
mode: Some("lexical".to_string()),
k: Some(5),
max_tokens: None,
snippet_chars: None,
cursor: None,
tags: None,
lang: None,
path_glob: None,
trust_min: None,
media: None,
ingested_after: None,
doc_id: None,
trace,
}
}
fn extract_json(result: &rmcp::model::CallToolResult) -> serde_json::Value {
assert!(
!result.is_error.unwrap_or(false),
"expected isError=false, got {result:?}"
);
let content = result.content.first().expect("at least one content item");
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected Text content, got {other:?}"),
};
serde_json::from_str(text).expect("valid JSON")
}
#[tokio::test]
async fn search_with_trace_true_returns_trace_field() {
let (_dir, handler) = setup();
let result = kebab_mcp::tools::search::handle(handler.state(), make_input(Some(true)));
let v = extract_json(&result);
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v["trace"].is_object(), "trace field present when trace:true");
assert!(v["trace"]["timing"]["total_ms"].is_number());
assert!(v["trace"]["lexical"].is_array());
assert!(v["trace"]["vector"].is_array());
assert!(v["trace"]["rrf_inputs"].is_array());
}
#[tokio::test]
async fn search_without_trace_omits_trace_field() {
let (_dir, handler) = setup();
let result = kebab_mcp::tools::search::handle(handler.state(), make_input(None));
let v = extract_json(&result);
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v.get("trace").is_none(), "trace absent when None");
}
#[tokio::test]
async fn search_with_trace_false_omits_trace_field() {
let (_dir, handler) = setup();
let result = kebab_mcp::tools::search::handle(handler.state(), make_input(Some(false)));
let v = extract_json(&result);
assert!(v.get("trace").is_none(), "trace absent when false");
}

View File

@@ -18,12 +18,15 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use kebab_core::{
IndexVersion, RetrievalDetail, Retriever, SearchHit, SearchMode, SearchQuery,
IndexVersion, RetrievalDetail, Retriever, SearchHit, SearchMode, SearchQuery, SearchTrace,
};
use crate::trace::{build_fusion_input_skeleton, candidates_from_hits, ScoreKind, TraceBuilder};
/// Default `k_rrf` if `kb-config::SearchCfg::rrf_k` is misconfigured.
/// Matches §6.4's documented default (60).
const DEFAULT_K_RRF: u32 = 60;
@@ -145,20 +148,22 @@ impl Retriever for HybridRetriever {
impl HybridRetriever {
fn fuse(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
let target_k = if query.k == 0 { self.default_k } else { query.k };
// Fanout: ask each retriever for `target_k * MULTIPLIER` so
// the disjoint set of candidates is wide enough. The two
// per-side queries are identical (same text, k, mode, filters);
// only the dispatch differs, so we share one `SearchQuery`.
let fanout_k = target_k.saturating_mul(HYBRID_FANOUT_MULTIPLIER);
let lex_query = SearchQuery {
k: fanout_k,
..query.clone()
};
let lex_hits = self.lexical.search(&lex_query)?;
let vec_hits = self.vector.search(&lex_query)?;
self.fuse_with_inputs(&lex_hits, &vec_hits, target_k)
}
fn fuse_with_inputs(
&self,
lex_hits: &[SearchHit],
vec_hits: &[SearchHit],
target_k: usize,
) -> Result<Vec<SearchHit>> {
tracing::debug!(
lex = lex_hits.len(),
vec = vec_hits.len(),
@@ -171,11 +176,13 @@ impl HybridRetriever {
// already 1-based by both LexicalRetriever and VectorRetriever
// (and any well-behaved Retriever should mirror).
let lex_index: HashMap<String, (u32, SearchHit)> = lex_hits
.into_iter()
.iter()
.cloned()
.map(|h| (h.chunk_id.0.clone(), (h.rank, h)))
.collect();
let vec_index: HashMap<String, (u32, SearchHit)> = vec_hits
.into_iter()
.iter()
.cloned()
.map(|h| (h.chunk_id.0.clone(), (h.rank, h)))
.collect();
@@ -312,6 +319,85 @@ impl HybridRetriever {
tracing::debug!(rows = hits.len(), "kb-search hybrid: search done");
Ok(hits)
}
/// p9-fb-37: parallel to `Retriever::search` but additionally returns
/// a trace of pre-fusion lex/vec lists, RRF inputs (union with each
/// side's rank), and per-stage timing.
pub fn search_with_trace(
&self,
query: &SearchQuery,
) -> anyhow::Result<(Vec<SearchHit>, SearchTrace)> {
let start_total = Instant::now();
let target_k = if query.k == 0 { self.default_k } else { query.k };
let fanout_k = target_k.saturating_mul(HYBRID_FANOUT_MULTIPLIER);
let fanout_query = SearchQuery {
k: fanout_k,
..query.clone()
};
let mut tb = TraceBuilder::default();
let (lex_hits, vec_hits): (Vec<SearchHit>, Vec<SearchHit>) = match query.mode {
SearchMode::Lexical => {
let t0 = Instant::now();
let lh = self.lexical.search(&fanout_query)?;
tb.timing.lexical_ms = t0.elapsed().as_millis() as u64;
(lh, Vec::new())
}
SearchMode::Vector => {
let t0 = Instant::now();
let vh = self.vector.search(&fanout_query)?;
tb.timing.vector_ms = t0.elapsed().as_millis() as u64;
(Vec::new(), vh)
}
SearchMode::Hybrid => {
let t0 = Instant::now();
let lh = self.lexical.search(&fanout_query)?;
tb.timing.lexical_ms = t0.elapsed().as_millis() as u64;
let t1 = Instant::now();
let vh = self.vector.search(&fanout_query)?;
tb.timing.vector_ms = t1.elapsed().as_millis() as u64;
(lh, vh)
}
};
tb.lexical = candidates_from_hits(&lex_hits, ScoreKind::Lexical);
tb.vector = candidates_from_hits(&vec_hits, ScoreKind::Vector);
tb.rrf_inputs = build_fusion_input_skeleton(&lex_hits, &vec_hits);
let t_fusion = Instant::now();
let final_hits = match query.mode {
SearchMode::Lexical => {
let mut h = lex_hits.clone();
h.truncate(target_k);
h
}
SearchMode::Vector => {
let mut h = vec_hits.clone();
h.truncate(target_k);
h
}
SearchMode::Hybrid => self.fuse_with_inputs(&lex_hits, &vec_hits, target_k)?,
};
tb.timing.fusion_ms = t_fusion.elapsed().as_millis() as u64;
let score_by_chunk: std::collections::HashMap<String, f32> = final_hits
.iter()
.map(|h| (h.chunk_id.0.clone(), h.retrieval.fusion_score))
.collect();
for entry in &mut tb.rrf_inputs {
if let Some(s) = score_by_chunk.get(&entry.chunk_id.0) {
entry.fusion_score = *s;
}
}
// total_ms is wall-clock from start; per-stage `lexical_ms` /
// `vector_ms` / `fusion_ms` each truncate to whole millis via
// `as_millis() as u64`, so their sum can drift below total
// (sub-ms losses) — DO NOT assert `total_ms >= sum(stages)`.
tb.timing.total_ms = start_total.elapsed().as_millis() as u64;
Ok((final_hits, tb.into_trace()))
}
}
/// Parse the `hybrid_fusion` config string into a [`FusionPolicy`].
@@ -633,4 +719,107 @@ mod tests {
let FusionPolicy::Rrf { k_rrf } = parse_fusion("rrf", 0);
assert_eq!(k_rrf, DEFAULT_K_RRF);
}
#[test]
fn search_with_trace_returns_lex_and_vec_lists() {
use kebab_core::{ChunkId, DocumentId, IndexVersion, ChunkerVersion,
RetrievalDetail, SearchHit, SearchMode, SearchQuery,
WorkspacePath, Citation};
use std::sync::Arc;
fn mk_hit(rank: u32, chunk: &str, score: f32, mode: SearchMode) -> SearchHit {
SearchHit {
rank,
chunk_id: ChunkId(chunk.into()),
doc_id: DocumentId(format!("d-{chunk}")),
doc_path: WorkspacePath::new(format!("{chunk}.md")).unwrap(),
heading_path: vec![],
section_label: None,
snippet: chunk.into(),
citation: Citation::Line {
path: WorkspacePath::new(format!("{chunk}.md")).unwrap(),
start: 1,
end: 1,
section: None,
},
retrieval: RetrievalDetail {
method: mode,
fusion_score: score,
lexical_score: if mode == SearchMode::Lexical { Some(score) } else { None },
vector_score: if mode == SearchMode::Vector { Some(score) } else { None },
lexical_rank: if mode == SearchMode::Lexical { Some(rank) } else { None },
vector_rank: if mode == SearchMode::Vector { Some(rank) } else { None },
},
index_version: IndexVersion("v1".into()),
embedding_model: None,
chunker_version: ChunkerVersion("c1".into()),
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
stale: false,
}
}
struct Stub { hits: Vec<SearchHit> }
impl Retriever for Stub {
fn search(&self, _q: &SearchQuery) -> anyhow::Result<Vec<SearchHit>> {
Ok(self.hits.clone())
}
fn index_version(&self) -> IndexVersion { IndexVersion("v1".into()) }
}
let lex = Arc::new(Stub {
hits: vec![
mk_hit(1, "c1", 0.9, SearchMode::Lexical),
mk_hit(2, "c2", 0.5, SearchMode::Lexical),
],
});
let vec_r = Arc::new(Stub {
hits: vec![
mk_hit(1, "c2", 0.8, SearchMode::Vector),
mk_hit(2, "c3", 0.6, SearchMode::Vector),
],
});
let hybrid = HybridRetriever::with_policy(
lex.clone(),
vec_r.clone(),
FusionPolicy::Rrf { k_rrf: 60 },
2,
);
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Hybrid,
k: 2,
filters: Default::default(),
};
let (hits, trace) = hybrid.search_with_trace(&q).unwrap();
assert!(!hits.is_empty());
assert_eq!(trace.lexical.len(), 2);
assert_eq!(trace.vector.len(), 2);
// Union: c1, c2, c3 → 3 entries.
assert_eq!(trace.rrf_inputs.len(), 3);
}
#[test]
fn search_with_trace_lexical_mode_empty_vector() {
use kebab_core::{IndexVersion, SearchMode, SearchQuery};
use std::sync::Arc;
struct EmptyR;
impl Retriever for EmptyR {
fn search(&self, _q: &SearchQuery) -> anyhow::Result<Vec<kebab_core::SearchHit>> {
Ok(vec![])
}
fn index_version(&self) -> IndexVersion { IndexVersion("v1".into()) }
}
let lex = Arc::new(EmptyR);
let vec_r = Arc::new(EmptyR);
let hybrid = HybridRetriever::with_policy(lex, vec_r, FusionPolicy::Rrf { k_rrf: 60 }, 2);
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 2,
filters: Default::default(),
};
let (_hits, trace) = hybrid.search_with_trace(&q).unwrap();
assert!(trace.vector.is_empty());
assert_eq!(trace.timing.vector_ms, 0);
}
}

View File

@@ -19,6 +19,7 @@
mod citation_helper;
mod hybrid;
mod lexical;
mod trace;
mod vector;
pub use hybrid::{FusionPolicy, HybridRetriever};

View File

@@ -0,0 +1,85 @@
//! p9-fb-37: trace capture helpers for `HybridRetriever::search_with_trace`.
use std::collections::BTreeMap;
use kebab_core::{
SearchHit, SearchTrace, TraceCandidate, TraceFusionInput, TraceTiming,
};
/// Build a `TraceCandidate` from a `SearchHit`. The score field reflects
/// each side's score (lexical / vector / fusion) — caller selects which
/// retriever's hit list this is.
pub fn candidates_from_hits(hits: &[SearchHit], score_kind: ScoreKind) -> Vec<TraceCandidate> {
hits.iter()
.map(|h| TraceCandidate {
chunk_id: h.chunk_id.clone(),
doc_id: h.doc_id.clone(),
doc_path: h.doc_path.clone(),
rank: h.rank,
score: match score_kind {
ScoreKind::Lexical => h.retrieval.lexical_score.unwrap_or(0.0),
ScoreKind::Vector => h.retrieval.vector_score.unwrap_or(0.0),
},
})
.collect()
}
#[derive(Clone, Copy, Debug)]
pub enum ScoreKind {
Lexical,
Vector,
}
/// Build the union of (chunk_id) across lex and vec hit lists, with
/// each side's rank captured. `fusion_score` is filled by the caller
/// (RRF computes it during fusion, this helper just pre-builds the
/// rank table — caller overwrites fusion_score in a second pass).
pub fn build_fusion_input_skeleton(
lex: &[SearchHit],
vec: &[SearchHit],
) -> Vec<TraceFusionInput> {
let mut by_chunk: BTreeMap<String, TraceFusionInput> = BTreeMap::new();
for h in lex {
by_chunk
.entry(h.chunk_id.0.clone())
.or_insert(TraceFusionInput {
chunk_id: h.chunk_id.clone(),
lexical_rank: None,
vector_rank: None,
fusion_score: 0.0,
})
.lexical_rank = Some(h.rank);
}
for h in vec {
by_chunk
.entry(h.chunk_id.0.clone())
.or_insert(TraceFusionInput {
chunk_id: h.chunk_id.clone(),
lexical_rank: None,
vector_rank: None,
fusion_score: 0.0,
})
.vector_rank = Some(h.rank);
}
by_chunk.into_values().collect()
}
/// Container the hybrid retriever fills during a traced run.
#[derive(Default)]
pub struct TraceBuilder {
pub lexical: Vec<TraceCandidate>,
pub vector: Vec<TraceCandidate>,
pub rrf_inputs: Vec<TraceFusionInput>,
pub timing: TraceTiming,
}
impl TraceBuilder {
pub fn into_trace(self) -> SearchTrace {
SearchTrace {
lexical: self.lexical,
vector: self.vector,
rrf_inputs: self.rrf_inputs,
timing: self.timing,
}
}
}

View File

@@ -28,6 +28,7 @@ mod fts;
mod jobs;
mod schema;
mod store;
pub mod stats_ext;
pub use embeddings::EmbeddingRecordRow;
pub use error::StoreError;

View File

@@ -0,0 +1,168 @@
//! p9-fb-37: extended stats helpers — per-media / per-lang doc counts,
//! stale doc count, on-disk index byte sums.
use std::collections::BTreeMap;
use std::path::Path;
use kebab_core::{IndexBytes, MEDIA_KINDS};
use rusqlite::Connection;
/// p9-fb-37: result of [`breakdowns`] — three independent counts collected in one pass.
#[derive(Debug, Clone, Default)]
pub struct Breakdowns {
pub media: BTreeMap<String, u64>,
pub lang: BTreeMap<String, u64>,
pub stale_doc_count: u64,
}
/// `media` always contains all 5 `MEDIA_KINDS` (zero-padded).
/// `lang` only contains observed languages; NULL lang is
/// keyed as the literal string `"null"`. `stale_doc_count` is 0 when
/// `threshold_days == 0` (mirrors fb-32 staleness disable semantics).
pub fn breakdowns(
conn: &Connection,
threshold_days: u64,
) -> rusqlite::Result<Breakdowns> {
// media: dual JSON shape — text variant ("markdown") vs object
// variant ({"image":{"format":"png"}}). Same CASE WHEN as fb-36.
let mut media: BTreeMap<String, u64> = MEDIA_KINDS
.iter()
.map(|k| ((*k).to_string(), 0u64))
.collect();
let mut stmt = conn.prepare(
"SELECT \
CASE \
WHEN json_type(a.media_type) = 'text' \
THEN json_extract(a.media_type, '$') \
ELSE (SELECT key FROM json_each(a.media_type) LIMIT 1) \
END AS kind, \
COUNT(DISTINCT d.doc_id) \
FROM documents d JOIN assets a ON a.asset_id = d.asset_id \
GROUP BY kind",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))
})?;
for row in rows {
let (kind, n) = row?;
media.insert(kind, n);
}
let mut lang: BTreeMap<String, u64> = BTreeMap::new();
let mut stmt = conn.prepare(
"SELECT COALESCE(lang, 'null') AS l, COUNT(*) \
FROM documents GROUP BY l",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))
})?;
for row in rows {
let (l, n) = row?;
lang.insert(l, n);
}
let stale_doc_count: u64 = if threshold_days == 0 {
0
} else {
let secs = (threshold_days as i64) * 86_400;
let cutoff = time::OffsetDateTime::now_utc()
- time::Duration::seconds(secs);
let cutoff_str = cutoff
.format(&time::format_description::well_known::Rfc3339)
.expect("RFC3339 format");
conn.query_row(
"SELECT COUNT(*) FROM documents WHERE updated_at < ?",
[cutoff_str],
|r| r.get(0),
)?
};
Ok(Breakdowns {
media,
lang,
stale_doc_count,
})
}
/// Sum on-disk bytes of the SQLite database (main + WAL + SHM) and
/// the LanceDB directory tree. Missing files / dir = 0.
pub fn index_bytes(data_dir: &Path) -> std::io::Result<IndexBytes> {
fn file_size_or_zero(p: &Path) -> u64 {
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
}
fn dir_walk_sum(p: &Path) -> std::io::Result<u64> {
if !p.exists() {
return Ok(0);
}
let mut total = 0u64;
for entry in std::fs::read_dir(p)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
total += dir_walk_sum(&entry.path())?;
} else if ty.is_file() {
total += entry.metadata()?.len();
}
}
Ok(total)
}
let sqlite_main = data_dir.join("kebab.sqlite");
let sqlite_wal = data_dir.join("kebab.sqlite-wal");
let sqlite_shm = data_dir.join("kebab.sqlite-shm");
let sqlite = file_size_or_zero(&sqlite_main)
+ file_size_or_zero(&sqlite_wal)
+ file_size_or_zero(&sqlite_shm);
let lancedb = dir_walk_sum(&data_dir.join("lancedb"))?;
Ok(IndexBytes { sqlite, lancedb })
}
#[cfg(test)]
mod tests {
use super::*;
fn open_fresh() -> (tempfile::TempDir, crate::SqliteStore) {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
let store = crate::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
(dir, store)
}
#[test]
fn breakdowns_empty_corpus() {
let (_dir, store) = open_fresh();
let conn = store.read_conn();
let b = breakdowns(&conn, 0).unwrap();
// 5 keys all zero, lang map empty, stale 0.
assert_eq!(b.media.len(), 5);
for k in MEDIA_KINDS {
assert_eq!(b.media.get(*k), Some(&0u64));
}
assert!(b.lang.is_empty());
assert_eq!(b.stale_doc_count, 0);
}
#[test]
fn index_bytes_includes_sqlite_main() {
let (dir, _store) = open_fresh();
let b = index_bytes(dir.path()).unwrap();
assert!(b.sqlite > 0, "main sqlite file should exist after migrations");
assert_eq!(b.lancedb, 0);
}
#[test]
fn index_bytes_lancedb_dir_walk() {
let dir = tempfile::tempdir().unwrap();
let lance = dir.path().join("lancedb");
std::fs::create_dir_all(lance.join("vectors.lance")).unwrap();
std::fs::write(
lance.join("vectors.lance").join("data.bin"),
vec![0u8; 1024],
)
.unwrap();
let b = index_bytes(dir.path()).unwrap();
assert_eq!(b.lancedb, 1024);
}
}

View File

@@ -604,6 +604,12 @@ pub struct CountSummary {
/// ISO-8601 timestamp of the most-recently updated document row, or
/// `None` when the store is empty.
pub last_ingest_at: Option<String>,
/// p9-fb-37: per-media-kind doc count (5 keys, zero-padded).
pub media_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: per-language doc count, NULL keyed as `"null"`.
pub lang_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: docs whose `updated_at < now - threshold_days`. 0 when threshold=0.
pub stale_doc_count: u64,
}
impl SqliteStore {
@@ -611,39 +617,58 @@ impl SqliteStore {
/// most-recent `documents.updated_at` timestamp.
///
/// Uses `read_conn()` (no mutations) — mirrors the pattern used by
/// [`Self::corpus_revision`].
pub fn count_summary(&self) -> anyhow::Result<CountSummary> {
/// Shared helper: counts and breakdowns in a single pass with given threshold.
fn count_summary_inner(&self, threshold_days: u64) -> anyhow::Result<CountSummary> {
use anyhow::Context;
use rusqlite::OptionalExtension;
let conn = self.read_conn();
let doc_count: u64 = conn
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
.context("count documents")?;
let chunk_count: u64 = conn
.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))
.context("count chunks")?;
let asset_count: u64 = conn
.query_row("SELECT COUNT(*) FROM assets", [], |r| r.get(0))
.context("count assets")?;
let last_ingest_at: Option<String> = conn
.query_row(
"SELECT MAX(updated_at) FROM documents",
[],
|r| r.get(0),
)
.query_row("SELECT MAX(updated_at) FROM documents", [], |r| r.get(0))
.optional()
.context("max updated_at")?
.flatten();
let bd = crate::stats_ext::breakdowns(&conn, threshold_days).context("breakdowns")?;
Ok(CountSummary {
doc_count,
chunk_count,
asset_count,
last_ingest_at,
media_breakdown: bd.media,
lang_breakdown: bd.lang,
stale_doc_count: bd.stale_doc_count,
})
}
/// [`Self::corpus_revision`].
pub fn count_summary(&self) -> anyhow::Result<CountSummary> {
// p9-fb-37: default uses threshold_days=0 (matches fb-32 disable
// semantics). Callers that need real stale_doc_count call
// count_summary_with_threshold.
self.count_summary_inner(0)
}
/// p9-fb-37: variant that honors `config.search.stale_threshold_days`.
/// Callers who need a meaningful `stale_doc_count` (e.g. `kebab schema`)
/// pass the configured threshold; the older `count_summary` returns 0.
pub fn count_summary_with_threshold(
&self,
threshold_days: u64,
) -> anyhow::Result<CountSummary> {
self.count_summary_inner(threshold_days)
}
}
/// Apply the design §5 / task-spec pragmas. Called once per connection.
@@ -681,6 +706,9 @@ mod tests {
assert_eq!(s.chunk_count, 0);
assert_eq!(s.asset_count, 0);
assert!(s.last_ingest_at.is_none());
assert_eq!(s.media_breakdown.len(), 5);
assert!(s.lang_breakdown.is_empty());
assert_eq!(s.stale_doc_count, 0);
}
}

View File

@@ -387,6 +387,8 @@ pub struct App {
pub ask: Option<AskState>,
/// Populated by p9-4.
pub inspect: Option<InspectState>,
/// p9-fb-37: trace popup state, `Some` while open.
pub trace_popup: Option<crate::trace_popup::TracePopupState>,
/// Populated by p9-fb-03 when the user kicks off an in-shell
/// ingest (Library `r`). Cleared by the run loop a few seconds
/// after the run reaches a terminal event.
@@ -461,6 +463,7 @@ impl App {
search: None,
ask: None,
inspect: None,
trace_popup: None,
ingest_state: None,
error_overlay: None,
should_quit: false,

View File

@@ -80,6 +80,7 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) {
("Delete", "remove char at cursor"),
("g", "open hit's citation in $EDITOR (Normal)"),
("o", "inspect selected hit's chunk (Normal — was `i` pre-fb-21)"),
("t", "open retrieval trace popup (Normal — p9-fb-37)"),
("i", "Normal → Insert (toggle back to typing)"),
("Esc", "back to Library"),
]);

View File

@@ -27,6 +27,7 @@ mod run;
mod search;
mod terminal;
mod theme;
pub mod trace_popup;
pub use input::{InputBuffer, display_width, place_cursor_x, truncate_to_display_width};
pub use theme::{Palette, Role, Theme};

View File

@@ -130,6 +130,21 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
if event::poll(POLL_INTERVAL)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// p9-fb-37: trace popup eats keys while open.
// Sits ahead of cheatsheet + mode + pane dispatch
// so Esc / j / k / arrows route to the popup
// instead of leaking through to the search pane.
if app.trace_popup.is_some() {
let close = if let Some(popup) = app.trace_popup.as_mut() {
crate::trace_popup::handle_key_trace_popup(popup, key)
} else {
false
};
if close {
app.trace_popup = None;
}
continue;
}
// p9-fb-13: cheatsheet popup toggle takes
// precedence over both mode + pane dispatch.
// F1 toggles open/close. While visible, Esc
@@ -255,6 +270,12 @@ fn render_root(f: &mut Frame, app: &App) {
}
render_status_bar(f, outer[2], app);
render_key_hints(f, outer[3], app);
// p9-fb-37: trace popup overlays on top of pane content but
// below the error overlay (errors are higher-priority modal).
if let Some(popup) = &app.trace_popup {
let popup_area = centered_rect(80, 80, f.area());
crate::trace_popup::render_trace_popup(f, popup_area, popup);
}
if let Some(err) = &app.error_overlay {
render_error_overlay(f, f.area(), err, &app.theme);
}
@@ -263,6 +284,28 @@ fn render_root(f: &mut Frame, app: &App) {
}
}
/// p9-fb-37: centered sub-rect helper for the trace popup. Returns
/// a rect of `percent_x` × `percent_y` percent of `r`, centered.
fn centered_rect(percent_x: u16, percent_y: u16, r: ratatui::layout::Rect) -> ratatui::layout::Rect {
use ratatui::layout::{Constraint, Direction, Layout};
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",

View File

@@ -209,6 +209,51 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
// pre-fb-12 SHIFT/none heuristic).
let is_normal = state.mode == crate::app::Mode::Normal;
// p9-fb-37: `t` opens the trace popup. Re-runs the last submitted
// query with SearchOpts.trace = true. Bypasses cache by going
// through `search_with_opts_with_config` (Task 5 wires opts.trace
// to skip the LRU cache).
if is_normal
&& matches!(
(key.code, key.modifiers),
(KeyCode::Char('t'), KeyModifiers::NONE)
)
{
let (last_query, has_results) = {
let s = state.search.as_ref().unwrap();
(s.last_query.clone(), !s.hits.is_empty())
};
if !has_results {
return KeyOutcome::Continue;
}
if let Some((q_text, q_mode)) = last_query {
// TODO: thread filters when TUI gains a filter UI (currently
// mirrors fire_search which also passes default filters).
let q = kebab_core::SearchQuery {
text: q_text,
mode: q_mode,
k: state.config.search.default_k,
filters: kebab_core::SearchFilters::default(),
};
let opts = kebab_core::SearchOpts {
trace: true,
..Default::default()
};
match kebab_app::search_with_opts_with_config(state.config.clone(), q, opts) {
Ok(resp) => {
if let Some(t) = resp.trace {
state.trace_popup = Some(crate::trace_popup::TracePopupState::new(t));
}
}
Err(_) => {
// Silent failure — trace is debug-only; user
// can still see search hits without it.
}
}
}
return KeyOutcome::Continue;
}
// p9-fb-21: chunk-inspect rebound from `i` to `o` (vim "open").
// The `i` key is now the universal Normal→Insert toggle (handled
// in `mode_intercept`), so it cannot also mean "inspect chunk"

View File

@@ -0,0 +1,139 @@
//! p9-fb-37: TUI trace popup. Opens from Search pane via `t` key
//! when results are visible. Re-runs the current query with
//! `SearchOpts.trace = true` and displays the lex / vec / rrf union
//! + per-stage timing as a single scroll list.
use crossterm::event::{KeyCode, KeyEvent};
use kebab_core::SearchTrace;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
#[derive(Debug, Clone)]
pub struct TracePopupState {
pub trace: SearchTrace,
pub scroll: u16,
}
impl TracePopupState {
pub fn new(trace: SearchTrace) -> Self {
Self { trace, scroll: 0 }
}
}
pub fn render_trace_popup(f: &mut Frame, area: Rect, state: &TracePopupState) {
let mut lines: Vec<Line> = Vec::new();
let bold = Style::default().add_modifier(Modifier::BOLD);
lines.push(Line::from(Span::styled(
format!(
"Lexical ({} hits, {} ms)",
state.trace.lexical.len(),
state.trace.timing.lexical_ms,
),
bold,
)));
for c in &state.trace.lexical {
lines.push(Line::from(format!(
" #{:>2} score={:.4} chunk={}",
c.rank, c.score, c.chunk_id.0
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"Vector ({} hits, {} ms)",
state.trace.vector.len(),
state.trace.timing.vector_ms,
),
bold,
)));
for c in &state.trace.vector {
lines.push(Line::from(format!(
" #{:>2} score={:.4} chunk={}",
c.rank, c.score, c.chunk_id.0
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"RRF inputs ({} entries, {} ms fusion)",
state.trace.rrf_inputs.len(),
state.trace.timing.fusion_ms,
),
bold,
)));
for e in &state.trace.rrf_inputs {
lines.push(Line::from(format!(
" chunk={} lex={:?} vec={:?} fusion={:.4}",
e.chunk_id.0, e.lexical_rank, e.vector_rank, e.fusion_score
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("Total: {} ms", state.trace.timing.total_ms),
bold,
)));
let block = Block::default()
.title("Trace — Esc to close, j/k or ↑↓ to scroll")
.borders(Borders::ALL);
let p = Paragraph::new(lines)
.block(block)
.scroll((state.scroll, 0))
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
/// Handle keys while popup is open. Returns true if the popup should close.
pub fn handle_key_trace_popup(state: &mut TracePopupState, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => true,
KeyCode::Char('j') | KeyCode::Down => {
state.scroll = state.scroll.saturating_add(1);
false
}
KeyCode::Char('k') | KeyCode::Up => {
state.scroll = state.scroll.saturating_sub(1);
false
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
use kebab_core::TraceTiming;
fn dummy_state() -> TracePopupState {
TracePopupState::new(SearchTrace {
lexical: vec![],
vector: vec![],
rrf_inputs: vec![],
timing: TraceTiming::default(),
})
}
#[test]
fn esc_closes() {
let mut s = dummy_state();
assert!(handle_key_trace_popup(
&mut s,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
));
}
#[test]
fn j_scrolls_down() {
let mut s = dummy_state();
assert!(!handle_key_trace_popup(
&mut s,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
));
assert_eq!(s.scroll, 1);
}
}

View File

@@ -206,6 +206,22 @@ kebab search "rust" --doc-id "<doc-id>" --tag rust --json
Bad `--ingested-after` → `error.v1.code = config_invalid`, exit 2.
Unknown `--media` value → silently empty (no error).
### Trace + stats (fb-37)
Re-run a search with `--trace` to see per-stage candidate lists + timing:
```bash
kebab --config /tmp/kebab-smoke/config.toml search "rust async" --trace --json | jq .trace
```
Inspect the corpus health surface:
```bash
kebab --config /tmp/kebab-smoke/config.toml schema --json | jq .stats
```
Look for: `media_breakdown` (5 keys), `lang_breakdown`, `index_bytes`, `stale_doc_count`.
## P6-4 이미지 ingestion 옵션
`config.toml` 에 다음 절을 추가하면 `kebab ingest` 가 `**/*.png` / `**/*.jpg` 등 이미지 자산도 함께 색인합니다 (텍스트만 색인하려면 생략):

View File

@@ -54,6 +54,30 @@
{ "type": "string", "format": "date-time" },
{ "type": "null" }
]
},
"media_breakdown": {
"type": "object",
"description": "p9-fb-37: per-media-kind doc count. 5 keys (markdown/pdf/image/audio/other), zero-padded.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"lang_breakdown": {
"type": "object",
"description": "p9-fb-37: per-language doc count. NULL lang keyed as the literal string 'null'. Map may be empty on empty corpus.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"index_bytes": {
"type": "object",
"description": "p9-fb-37: on-disk byte sums.",
"required": ["sqlite", "lancedb"],
"properties": {
"sqlite": { "type": "integer", "minimum": 0 },
"lancedb": { "type": "integer", "minimum": 0 }
}
},
"stale_doc_count": {
"type": "integer",
"minimum": 0,
"description": "p9-fb-37: docs whose updated_at exceeds config.search.stale_threshold_days. 0 when threshold=0."
}
}
}

View File

@@ -9,6 +9,26 @@
"schema_version": { "const": "search_response.v1" },
"hits": { "type": "array", "description": "search_hit.v1[]" },
"next_cursor": { "type": ["string", "null"], "description": "Opaque base64 cursor for next page; null when no more hits." },
"truncated": { "type": "boolean", "description": "True when budget forced snippet shortening or k reduction. Independent of `next_cursor`: caller may widen `max_tokens` (re-issue same query) or follow `next_cursor` (advance through more hits) or both." }
"truncated": { "type": "boolean", "description": "True when budget forced snippet shortening or k reduction. Independent of `next_cursor`: caller may widen `max_tokens` (re-issue same query) or follow `next_cursor` (advance through more hits) or both." },
"trace": {
"type": "object",
"description": "p9-fb-37: present iff caller passed --trace / SearchOpts.trace=true. Lex/vec pre-fusion lists + RRF union + per-stage timing.",
"required": ["lexical", "vector", "rrf_inputs", "timing"],
"properties": {
"lexical": { "type": "array", "items": { "type": "object" } },
"vector": { "type": "array", "items": { "type": "object" } },
"rrf_inputs":{ "type": "array", "items": { "type": "object" } },
"timing": {
"type": "object",
"required": ["lexical_ms", "vector_ms", "fusion_ms", "total_ms"],
"properties": {
"lexical_ms": { "type": "integer", "minimum": 0 },
"vector_ms": { "type": "integer", "minimum": 0 },
"fusion_ms": { "type": "integer", "minimum": 0 },
"total_ms": { "type": "integer", "minimum": 0 }
}
}
}
}
}
}

View File

@@ -48,7 +48,7 @@ Use when the user wants to **find** a doc, or when you (the model) need raw chun
Input:
```json
{ "query": "<query>", "mode": "hybrid", "k": 10, "max_tokens": null, "snippet_chars": null, "cursor": null, "tags": null, "lang": null, "path_glob": null, "trust_min": null, "media": null, "ingested_after": null, "doc_id": null }
{ "query": "<query>", "mode": "hybrid", "k": 10, "max_tokens": null, "snippet_chars": null, "cursor": null, "tags": null, "lang": null, "path_glob": null, "trust_min": null, "media": null, "ingested_after": null, "doc_id": null, "trace": null }
```
- `mode = "hybrid"` is the default-correct choice. Use `"vector"` for semantic-only ("docs about X concept"), `"lexical"` for exact strings ("the literal flag `--foo-bar`").
@@ -57,6 +57,7 @@ Input:
- Output is `search_response.v1`: `{ hits: search_hit.v1[], next_cursor: string|null, truncated: bool }`. Iterate `response.hits[]` for individual hits. Key hit fields: `rank`, `score`, `doc_path`, `heading_path[]`, `section_label`, `snippet`, `citation` (line range / page), `chunk_id`.
- Cite back to the user as `doc_path § heading_path[-1]` so they can open the source.
- When `truncated: true`, the budget loop modified the page (snippet shortening or k reduction). `next_cursor` is **independent** — non-null whenever more hits may be reachable. Caller may widen `max_tokens` (re-issue same query for fuller snippets / more hits per page) or follow `next_cursor` (advance through more hits) or both. Mismatched cursor (corpus_revision changed) returns `error.v1.code = stale_cursor` — re-issue the search to obtain a fresh one.
- **`trace: true` (p9-fb-37)** — debug aid. Response carries an extra `trace` block: `lexical[]` + `vector[]` (pre-fusion candidates), `rrf_inputs[]` (RRF union before final cut), and `timing` (`lexical_ms`, `vector_ms`, `fusion_ms`, `total_ms`). Trace bypasses the search cache (always cold). Use sparingly — it bloats the wire response and is for diagnosing "why did this hit / not hit", not normal retrieval.
### `mcp__kebab__ask` — when you need the answer
@@ -133,7 +134,7 @@ Claude Code spawns `kebab mcp` at session start; the process stays alive across
Before using streaming or multi-turn features, probe what this binary supports — call `mcp__kebab__schema` (or CLI `kebab schema --json`):
Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (doc/chunk/asset count + last_ingest_at). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. Cheap call (no LLM), once per session.
Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (doc/chunk/asset count + last_ingest_at, plus p9-fb-37 health surface: `media_breakdown` per-kind doc counts (5 zero-padded keys: markdown / pdf / image / audio / other), `lang_breakdown` per BCP-47 lang (NULL keyed as the literal string `"null"`), `index_bytes.{sqlite,lancedb}` on-disk byte sums, `stale_doc_count` for docs older than `config.search.stale_threshold_days`). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. Cheap call (no LLM), once per session.
## Quick health check

View File

@@ -125,7 +125,7 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
- [p9-fb-34 output budget controls](p9/p9-fb-34-output-budget-controls.md) — ✅ 머지 + v0.5.0 cut 후보 (2026-05-09)
- [p9-fb-35 verbatim fetch](p9/p9-fb-35-verbatim-fetch.md) — ✅ 머지 + v0.5.0 cut 후보 (2026-05-09)
- [p9-fb-36 search filter args](p9/p9-fb-36-search-filters.md) — ✅ 머지 (2026-05-10)
- [p9-fb-37 trace + stats](p9/p9-fb-37-trace-and-stats.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27)
- [p9-fb-37 trace + stats](p9/p9-fb-37-trace-and-stats.md) — ✅ 머지 (2026-05-10)
### 🎯 0.5.0 — RAG quality (cascade 동반: V00X + reindex)
- [p9-fb-38 score semantics](p9/p9-fb-38-score-semantics.md) — ⏳ 미구현, brainstorm 필요

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-cli + kebab-search + kebab-rag
task_id: p9-fb-37
title: "Trace (--trace) + stats — pipeline 가시성"
status: open
status: completed
target_version: 0.4.0
depends_on: [p9-fb-27]
unblocks: []
@@ -14,7 +14,10 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — agent / 사용자가 "왜
# p9-fb-37 — Trace + stats
> **백로그 only — 미구현 (Nice-to-have).** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. trace 의 verbosity level / wire shape / stats 의 별도 명령 vs schema 통합 brainstorm 후 확정.
> **구현 완료.** 본 spec 은 구현 시점의 frozen 상태.
>
> - Design: [`docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md)
> - Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md)
## 증상 / 동기