Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1e82cca92 | |||
| 2c05dbd0dd | |||
| 96766406aa | |||
| 710945c4b0 | |||
| d4395a306b | |||
| bd48baa19a | |||
| b02ac8200e | |||
| 336962715a | |||
| 1a224bf983 | |||
| a210bf5d52 | |||
| 429287f6cb | |||
| 08495eb425 | |||
| 98cf4e8a04 | |||
| 4030f04f37 | |||
| 7c27633df2 | |||
| 3712d005cc | |||
| 7c85de065a | |||
| a0ccc7b021 | |||
| a8fd6994d2 | |||
| 505b3889fb | |||
| 772575d8f0 | |||
| 00ffe9c792 | |||
| 681c48b2a3 | |||
| 546c1564b0 | |||
| 79ad6e376f | |||
| 6ffbe0a5a3 | |||
| ab3408cb49 | |||
| b807fd5aa5 | |||
| 93436f9eca | |||
| 11ce7847a1 | |||
| 1d88dccf8a | |||
| 1eb0bbecb3 | |||
| 44fbffff26 | |||
| 63aece3ea1 | |||
| 28a8bbeace | |||
| 52a97303dc | |||
| 71fb2cbcb3 | |||
| 85855ef596 | |||
| da25ce330b | |||
| 5bfea3c28b | |||
| b6756f8ce3 | |||
| 016f380428 | |||
| bf28a1e4d9 | |||
| 24221826ed | |||
| 8a2f7affa6 | |||
| f28a422f79 | |||
| c56242d04f | |||
| 17c48a0ee6 | |||
| 64a009314c | |||
| ddfe7ba099 | |||
| 104363a0db | |||
| 6188a50c1c | |||
| 94e6146013 | |||
| 12c7dc9efb | |||
| cd1d4fb807 | |||
| 7150c376bb | |||
| 6280abf2df | |||
| 192da45dbf | |||
| cf35f36f88 | |||
| ed34f2e03f | |||
| 624b44c46b | |||
| caf690dc72 |
75
Cargo.lock
generated
75
Cargo.lock
generated
@@ -4127,7 +4127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-app"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -4142,12 +4142,11 @@ dependencies = [
|
||||
"kebab-embed-local",
|
||||
"kebab-llm",
|
||||
"kebab-llm-local",
|
||||
"kebab-normalize",
|
||||
"kebab-nli",
|
||||
"kebab-parse-code",
|
||||
"kebab-parse-image",
|
||||
"kebab-parse-md",
|
||||
"kebab-parse-pdf",
|
||||
"kebab-parse-types",
|
||||
"kebab-rag",
|
||||
"kebab-search",
|
||||
"kebab-source-fs",
|
||||
@@ -4172,12 +4171,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-chunk"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"kebab-core",
|
||||
"kebab-normalize",
|
||||
"kebab-parse-code",
|
||||
"kebab-parse-md",
|
||||
"serde_json",
|
||||
@@ -4189,7 +4187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-cli"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4210,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-config"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
@@ -4225,7 +4223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-core"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4239,7 +4237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4253,7 +4251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-local"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fastembed",
|
||||
@@ -4266,7 +4264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-eval"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4285,7 +4283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -4294,7 +4292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm-local"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -4311,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-mcp"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4328,23 +4326,23 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-normalize"
|
||||
version = "0.17.2"
|
||||
name = "kebab-nli"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
"kebab-parse-md",
|
||||
"kebab-parse-types",
|
||||
"hf-hub",
|
||||
"kebab-config",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tempfile",
|
||||
"tokenizers",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-code"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gix",
|
||||
@@ -4367,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-image"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
@@ -4391,11 +4389,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-md"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
"kebab-parse-types",
|
||||
"lingua",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
@@ -4404,11 +4401,12 @@ dependencies = [
|
||||
"time",
|
||||
"toml",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4419,23 +4417,16 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-types"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"kebab-core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-rag"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-llm",
|
||||
"kebab-nli",
|
||||
"kebab-search",
|
||||
"kebab-store-sqlite",
|
||||
"regex",
|
||||
@@ -4450,7 +4441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-search"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
@@ -4469,7 +4460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-source-fs"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4477,7 +4468,6 @@ dependencies = [
|
||||
"ignore",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-parse-code",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -4488,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-sqlite"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4496,7 +4486,6 @@ dependencies = [
|
||||
"kebab-chunk",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-normalize",
|
||||
"kebab-parse-md",
|
||||
"refinery",
|
||||
"rusqlite",
|
||||
@@ -4509,7 +4498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-vector"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrow",
|
||||
@@ -4533,7 +4522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
|
||||
106
Cargo.toml
106
Cargo.toml
@@ -2,11 +2,9 @@
|
||||
resolver = "3"
|
||||
members = [
|
||||
"crates/kebab-core",
|
||||
"crates/kebab-parse-types",
|
||||
"crates/kebab-config",
|
||||
"crates/kebab-source-fs",
|
||||
"crates/kebab-parse-md",
|
||||
"crates/kebab-normalize",
|
||||
"crates/kebab-chunk",
|
||||
"crates/kebab-store-sqlite",
|
||||
"crates/kebab-store-vector",
|
||||
@@ -24,6 +22,7 @@ members = [
|
||||
"crates/kebab-tui",
|
||||
"crates/kebab-mcp",
|
||||
"crates/kebab-parse-code",
|
||||
"crates/kebab-nli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -31,7 +30,95 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.17.2"
|
||||
version = "0.19.0"
|
||||
|
||||
# pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with
|
||||
# intentional allow-list. The allowed lints are either cosmetic (doc style),
|
||||
# informational (function size), or carry intentional truncation we accept
|
||||
# (numeric casts in tokenizer/ONNX inputs, hash modular reduction, etc).
|
||||
[workspace.lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
# Intentional u32 ↔ i64 casts in kebab-nli (ONNX i64 inputs from tokenizer u32 ids).
|
||||
# u64 ↔ usize across kebab-store-sqlite row counts. Wide truncation is auditable
|
||||
# at use site, not lint-wide.
|
||||
cast_possible_truncation = "allow"
|
||||
cast_possible_wrap = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
# Doc markdown style is cosmetic; we run rustdoc on demand.
|
||||
doc_markdown = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
# Informational only — splitting a long pipeline function isn't always cleaner.
|
||||
too_many_lines = "allow"
|
||||
# `Foo::default()` is concise and idiomatic here; `<Foo as Default>::default()`
|
||||
# adds noise without surfacing intent.
|
||||
default_trait_access = "allow"
|
||||
# Module name prefix on public items keeps the wire/log surface readable
|
||||
# (`refusal_reason::no_chunks` etc).
|
||||
module_name_repetitions = "allow"
|
||||
# We use `#[must_use]` deliberately on public results, not blanket.
|
||||
must_use_candidate = "allow"
|
||||
# `String` arg sometimes signals "I'll consume this" — let signature decide.
|
||||
needless_pass_by_value = "allow"
|
||||
# Idiomatic single-line bindings stay; let-else expansion isn't always clearer.
|
||||
manual_let_else = "allow"
|
||||
# `use` after `let` is a common kebab pattern (scoped imports next to use site).
|
||||
items_after_statements = "allow"
|
||||
# Naming pairs like `chunk_id` / `chunks_id` are intentional domain terms.
|
||||
similar_names = "allow"
|
||||
# `iter.map(format!).collect::<String>()` is idiomatic when the per-element
|
||||
# string is genuinely independent — `fold` only wins on accumulation patterns.
|
||||
format_collect = "allow"
|
||||
# Exhaustive `match` with explicit variant arms (vs `_`) catches future
|
||||
# variant additions at compile time (kebab core's `RefusalReason` pattern).
|
||||
match_wildcard_for_single_variants = "allow"
|
||||
# Copy types under `&self` keep call-site discipline; auto-deref noise > tiny perf gain.
|
||||
trivially_copy_pass_by_ref = "allow"
|
||||
# `unnecessary_wraps` flags helpers that could drop `Result`, but keeping the
|
||||
# Result allows future error variants without churning callers.
|
||||
unnecessary_wraps = "allow"
|
||||
# NLI score / RRF fusion / similarity threshold comparisons are intentional —
|
||||
# floats live in the `[0, 1]` band and are compared with explicit thresholds.
|
||||
float_cmp = "allow"
|
||||
# File-extension dispatch is keyed on ASCII conventions; case sensitivity
|
||||
# is part of the spec for `.md`, `.pdf`, etc.
|
||||
case_sensitive_file_extension_comparisons = "allow"
|
||||
# Config / opts structs intentionally bundle boolean flags (ingest options,
|
||||
# search modes, etc) — splitting them into enums would obscure the wire shape.
|
||||
struct_excessive_bools = "allow"
|
||||
# `bytecount` crate would be a new dep just for one-off ASCII counts.
|
||||
naive_bytecount = "allow"
|
||||
# `#[ignore]` annotations on tests document via the test name + nearby comment.
|
||||
ignore_without_reason = "allow"
|
||||
# `format!` push patterns are a hot path for kebab-tui's progressive rendering;
|
||||
# `write!` rewrite needs a verified-equal benchmark before swapping.
|
||||
format_push_string = "allow"
|
||||
# Builder-style `with_*` methods return `Self`; the existing `#[must_use]`
|
||||
# discipline lives on aggregate constructors, not every chainable setter.
|
||||
return_self_not_must_use = "allow"
|
||||
# Match arms grouped by side-effect over body equality (e.g. snake_case wire
|
||||
# label tables) — fanning them out keeps adding a new variant trivial.
|
||||
match_same_arms = "allow"
|
||||
# Remaining style-only warnings: trailing `continue` is sometimes clearer than
|
||||
# rewriting, `_x` underscored bindings document intent at the use site, and
|
||||
# `!(a == b)` reads better than `a != b` when paired with a complementary check.
|
||||
needless_continue = "allow"
|
||||
used_underscore_binding = "allow"
|
||||
nonminimal_bool = "allow"
|
||||
# Other one-off cosmetic items: large literal formatting, doc link quoting,
|
||||
# `Clone::clone_from` swap, `str::replace` chaining, `Iterator::any` ergonomics.
|
||||
unreadable_literal = "allow"
|
||||
many_single_char_names = "allow"
|
||||
doc_link_with_quotes = "allow"
|
||||
assigning_clones = "allow"
|
||||
collapsible_str_replace = "allow"
|
||||
trivial_regex = "allow"
|
||||
elidable_lifetime_names = "allow"
|
||||
range_plus_one = "allow"
|
||||
explicit_iter_loop = "allow"
|
||||
implicit_hasher = "allow"
|
||||
ref_option = "allow"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
@@ -102,6 +189,19 @@ tree-sitter-kotlin-ng = "1.1.0" # bare tree-sitter-kotlin requires ts <0.23;
|
||||
# C/C++ family grammars for code ingest (kebab-parse-code, p10-1D).
|
||||
tree-sitter-c = "0.24.2"
|
||||
tree-sitter-cpp = "0.23.4"
|
||||
# fb-41 PR-9 (kebab-nli): mDeBERTa-v3 XNLI verifier deps. Versions match
|
||||
# the fastembed 4.9 transitive set so the ONNX Runtime + tokenizer stack
|
||||
# stays single-versioned across the workspace. ort `default-features=false`
|
||||
# drops the bundled binary downloader (fastembed already provides one);
|
||||
# tokenizers `default-features=false, onig` swaps the default `esaxx` regex
|
||||
# backend for `onig` so the build doesn't need libstdc++ headers (verified
|
||||
# via PR-9a pre-flight: SentencePiece tokenizer.json loads + KR/EN encode).
|
||||
# hf-hub uses `ureq + rustls-tls` to stay aligned with kebab-embed-local's
|
||||
# pure-Rust TLS stack.
|
||||
ort = { version = "=2.0.0-rc.9", default-features = false, features = ["ndarray"] }
|
||||
tokenizers = { version = "0.21", default-features = false, features = ["onig"] }
|
||||
hf-hub = { version = "0.4", default-features = false, features = ["ureq", "rustls-tls"] }
|
||||
ndarray = "0.16"
|
||||
|
||||
# Disk-footprint trim for dev / test builds. Codegen, opt-level, and
|
||||
# behavior are unchanged — only DWARF debug info is reduced (line
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
P0–P5 + P6 + P7 + P9-1/2/3/4 (Library / Search / Ask / Inspect) + P10 전체 머지 완료 (현재 **v0.17.2**). `kebab ingest` 가 markdown / image / PDF / 소스코드 (Rust / Python / TS / JS / Go / Java / Kotlin / C / C++) / Tier 2 리소스 파일 (yaml/k8s / dockerfile / toml / json / xml / groovy / go-mod) + Tier 3 paragraph fallback (shell / 비-k8s YAML / AST 실패 케이스) 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page / code citation 반환. `kebab tui` 가 4 패널 (Library + Search + Ask + Inspect) 제공. **v0.17.0 cut (2026-05-24)**: 한국어 trigram FTS5 tokenizer (PR #159) + C typedef alias unit (PR #160) + `code_lang_chunk_breakdown` additive (PR #161). **v0.17.1 cut (2026-05-25)**: 확장 도그푸딩 후 `[models.llm] request_timeout_secs` config 노브 (PR #162) + sudo 없이 ollama 설치 + `kebab ask --stream` UX 권장 docs (PR #163). **v0.17.2 cut (2026-05-25)**: v0.17.1 post-dogfood polish — `[image.ocr] request_timeout_secs` 별 노브 (PR #164, v0.17.1 미진행 closure) + `heading_path` FTS5 column filter 로 text-only 매칭 + raw-mode escape hatch (PR #165, 2026-05-24 v0.17.0 trigram entry 의 JSON 노이즈 closure). 자세한 영향은 [v0.17.0 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.17.0) + [v0.17.1 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.17.1) + [v0.17.2 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.17.2). 구조적으로 남은 component 는 P9-5 (desktop tauri) 하나뿐, P8 (audio) 는 사용자 보류.
|
||||
P0–P5 + P6 + P7 + P9-1/2/3/4 (Library / Search / Ask / Inspect) + P10 전체 머지 완료 (현재 **v0.18.0**). `kebab ingest` 가 markdown / image / PDF / 소스코드 (Rust / Python / TS / JS / Go / Java / Kotlin / C / C++) / Tier 2 리소스 파일 (yaml/k8s / dockerfile / toml / json / xml / groovy / go-mod) + Tier 3 paragraph fallback (shell / 비-k8s YAML / AST 실패 케이스) 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page / code citation 반환. `kebab tui` 가 4 패널 (Library + Search + Ask + Inspect) 제공. **v0.17.0 cut (2026-05-24)**: 한국어 trigram FTS5 tokenizer (PR #159) + C typedef alias unit (PR #160) + `code_lang_chunk_breakdown` additive (PR #161). **v0.17.1 cut (2026-05-25)**: 확장 도그푸딩 후 `[models.llm] request_timeout_secs` config 노브 (PR #162) + sudo 없이 ollama 설치 + `kebab ask --stream` UX 권장 docs (PR #163). **v0.17.2 cut (2026-05-25)**: v0.17.1 post-dogfood polish — `[image.ocr] request_timeout_secs` 별 노브 (PR #164, v0.17.1 미진행 closure) + `heading_path` FTS5 column filter 로 text-only 매칭 + raw-mode escape hatch (PR #165, 2026-05-24 v0.17.0 trigram entry 의 JSON 노이즈 closure). **v0.18.0 cut (2026-05-26)**: fb-41 multi-hop RAG + NLI verification ship (PR #176-180) — `kebab ask --multi-hop` 의 decompose → decide → synthesize loop + mDeBERTa-v3 XNLI ONNX post-synthesize entailment 검사. dogfood S7 caffeine hallucination 의 silent LLM-self-judge ceiling 해결 (nli_score 0.0035 graceful refuse). 추가 `chore: workspace-wide cleanup + post-PR9 refactor` (PR #181) — clippy::pedantic baseline + H1 config wiring + 9 new tests. 자세한 영향은 [v0.17.0 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.17.0) + [v0.17.1 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.17.1) + [v0.17.2 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.17.2) + [v0.18.0 release notes](https://gitea.altair823.xyz/altair823-org/kebab/releases/tag/v0.18.0). 구조적으로 남은 component 는 P9-5 (desktop tauri) 하나뿐, P8 (audio) 는 사용자 보류.
|
||||
|
||||
## Phase 로드맵
|
||||
|
||||
@@ -26,12 +26,14 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
## Component 카운트
|
||||
|
||||
총 33 component task — spec 시점 31 개 + 후속 wiring task 3 (P3-5 / P6-4 / P7-3) 가 머지 시점에 추가됨. per-component 진행 + status 는 [tasks/INDEX.md](tasks/INDEX.md).
|
||||
총 33 component task — spec 시점 31 개 + 후속 wiring task 3 (P3-5 / P6-4 / P7-3) 가 머지 시점에 추가됨. v0.18.0 cut 시점에 fb-41 multi-hop RAG + NLI verification (PR-9 5 sub-PRs) 가 P9 추가 component 로 ship — `kebab-nli` 신규 crate (mDeBERTa-v3 XNLI ONNX verifier) + `kebab-rag::ask_multi_hop` (decompose/decide/synthesize loop + step 8.5 NLI hook). per-component 진행 + status 는 [tasks/INDEX.md](tasks/INDEX.md).
|
||||
|
||||
## 머지 후 발견된 버그 / 결정 (요약)
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
- **2026-05-26 kebab-normalize + kebab-parse-types 흡수 (24 → 22 crates, design §3.7b 재작성)** — v0.19.0 cut. 4 parser 중 markdown 한 갈래만 lift 를 경유하는 reality 가 design §3.7b 의 fan-in ≥ 2 가정과 diverge → thin layer (`kebab-parse-types`) + `kebab-normalize` 두 crate 가 `kebab-parse-md` 로 흡수. 5 사용 type + 3 forward-declared struct 모두 `kebab-parse-md::{types,normalize}` module 의 `pub` re-export 로 보존. wire / surface impact = 0 (CLI / TUI / MCP / `--json` / config / XDG / parser_version 모두 unchanged). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-26 design deviation entry).
|
||||
- **2026-05-26 v0.18.0 fb-41 multi-hop RAG + NLI verification ship (PR #176-180) + post-PR9 cleanup (PR #181)** — pre-v0.18.0 dogfood (`/build/cache/dogfood-v018/`, 33 assets / 205 chunks, gemma3:4b CPU only / 16 GB RAM) 에서 발견된 S7 caffeine hallucination 의 root cause = LLM-self-judge ceiling (synthesize 가 chunks 와 무관한 Adam optimizer gradient 식을 silent emit, self-judge 가 reject 못함). 학계 표준 (Self-RAG, CRAG, Auto-GDA, MedTrust-RAG) 결론 = deterministic post-synthesis verification. mDeBERTa-v3 XNLI ONNX (280 MB, Xenova HF) 가 `(packed_chunks, answer)` entailment 검사 — `[rag] nli_threshold > 0` (default 0.0 = disabled, production 권장 0.5) 일 때 활성. dogfood retest 측정 — S7 PR-8 baseline `grounded=true + Adam hallucination` → PR-9 `nli_verification_failed, nli_score 0.0035`. wire additive minor — `answer.v1.verification` field + `refusal_reason` 의 `nli_verification_failed` / `nli_model_unavailable` 추가, pre-v0.18 reader 무영향. 5 sub-PR 시퀀스 + cleanup PR (clippy::pedantic baseline + 의도적 30+ allow + H1 `[models.nli].model` config wiring + 9 new tests). post-refactor retest = PR-9d byte-identical (deterministic 확인). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-25 fb-41 PR-9 closure entry + S3 follow-up).
|
||||
- **2026-05-25 v0.17.2 post-v0.17.1 polish (PR #164 + #165)** — v0.17.1 의 두 follow-up closure. (1) `[image.ocr] request_timeout_secs` 별 노브 — `crates/kebab-parse-image/src/ocr.rs::REQUEST_TIMEOUT` hard 300s 제거, LLM 쪽 패턴 (PR #162) 을 OCR 어댑터에 동일 적용. 사용자 결정으로 별 노브 분리 (OCR vs LLM 의 cold start 패턴이 달라 독립 조절). v0.17.1 미진행 항목 closure. (2) `chunks_fts` 의 `heading_path` 컬럼이 JSON 표기 + path 세그먼트 까지 trigram 색인 → query false positive 가능 문제 closure. `lexical.rs::build_match_string` 가 non-raw 분기 결과를 `text : (<expr>)` 로 wrap — heading 색인 V007 verbatim 유지, 매칭만 text 한정. 사용자가 명시 heading 검색 하려면 raw mode `'heading_path : <token>'` escape hatch (SKILL.md 갱신). 둘 다 additive (옛 config 호환) / re-ingest 불필요. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-25 v0.17.2 두 entry).
|
||||
- **2026-05-25 v0.17.1 post-dogfood (PR #162 + #163)** — 확장 도그푸딩 (16 GB CPU only, gemma4:e4b 시도) 에서 발견된 두 follow-up 한 묶음. (1) `crates/kebab-llm-local/src/ollama.rs::REQUEST_TIMEOUT` hard 300s → `[models.llm] request_timeout_secs` config + env override (additive, default 300, `=0` 은 disable 아닌 "즉시 timeout" 이라 doc 명시). (2) README + SMOKE 에 sudo / systemd 없이 ollama 설치 + ≤4B Q4 권장 모델 + `kebab ask --stream` UX 권장 docs. additive only — 옛 config / wire 호환. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-25).
|
||||
- **2026-05-24 v0.17.0 PR-C `code_lang_chunk_breakdown` additive (closure of 2026-05-22 LOW)** — `schema.v1.stats` 에 chunk 수 집계 신규 키. 기존 `code_lang_breakdown` (doc count) 와 sister. 또 기존 두 필드 JSON schema description 의 "chunk count" 오기재 → "doc count" 로 정정. wire additive — schema_version bump 불필요. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-24 PR-C).
|
||||
|
||||
@@ -89,7 +89,7 @@ kebab doctor
|
||||
| `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. |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>] [--stream]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe. **`--stream` (p9-fb-33)** 로 ndjson `answer_event.v1` event (retrieval_done → token* → final) 를 stderr 에 흘리고 stdout 마지막 줄에 기존 `answer.v1` — agent 가 token 즉시 소비 가능 |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>] [--stream] [--multi-hop]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe. **`--stream` (p9-fb-33)** 로 ndjson `answer_event.v1` event (retrieval_done → token* → final) 를 stderr 에 흘리고 stdout 마지막 줄에 기존 `answer.v1` — agent 가 token 즉시 소비 가능. **`--multi-hop` (v0.18.0 fb-41)** — single-pass 대신 decompose → decide → synthesize 의 N-hop loop. compound 질문 (cross-doc / prereq chain) 에 효과적. 최종 답변 후 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 — `[rag] nli_threshold > 0` (default 0.0 = disabled, production 권장 0.5) 일 때 활성. entailment < threshold → `refusal_reason = "nli_verification_failed"` (LLM-self-judge ceiling 극복, S7 caffeine hallucination 같은 케이스 catch). 첫 호출 시 ~280 MB ONNX model 자동 다운로드 + RAM peak ~7-8 GB (gemma3:4b 기준). model unavailable 시 `refusal_reason = "nli_model_unavailable"`, 우회는 `[rag] nli_threshold = 0` 임시 disable. |
|
||||
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
|
||||
| `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 방지) |
|
||||
|
||||
@@ -12,8 +12,6 @@ kebab-core = { path = "../kebab-core" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
kebab-source-fs = { path = "../kebab-source-fs" }
|
||||
kebab-parse-md = { path = "../kebab-parse-md" }
|
||||
kebab-parse-types = { path = "../kebab-parse-types" }
|
||||
kebab-normalize = { path = "../kebab-normalize" }
|
||||
kebab-chunk = { path = "../kebab-chunk" }
|
||||
kebab-store-sqlite = { path = "../kebab-store-sqlite" }
|
||||
kebab-store-vector = { path = "../kebab-store-vector" }
|
||||
@@ -23,6 +21,11 @@ kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
kebab-llm = { path = "../kebab-llm" }
|
||||
kebab-llm-local = { path = "../kebab-llm-local" }
|
||||
kebab-rag = { path = "../kebab-rag" }
|
||||
# p9-fb-41 PR-9c-2: facade construction of OnnxNliVerifier when
|
||||
# `[rag] nli_threshold > 0`. Trait-only consumption via kebab-rag's
|
||||
# `with_verifier`; no kebab-nli internals leak into kebab-app code
|
||||
# beyond the construction site in `open_with_config`.
|
||||
kebab-nli = { path = "../kebab-nli" }
|
||||
# P6-4: image extractor + OCR + caption adapters live here. App
|
||||
# threads them into the per-asset dispatch (see `ingest_one_asset`
|
||||
# image branch). Trait-only consumption — no `kebab-parse-image`
|
||||
@@ -76,3 +79,6 @@ lopdf = "0.32"
|
||||
# error_wire::tests::llm_unreachable_classifies_to_model_unreachable needs a real
|
||||
# reqwest::Error (private constructor) — built from a connect-refused call.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -40,11 +40,18 @@ use anyhow::{Context, Result, anyhow};
|
||||
use lru::LruCache;
|
||||
|
||||
use kebab_core::{
|
||||
Answer, DocumentStore, Embedder, IndexVersion, LanguageModel, Retriever, SearchHit,
|
||||
SearchMode, SearchOpts, SearchQuery, VectorStore,
|
||||
Answer, DocumentStore, Embedder, ExtractContext, Extractor, IndexVersion, LanguageModel,
|
||||
MediaType, Retriever, SearchHit, SearchMode, SearchOpts, SearchQuery, VectorStore,
|
||||
};
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
use kebab_parse_code::{
|
||||
CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor,
|
||||
JavascriptAstExtractor, KotlinAstExtractor, PythonAstExtractor, RustAstExtractor,
|
||||
TypescriptAstExtractor,
|
||||
};
|
||||
use kebab_parse_image::ImageExtractor;
|
||||
use kebab_parse_pdf::PdfTextExtractor;
|
||||
use kebab_rag::{AskOpts, RagPipeline};
|
||||
use kebab_search::{HybridRetriever, LexicalRetriever, VectorRetriever};
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
@@ -115,6 +122,12 @@ pub fn short_query_hint(query_text: &str, hits_empty: bool) -> Option<String> {
|
||||
pub struct App {
|
||||
pub(crate) config: kebab_config::Config,
|
||||
pub(crate) sqlite: Arc<SqliteStore>,
|
||||
/// post-v0.18.0 extractor-dispatch-unification: polymorphic Extractor
|
||||
/// registry. App init 시 1회 등록되어 `extract_for(...)` 가 lookup
|
||||
/// 한다. 현재 11 entry (ImageExtractor + PdfTextExtractor + 9 AST).
|
||||
/// MarkdownExtractor 는 별 PR 에서 추가 — markdown ingest path 는
|
||||
/// 본 PR 에서 free-function 그대로 유지.
|
||||
pub(crate) extractors: Vec<Box<dyn Extractor + Send + Sync>>,
|
||||
/// Memoized embedder — built lazily on first `embedder()` call when
|
||||
/// embeddings are enabled. `OnceLock` keeps the struct `Sync` and
|
||||
/// the build path cold-only-once.
|
||||
@@ -133,6 +146,17 @@ pub struct App {
|
||||
/// `corpus_revision` snapshot embedded in `SearchCacheKey`
|
||||
/// invalidates every entry the moment a new ingest commit lands.
|
||||
search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>,
|
||||
/// p9-fb-41 PR-9c-2: NLI verifier built eagerly at
|
||||
/// `open_with_config` time when `config.rag.nli_threshold > 0`,
|
||||
/// consumed by `RagPipeline::with_verifier` on every `ask` /
|
||||
/// `ask_with_session` call. `None` when the gate is disabled
|
||||
/// (default, threshold = 0) — multi-hop skips step 8.5 entirely
|
||||
/// and single-pass never touches the verifier.
|
||||
///
|
||||
/// Built eagerly (not lazy) so the `open_with_config` `?`
|
||||
/// propagation surfaces NLI model construction errors at App
|
||||
/// boot time, before any user query runs.
|
||||
pipeline_verifier: Option<Arc<dyn kebab_nli::NliVerifier>>,
|
||||
}
|
||||
|
||||
/// p9-fb-19: cache key for `App::search`. Includes every field that
|
||||
@@ -193,16 +217,76 @@ impl App {
|
||||
// `None` (cache disabled — every search hits the retrievers).
|
||||
let search_cache = NonZeroUsize::new(config.search.cache_capacity)
|
||||
.map(|cap| Mutex::new(LruCache::new(cap)));
|
||||
// post-v0.18.0 extractor-dispatch-unification: build the 11-entry
|
||||
// Extractor registry. All entries are state-less unit structs with
|
||||
// zero-cost `new()`, so init cost is effectively 0 and side effects
|
||||
// are 0 — `pipeline_verifier` fallible `?` below may bail but the
|
||||
// already-constructed `extractors` Vec drops without cost. Markdown
|
||||
// is NOT registered (see field doc).
|
||||
let extractors: Vec<Box<dyn Extractor + Send + Sync>> = vec![
|
||||
Box::new(ImageExtractor::new()),
|
||||
Box::new(PdfTextExtractor::new()),
|
||||
Box::new(RustAstExtractor::new()),
|
||||
Box::new(PythonAstExtractor::new()),
|
||||
Box::new(TypescriptAstExtractor::new()),
|
||||
Box::new(JavascriptAstExtractor::new()),
|
||||
Box::new(GoAstExtractor::new()),
|
||||
Box::new(JavaAstExtractor::new()),
|
||||
Box::new(KotlinAstExtractor::new()),
|
||||
Box::new(CAstExtractor::new()),
|
||||
Box::new(CppAstExtractor::new()),
|
||||
];
|
||||
// p9-fb-41 PR-9c-2: build the NLI verifier when the gate is
|
||||
// enabled. App carries it on `RagPipeline` via
|
||||
// `with_verifier` so the rag crate doesn't have to know about
|
||||
// kebab-nli construction. Failure (`?`) surfaces as a user-
|
||||
// facing error at App boot — never a panic in the pipeline's
|
||||
// `expect("verifier must be Some when nli_threshold > 0.0")`.
|
||||
let pipeline_verifier: Option<Arc<dyn kebab_nli::NliVerifier>> =
|
||||
if config.rag.nli_threshold > 0.0 {
|
||||
let v = kebab_nli::OnnxNliVerifier::new(&config).context(
|
||||
"kebab-app: construct OnnxNliVerifier (config.rag.nli_threshold > 0)",
|
||||
)?;
|
||||
Some(Arc::new(v))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
config,
|
||||
sqlite: Arc::new(sqlite),
|
||||
extractors,
|
||||
embedder: OnceLock::new(),
|
||||
vector: OnceLock::new(),
|
||||
llm: OnceLock::new(),
|
||||
search_cache,
|
||||
pipeline_verifier,
|
||||
})
|
||||
}
|
||||
|
||||
/// Polymorphic dispatcher for the [`Extractor`] trait. Looks up the
|
||||
/// first Extractor whose `supports(media)` returns true and invokes
|
||||
/// `extract(ctx, bytes)` on it.
|
||||
///
|
||||
/// Errors with `anyhow!("no Extractor for media_type {media:?}")`
|
||||
/// when no matching Extractor is registered. Callers in
|
||||
/// `ingest_one_*_asset` reach this only after the outer 4-arm
|
||||
/// dispatch (`MediaType::Markdown` / `Image` / `Pdf` / `Code(lang)`)
|
||||
/// has matched, so a miss is a programming error — NOT a user-
|
||||
/// facing skip.
|
||||
pub(crate) fn extract_for(
|
||||
&self,
|
||||
media: &MediaType,
|
||||
ctx: &ExtractContext<'_>,
|
||||
bytes: &[u8],
|
||||
) -> Result<kebab_core::CanonicalDocument> {
|
||||
let extractor = self
|
||||
.extractors
|
||||
.iter()
|
||||
.find(|e| e.supports(media))
|
||||
.ok_or_else(|| anyhow!("no Extractor for media_type {media:?}"))?;
|
||||
extractor.extract(ctx, bytes)
|
||||
}
|
||||
|
||||
/// Run a [`SearchQuery`] through the configured retriever stack and
|
||||
/// return the top-k hits. p9-fb-19: result is served from the
|
||||
/// in-process LRU cache when the same `(query_norm, mode, k,
|
||||
@@ -266,7 +350,7 @@ impl App {
|
||||
// so other in-flight searches can use the cache concurrently.
|
||||
drop(guard);
|
||||
let hits = self.search_uncached(query)?;
|
||||
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut guard = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
guard.put(key, hits.clone());
|
||||
Ok(hits)
|
||||
}
|
||||
@@ -440,7 +524,7 @@ impl App {
|
||||
|
||||
// Snippet truncation if opts.snippet_chars set (mirror non-trace path).
|
||||
if opts.snippet_chars.is_some() {
|
||||
for h in hits.iter_mut() {
|
||||
for h in &mut hits {
|
||||
if h.snippet.chars().count() > snippet_chars {
|
||||
h.snippet = trim_to_chars(&h.snippet, snippet_chars);
|
||||
}
|
||||
@@ -475,7 +559,7 @@ impl App {
|
||||
// `config.search.snippet_chars`; this only kicks in when the
|
||||
// caller asked for *less*).
|
||||
if opts.snippet_chars.is_some() {
|
||||
for h in hits.iter_mut() {
|
||||
for h in &mut hits {
|
||||
if h.snippet.chars().count() > snippet_chars {
|
||||
h.snippet = trim_to_chars(&h.snippet, snippet_chars);
|
||||
}
|
||||
@@ -494,7 +578,7 @@ impl App {
|
||||
{
|
||||
current_snippet_cap =
|
||||
(current_snippet_cap / 2).max(SNIPPET_FLOOR);
|
||||
for h in hits.iter_mut() {
|
||||
for h in &mut hits {
|
||||
if h.snippet.chars().count() > current_snippet_cap {
|
||||
h.snippet =
|
||||
trim_to_chars(&h.snippet, current_snippet_cap);
|
||||
@@ -553,9 +637,26 @@ impl App {
|
||||
pub fn ask(&self, query: &str, opts: AskOpts) -> Result<Answer> {
|
||||
let retriever = self.build_retriever(opts.mode)?;
|
||||
let llm = self.llm()?;
|
||||
let pipeline = self.build_pipeline(retriever, llm);
|
||||
pipeline.ask(query, opts)
|
||||
}
|
||||
|
||||
/// p9-fb-41 PR-9c-2: shared pipeline builder used by [`Self::ask`]
|
||||
/// and [`Self::ask_with_session`]. Attaches the App-built NLI
|
||||
/// verifier (when `cfg.rag.nli_threshold > 0`) via
|
||||
/// `RagPipeline::with_verifier`, keeping the construction site in
|
||||
/// a single place so the two call paths can't drift.
|
||||
fn build_pipeline(
|
||||
&self,
|
||||
retriever: Arc<dyn Retriever>,
|
||||
llm: Arc<dyn LanguageModel>,
|
||||
) -> RagPipeline {
|
||||
let pipeline =
|
||||
RagPipeline::new(self.config.clone(), retriever, llm, self.sqlite.clone());
|
||||
pipeline.ask(query, opts)
|
||||
match &self.pipeline_verifier {
|
||||
Some(v) => pipeline.with_verifier(v.clone()),
|
||||
None => pipeline,
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-18: shared retriever-stack builder used by [`Self::ask`]
|
||||
@@ -660,10 +761,11 @@ impl App {
|
||||
|
||||
// p9-fb-18 R1: shared retriever builder removes the prior
|
||||
// copy of `ask`'s 35-line stack — see [`Self::build_retriever`].
|
||||
// p9-fb-41 PR-9c-2: shared `build_pipeline` attaches the NLI
|
||||
// verifier when the gate is enabled.
|
||||
let retriever = self.build_retriever(opts.mode)?;
|
||||
let llm = self.llm()?;
|
||||
let pipeline =
|
||||
RagPipeline::new(self.config.clone(), retriever, llm, self.sqlite.clone());
|
||||
let pipeline = self.build_pipeline(retriever, llm);
|
||||
let answer = pipeline.ask_with_history(
|
||||
query,
|
||||
history,
|
||||
@@ -823,7 +925,7 @@ impl App {
|
||||
/// clear` admin command). No-op when the cache is disabled.
|
||||
pub fn clear_search_cache(&self) {
|
||||
if let Some(cache) = self.search_cache.as_ref() {
|
||||
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut guard = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
guard.clear();
|
||||
}
|
||||
}
|
||||
@@ -1112,3 +1214,128 @@ mod tests_trace {
|
||||
assert!(resp.trace.is_some(), "trace populated when opts.trace=true");
|
||||
}
|
||||
}
|
||||
|
||||
/// post-v0.18.0 extractor-dispatch-unification: in-crate unit tests for
|
||||
/// the `App.extractors` registry + `App::extract_for` polymorphic
|
||||
/// dispatch. In-crate (not `tests/`) because `extractors` + `extract_for`
|
||||
/// are `pub(crate)` — integration tests cannot reach them.
|
||||
///
|
||||
/// Spec §5.1 + plan §2 Step 10 — 3 test class:
|
||||
/// 1. registry length = 11 (image + pdf + 9 AST).
|
||||
/// 2. mutually-exclusive `supports()` grid over 16 sample MediaTypes.
|
||||
/// 3. `extract_for` returns `Err("no Extractor ...")` for registry-NOT-cover
|
||||
/// MediaType (Audio).
|
||||
#[cfg(test)]
|
||||
mod tests_extractor_dispatch {
|
||||
use super::*;
|
||||
use kebab_core::{AudioType, ExtractConfig, ImageType};
|
||||
|
||||
/// helper: tempdir-isolated App for tests (mirrors `tests_trace`'s
|
||||
/// `open_app_with_temp_dir` pattern).
|
||||
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)
|
||||
}
|
||||
|
||||
/// Registry length invariant: 11 Extractor (image + pdf + 9 AST).
|
||||
/// Markdown is NOT registered (free-function path — defer to a
|
||||
/// separate PR per spec §3.4).
|
||||
#[test]
|
||||
fn registry_has_eleven_extractors() {
|
||||
let (_dir, app) = open_app_with_temp_dir();
|
||||
assert_eq!(
|
||||
app.extractors.len(),
|
||||
11,
|
||||
"registry must hold 11 Extractors (image + pdf + 9 AST). \
|
||||
markdown 은 별 PR."
|
||||
);
|
||||
}
|
||||
|
||||
/// 11 Extractor 의 `supports()` 가 16 sample MediaType 에 대해
|
||||
/// mutually exclusive — 어떤 두 Extractor 도 동일 MediaType 에
|
||||
/// 대해 true 반환 안 됨.
|
||||
#[test]
|
||||
fn supports_grid_is_mutually_exclusive() {
|
||||
let (_dir, app) = open_app_with_temp_dir();
|
||||
let samples = vec![
|
||||
MediaType::Markdown,
|
||||
MediaType::Pdf,
|
||||
MediaType::Image(ImageType::Png),
|
||||
MediaType::Image(ImageType::Jpeg),
|
||||
MediaType::Code("rust".into()),
|
||||
MediaType::Code("python".into()),
|
||||
MediaType::Code("typescript".into()),
|
||||
MediaType::Code("javascript".into()),
|
||||
MediaType::Code("go".into()),
|
||||
MediaType::Code("java".into()),
|
||||
MediaType::Code("kotlin".into()),
|
||||
MediaType::Code("c".into()),
|
||||
MediaType::Code("cpp".into()),
|
||||
MediaType::Code("yaml".into()), // registry NOT cover
|
||||
MediaType::Code("shell".into()), // registry NOT cover
|
||||
MediaType::Audio(AudioType::Wav), // registry NOT cover
|
||||
];
|
||||
for sample in &samples {
|
||||
let hits: Vec<_> = app
|
||||
.extractors
|
||||
.iter()
|
||||
.filter(|e| e.supports(sample))
|
||||
.collect();
|
||||
assert!(
|
||||
hits.len() <= 1,
|
||||
"mutually exclusive violated for {sample:?}: {} hits",
|
||||
hits.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `extract_for` 가 registry NOT cover MediaType (Audio) 에 대해
|
||||
/// `Err("no Extractor for media_type ...")` 반환. Audio MediaType
|
||||
/// 사용으로 RawAsset 의 actual content 의존 회피 — registry NOT
|
||||
/// cover → 즉시 Err.
|
||||
#[test]
|
||||
fn extract_for_unsupported_media_errors() {
|
||||
let (_dir, app) = open_app_with_temp_dir();
|
||||
|
||||
// Minimal RawAsset. Actual content never read — Audio MediaType
|
||||
// 는 registry NOT cover → `extract_for` 가 dispatch loop 안에서
|
||||
// 바로 Err 반환. RawAsset field set 은 `crates/kebab-core/src/
|
||||
// asset.rs:62-73` 와 정합 (8 field).
|
||||
let asset = kebab_core::RawAsset {
|
||||
asset_id: kebab_core::AssetId("00".repeat(16)),
|
||||
source_uri: kebab_core::SourceUri::File("/tmp/dummy.wav".into()),
|
||||
workspace_path: kebab_core::WorkspacePath("dummy.wav".to_string()),
|
||||
media_type: MediaType::Audio(AudioType::Wav),
|
||||
byte_len: 0,
|
||||
checksum: kebab_core::Checksum("00".repeat(32)),
|
||||
discovered_at: time::OffsetDateTime::now_utc(),
|
||||
// AssetStorage::Inline 미존재 — actual variant `Copied { path }`
|
||||
// 사용 (kebab-core/src/asset.rs:55-60).
|
||||
stored: kebab_core::AssetStorage::Copied {
|
||||
path: std::path::PathBuf::from("/tmp/dummy.wav"),
|
||||
},
|
||||
};
|
||||
|
||||
let workspace_root: std::path::PathBuf = std::path::PathBuf::from("/tmp");
|
||||
let cfg = ExtractConfig::default();
|
||||
let ctx = ExtractContext {
|
||||
asset: &asset,
|
||||
workspace_root: &workspace_root,
|
||||
config: &cfg,
|
||||
};
|
||||
let result = app.extract_for(&MediaType::Audio(AudioType::Wav), &ctx, &[]);
|
||||
assert!(result.is_err(), "Audio 는 registry 미포함 → Err 기대");
|
||||
let err_msg = format!("{:#}", result.unwrap_err());
|
||||
assert!(
|
||||
err_msg.contains("no Extractor"),
|
||||
"unexpected err: {err_msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,8 @@ fn parse_one(raw: &Value) -> Result<(SearchQuery, SearchOpts), String> {
|
||||
|
||||
let k = obj
|
||||
.get("k")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(0); // 0 → use config default in app
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map_or(0, |n| n as usize); // 0 → use config default in app
|
||||
|
||||
let trust_min = match obj.get("trust_min").and_then(|v| v.as_str()) {
|
||||
None => None,
|
||||
@@ -209,14 +208,14 @@ fn parse_one(raw: &Value) -> Result<(SearchQuery, SearchOpts), String> {
|
||||
let opts = SearchOpts {
|
||||
max_tokens: obj
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|n| n as usize),
|
||||
snippet_chars: obj
|
||||
.get("snippet_chars")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|n| n as usize),
|
||||
cursor: obj.get("cursor").and_then(|v| v.as_str()).map(String::from),
|
||||
trace: obj.get("trace").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
trace: obj.get("trace").and_then(serde_json::Value::as_bool).unwrap_or(false),
|
||||
};
|
||||
|
||||
Ok((
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
|
||||
}
|
||||
let mut details = json!({});
|
||||
if verbose {
|
||||
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
|
||||
let chain: Vec<String> = err.chain().map(std::string::ToString::to_string).collect();
|
||||
details = json!({"chain": chain});
|
||||
}
|
||||
ErrorV1 {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn ensure_kebabignore_entry(workspace_root: &Path) -> Result<()> {
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
writeln!(file, "{}", KEBABIGNORE_LINE)?;
|
||||
writeln!(file, "{KEBABIGNORE_LINE}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -166,8 +166,8 @@ mod tests {
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v.get("kind").and_then(|s| s.as_str()), Some("asset_started"));
|
||||
assert_eq!(v.get("idx").and_then(|n| n.as_u64()), Some(1));
|
||||
assert_eq!(v.get("total").and_then(|n| n.as_u64()), Some(10));
|
||||
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(1));
|
||||
assert_eq!(v.get("total").and_then(serde_json::Value::as_u64), Some(10));
|
||||
assert_eq!(v.get("path").and_then(|s| s.as_str()), Some("notes/foo.md"));
|
||||
assert_eq!(v.get("media").and_then(|s| s.as_str()), Some("markdown"));
|
||||
}
|
||||
@@ -184,8 +184,8 @@ mod tests {
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v.get("kind").and_then(|s| s.as_str()), Some("completed"));
|
||||
let counts = v.get("counts").unwrap();
|
||||
assert_eq!(counts.get("scanned").and_then(|n| n.as_u64()), Some(5));
|
||||
assert_eq!(counts.get("new").and_then(|n| n.as_u64()), Some(2));
|
||||
assert_eq!(counts.get("scanned").and_then(serde_json::Value::as_u64), Some(5));
|
||||
assert_eq!(counts.get("new").and_then(serde_json::Value::as_u64), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -43,16 +43,13 @@ use kebab_chunk::{CodeCAstV1Chunker, CodeCppAstV1Chunker, CodeGoAstV1Chunker, Co
|
||||
use kebab_core::{
|
||||
Answer, Block, CanonicalDocument, Chunk, ChunkId, ChunkPolicy, ChunkerVersion, Chunker,
|
||||
DocFilter, DocSummary, DocumentId, DocumentStore, Embedder, EmbeddingInput,
|
||||
EmbeddingKind, ExtractContext, Extractor, IngestReport, Lang, LanguageModel, MediaType,
|
||||
EmbeddingKind, ExtractContext, IngestReport, Lang, LanguageModel, MediaType,
|
||||
ParserVersion, RawAsset, SearchHit, SearchQuery, SourceScope,
|
||||
SourceUri, VectorRecord, VectorStore,
|
||||
};
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
use kebab_normalize::build_canonical_document;
|
||||
use kebab_parse_image::{ImageExtractor, OllamaVisionOcr, apply_caption, apply_ocr};
|
||||
use kebab_parse_code::{CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor, JavascriptAstExtractor, KotlinAstExtractor, PythonAstExtractor, RustAstExtractor, TypescriptAstExtractor};
|
||||
use kebab_parse_pdf::PdfTextExtractor;
|
||||
use kebab_parse_md::{BodyHints, parse_blocks, parse_frontmatter};
|
||||
use kebab_parse_image::{OllamaVisionOcr, apply_caption, apply_ocr};
|
||||
use kebab_parse_md::{BodyHints, build_canonical_document, parse_blocks, parse_frontmatter};
|
||||
use kebab_source_fs::FsSourceConnector;
|
||||
|
||||
mod app;
|
||||
@@ -289,8 +286,7 @@ pub fn ingest_with_config_opts(
|
||||
let cancelled = || {
|
||||
opts.cancel
|
||||
.as_ref()
|
||||
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
|
||||
};
|
||||
let force_reingest = opts.force_reingest;
|
||||
let started_instant = std::time::Instant::now();
|
||||
@@ -355,9 +351,7 @@ pub fn ingest_with_config_opts(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let image_extractor = ImageExtractor::new();
|
||||
let image_pipeline = ImagePipeline {
|
||||
extractor: &image_extractor,
|
||||
ocr_engine: ocr_engine.as_ref(),
|
||||
caption_llm: caption_llm.as_deref(),
|
||||
};
|
||||
@@ -394,7 +388,7 @@ pub fn ingest_with_config_opts(
|
||||
let purged_deleted_files = sweep_deleted_files(
|
||||
&app,
|
||||
&scanned_paths,
|
||||
vector_store.as_ref().map(|v| v.as_ref()),
|
||||
vector_store.as_ref().map(std::convert::AsRef::as_ref),
|
||||
)?;
|
||||
|
||||
let started_at = time::OffsetDateTime::now_utc();
|
||||
@@ -509,10 +503,10 @@ pub fn ingest_with_config_opts(
|
||||
*skipped_by_extension.entry(ext).or_insert(0) += 1;
|
||||
}
|
||||
kebab_core::IngestItemKind::Unchanged => {
|
||||
unchanged_count = unchanged_count.saturating_add(1)
|
||||
unchanged_count = unchanged_count.saturating_add(1);
|
||||
}
|
||||
kebab_core::IngestItemKind::Error => {
|
||||
error_count = error_count.saturating_add(1)
|
||||
error_count = error_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
crate::ingest_progress::emit(
|
||||
@@ -760,7 +754,6 @@ type SqliteStoreAlias = kebab_store_sqlite::SqliteStore;
|
||||
/// once per ingest invocation. Threaded through `ingest_one_asset` so
|
||||
/// the dispatch does not need ten separate parameters.
|
||||
struct ImagePipeline<'a> {
|
||||
extractor: &'a ImageExtractor,
|
||||
ocr_engine: Option<&'a OllamaVisionOcr>,
|
||||
caption_llm: Option<&'a dyn LanguageModel>,
|
||||
}
|
||||
@@ -940,9 +933,7 @@ fn try_skip_unchanged(
|
||||
fn ext_for_skip_warning(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| NO_EXT_SENTINEL.to_string())
|
||||
.and_then(|s| s.to_str()).map_or_else(|| NO_EXT_SENTINEL.to_string(), str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
/// p9-fb-25: render the `IngestItem.warnings` line for a Skipped
|
||||
@@ -1119,7 +1110,7 @@ fn ingest_one_asset(
|
||||
parser_version,
|
||||
all_warnings,
|
||||
)
|
||||
.context("kb-normalize::build_canonical_document")?;
|
||||
.context("kb-parse-md::build_canonical_document")?;
|
||||
|
||||
let chunks = MdHeadingV1Chunker
|
||||
.chunk(&canonical, chunk_policy)
|
||||
@@ -1236,7 +1227,6 @@ fn ingest_one_image_asset(
|
||||
image_pipeline: &ImagePipeline<'_>,
|
||||
force_reingest: bool,
|
||||
) -> anyhow::Result<kebab_core::IngestItem> {
|
||||
let image_extractor = image_pipeline.extractor;
|
||||
let ocr_engine = image_pipeline.ocr_engine;
|
||||
let caption_llm = image_pipeline.caption_llm;
|
||||
let path = match &asset.source_uri {
|
||||
@@ -1295,9 +1285,9 @@ fn ingest_one_image_asset(
|
||||
workspace_root: &workspace_root,
|
||||
config: &extract_config,
|
||||
};
|
||||
let mut canonical = image_extractor
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-image::ImageExtractor::extract")?;
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (image)")?;
|
||||
|
||||
// 2 + 3. Apply OCR / caption when their adapters exist. Both are
|
||||
// Lenient — failure is captured into Provenance Warning,
|
||||
@@ -1784,9 +1774,9 @@ fn ingest_one_pdf_asset(
|
||||
workspace_root: &workspace_root,
|
||||
config: &extract_config,
|
||||
};
|
||||
let mut canonical = PdfTextExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-pdf::PdfTextExtractor::extract")?;
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (pdf)")?;
|
||||
|
||||
// Per-medium chunker selection: PDF docs always use pdf-page-v1
|
||||
// regardless of `config.chunking.chunker_version`. The chunker
|
||||
@@ -2011,43 +2001,24 @@ fn ingest_one_code_asset(
|
||||
config: &extract_config,
|
||||
};
|
||||
|
||||
// p10-1b Task D/G/J/L: extractor per-lang.
|
||||
// post-v0.18.0 extractor-dispatch-unification:
|
||||
// 9 AST lang 의 dispatch 가 polymorphic — App.extractors registry 의
|
||||
// `*AstExtractor` entry 가 lang string 으로 disjoint `supports()` 비교
|
||||
// 후 단일 hit. Tier 2 (manifest) + Tier 3 (shell) 은 free-function
|
||||
// `synthesize_tier2_document` 유지 (Extractor impl 아님 — 별 PR).
|
||||
// p10-3: capture Result so Tier 1 extractor errors can fall back to Tier 3.
|
||||
let canonical_result: anyhow::Result<kebab_core::CanonicalDocument> = match code_lang {
|
||||
"rust" => RustAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::RustAstExtractor::extract (code:rust)"),
|
||||
"python" => PythonAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::PythonAstExtractor::extract (code:python)"),
|
||||
"typescript" => TypescriptAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::TypescriptAstExtractor::extract (code:typescript)"),
|
||||
"javascript" => JavascriptAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::JavascriptAstExtractor::extract (code:javascript)"),
|
||||
"go" => GoAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::GoAstExtractor::extract (code:go)"),
|
||||
"java" => JavaAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::JavaAstExtractor::extract (code:java)"),
|
||||
"kotlin" => KotlinAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::KotlinAstExtractor::extract (code:kotlin)"),
|
||||
// 9 AST lang: rust / python / typescript / javascript / go / java / kotlin / c / cpp
|
||||
"rust" | "python" | "typescript" | "javascript" | "go" | "java" | "kotlin" | "c"
|
||||
| "cpp" => app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.with_context(|| format!("kb-app::extract_for (code:{code_lang})")),
|
||||
// p10-2 Tier 2: no extractor — synthesize Document directly from raw bytes.
|
||||
"yaml" | "dockerfile" | "toml" | "json" | "xml" | "groovy" | "go-mod" => {
|
||||
synthesize_tier2_document(asset, &bytes, code_lang, &parser_version)
|
||||
}
|
||||
// p10-3: shell reuses the same synthesizer.
|
||||
"shell" => synthesize_tier2_document(asset, &bytes, "shell", &parser_version),
|
||||
// p10-1D: C + C++ AST extractors.
|
||||
"c" => CAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kebab-parse-code::CAstExtractor::extract (code:c)"),
|
||||
"cpp" => CppAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kebab-parse-code::CppAstExtractor::extract (code:cpp)"),
|
||||
other => anyhow::bail!("unreachable (extract): {other}"),
|
||||
};
|
||||
|
||||
@@ -2407,7 +2378,7 @@ fn lang_hint_from_doc(doc: &CanonicalDocument) -> Option<Lang> {
|
||||
|
||||
/// Convenience: end byte of the frontmatter region (or 0 when absent).
|
||||
fn fm_span_end(span: Option<kebab_parse_md::FrontmatterSpan>) -> usize {
|
||||
span.map(|s| s.end).unwrap_or(0)
|
||||
span.map_or(0, |s| s.end)
|
||||
}
|
||||
|
||||
/// Count `\n` in a byte prefix to convert frontmatter byte span to
|
||||
@@ -2710,8 +2681,7 @@ pub fn ingest_file_with_config(
|
||||
const SUPPORTED_EXTS: &[&str] = &["md", "pdf", "png", "jpg", "jpeg"];
|
||||
if !SUPPORTED_EXTS.contains(&ext.as_str()) {
|
||||
anyhow::bail!(
|
||||
"ingest-file: unsupported extension `.{}` (supported: {:?})",
|
||||
ext, SUPPORTED_EXTS
|
||||
"ingest-file: unsupported extension `.{ext}` (supported: {SUPPORTED_EXTS:?})"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ fn collect_stats(
|
||||
store: &kebab_store_sqlite::SqliteStore,
|
||||
) -> anyhow::Result<Stats> {
|
||||
let counts = store
|
||||
.count_summary_with_threshold(cfg.search.stale_threshold_days as u64)?;
|
||||
.count_summary_with_threshold(u64::from(cfg.search.stale_threshold_days))?;
|
||||
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}"))?;
|
||||
|
||||
@@ -33,6 +33,7 @@ fn ask_lexical_smoke() {
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
multi_hop: false,
|
||||
};
|
||||
// The fixture workspace contains "ownership" content; the model's
|
||||
// citation behavior depends on its training, so we don't assert on
|
||||
|
||||
@@ -1267,7 +1267,7 @@ fn tier1_cpp_ingest_searchable() {
|
||||
// (method) depending on which chunk ranks first.
|
||||
assert!(
|
||||
symbol.as_deref().is_some_and(|s| s.starts_with("kebab::chunk::Foo")),
|
||||
"C++ symbol must start with namespace::Class prefix, got {:?}", symbol
|
||||
"C++ symbol must start with namespace::Class prefix, got {symbol:?}"
|
||||
);
|
||||
assert!(*line_start >= 1, "line_start must be >=1");
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ fn ingest_file_copies_external_md_and_reports_new() {
|
||||
assert!(ext_dir.is_dir());
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1, "exactly one file in _external/");
|
||||
let name = entries[0].file_name().to_string_lossy().into_owned();
|
||||
|
||||
@@ -35,7 +35,7 @@ fn ingest_stdin_writes_frontmatter_and_reports_new() {
|
||||
// _external/ contains exactly one .md file with frontmatter.
|
||||
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let content = fs::read_to_string(entries[0].path()).unwrap();
|
||||
@@ -60,7 +60,7 @@ fn ingest_stdin_without_source_uri() {
|
||||
|
||||
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
let content = fs::read_to_string(entries[0].path()).unwrap();
|
||||
assert!(content.contains("title: \"Title\""));
|
||||
|
||||
81
crates/kebab-app/tests/open_with_config_nli.rs
Normal file
81
crates/kebab-app/tests/open_with_config_nli.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! Tests for `App::open_with_config`'s NLI verifier construction path.
|
||||
//!
|
||||
//! Coverage:
|
||||
//! 1. `open_with_config_nli_fails_when_model_dir_unwritable_and_threshold_positive` —
|
||||
//! when `rag.nli_threshold > 0` and `storage.model_dir` is unwritable,
|
||||
//! `open_with_config` returns `Err` with "OnnxNliVerifier" in the
|
||||
//! error chain.
|
||||
//! 2. `open_with_config_nli_skipped_when_threshold_zero` —
|
||||
//! same bad `model_dir`, but `rag.nli_threshold = 0.0` (gate disabled),
|
||||
//! so `OnnxNliVerifier::new` is never called and `open_with_config`
|
||||
//! succeeds.
|
||||
//!
|
||||
//! `/proc/1/root` is the init process's filesystem root; on Linux it is
|
||||
//! owned by root and not traversable by unprivileged users, making
|
||||
//! `create_dir_all` fail with `EACCES` — a reliable "unwritable path"
|
||||
//! that requires no test setup beyond the path literal.
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
/// Return a `Config` whose `data_dir` lives in a fresh `TempDir`
|
||||
/// (so `SqliteStore::open` succeeds) and whose `model_dir` is set to
|
||||
/// `/proc/1/root` (unwritable by non-root processes on Linux).
|
||||
///
|
||||
/// The `TempDir` is returned alongside the config so the caller keeps
|
||||
/// it alive until the test completes — dropping it early would delete
|
||||
/// the data directory before any assertions run.
|
||||
fn config_with_unwritable_model_dir() -> (tempfile::TempDir, Config) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = Config::defaults();
|
||||
// Valid data_dir → SqliteStore::open + run_migrations succeed.
|
||||
cfg.storage.data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
// /proc/1/root is only accessible to root; create_dir_all will
|
||||
// return EACCES for any unprivileged user, which is exactly the
|
||||
// failure mode we want to exercise.
|
||||
cfg.storage.model_dir = "/proc/1/root".to_string();
|
||||
(tmp, cfg)
|
||||
}
|
||||
|
||||
// ── 1. Failure path: threshold > 0 + unwritable model_dir ─────────────────
|
||||
|
||||
#[test]
|
||||
fn open_with_config_nli_fails_when_model_dir_unwritable_and_threshold_positive() {
|
||||
let (_tmp, mut cfg) = config_with_unwritable_model_dir();
|
||||
cfg.rag.nli_threshold = 0.5; // gate enabled → OnnxNliVerifier::new runs
|
||||
|
||||
let result = kebab_app::App::open_with_config(cfg);
|
||||
|
||||
let Err(err) = result else {
|
||||
panic!(
|
||||
"App::open_with_config must fail when model_dir is unwritable and nli_threshold > 0"
|
||||
);
|
||||
};
|
||||
// The error chain must identify the OnnxNliVerifier as the source so
|
||||
// an operator reading logs can trace the failure to the NLI config.
|
||||
let err_chain = format!("{err:?}");
|
||||
assert!(
|
||||
err_chain.contains("OnnxNliVerifier"),
|
||||
"error chain must mention OnnxNliVerifier; full chain: {err_chain}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Success path: threshold = 0.0 → NLI verifier never constructed ──────
|
||||
|
||||
#[test]
|
||||
fn open_with_config_nli_skipped_when_threshold_zero() {
|
||||
let (_tmp, cfg) = config_with_unwritable_model_dir();
|
||||
// Default nli_threshold is 0.0 — gate disabled, verifier skipped.
|
||||
assert!(
|
||||
(cfg.rag.nli_threshold - 0.0).abs() < f32::EPSILON,
|
||||
"precondition: default nli_threshold must be 0.0 (gate disabled)"
|
||||
);
|
||||
|
||||
// A bad model_dir must NOT cause a failure when the NLI gate is off.
|
||||
let result = kebab_app::App::open_with_config(cfg);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"App::open_with_config must succeed when nli_threshold = 0.0 \
|
||||
(OnnxNliVerifier is never constructed); err: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,10 @@ use common::TestEnv;
|
||||
fn require_avx_or_panic() {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if !std::is_x86_feature_detected!("avx") {
|
||||
panic!(
|
||||
"kb-app vector integration test requires AVX-capable hardware; \
|
||||
host CPU lacks AVX. Run on an AVX-capable machine."
|
||||
);
|
||||
}
|
||||
assert!(std::is_x86_feature_detected!("avx"),
|
||||
"kb-app vector integration test requires AVX-capable hardware; \
|
||||
host CPU lacks AVX. Run on an AVX-capable machine."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,16 @@ tracing = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# kb-parse-md / kb-normalize / kb-parse-code are dev-only — used by the
|
||||
# snapshot integration tests to build a CanonicalDocument from fixture files.
|
||||
# Forbidden as regular deps per design §8 (chunker consumes CanonicalDocument
|
||||
# from kb-core only); `cargo tree -p kb-chunk --depth 1` (default scope,
|
||||
# excludes dev-deps) confirms this.
|
||||
# kb-parse-md / kb-parse-code are dev-only — used by the snapshot integration
|
||||
# tests to build a CanonicalDocument from fixture files. kb-parse-md absorbed
|
||||
# kb-normalize in v0.19.0 (HOTFIXES.md 2026-05-26). Forbidden as regular deps
|
||||
# per design §8 (chunker consumes CanonicalDocument from kb-core only);
|
||||
# `cargo tree -p kb-chunk --depth 1` (default scope, excludes dev-deps)
|
||||
# confirms this.
|
||||
kebab-parse-md = { path = "../kebab-parse-md" }
|
||||
kebab-parse-code = { path = "../kebab-parse-code" }
|
||||
kebab-normalize = { path = "../kebab-normalize" }
|
||||
serde_json = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -266,7 +266,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn oversize_unit_splits_into_parts_with_unique_ids() {
|
||||
let body = (0..500).map(|i| format!("\tx{i} = {i};\n")).collect::<Vec<_>>().join("");
|
||||
let body = (0..500).map(|i| format!("\tx{i} = {i};\n")).collect::<String>();
|
||||
let code = format!("int big() {{\n{body}\n}}");
|
||||
let doc = code_doc(&[("big", 1, 502, &code)]);
|
||||
let chunks = CodeCAstV1Chunker.chunk(&doc, &policy()).unwrap();
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn oversize_unit_splits_into_parts_with_unique_ids() {
|
||||
let body = (0..500).map(|i| format!("\tx{i} = {i};\n")).collect::<Vec<_>>().join("");
|
||||
let body = (0..500).map(|i| format!("\tx{i} = {i};\n")).collect::<String>();
|
||||
let code = format!("int big() {{\n{body}\n}}");
|
||||
let doc = code_doc(&[("big", 1, 502, &code)]);
|
||||
let chunks = CodeCppAstV1Chunker.chunk(&doc, &policy()).unwrap();
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
let n = ids.len(); ids.sort(); ids.dedup();
|
||||
let n = ids.len(); ids.sort_unstable(); ids.dedup();
|
||||
assert_eq!(ids.len(), n, "chunk_ids unique across split parts");
|
||||
}
|
||||
|
||||
|
||||
@@ -387,9 +387,7 @@ fn render_block_text(b: &Block) -> String {
|
||||
// alt keeps lexical search hits on filenames working even when
|
||||
// P6-1's filename auto-fill is bypassed.
|
||||
Block::ImageRef(i) => {
|
||||
let alt = if !i.alt.is_empty() {
|
||||
i.alt.clone()
|
||||
} else {
|
||||
let alt = if i.alt.is_empty() {
|
||||
// P6-1 falls back to filename so this branch is
|
||||
// defensive — keep it lest a future test fixture or
|
||||
// synthetic block path skip the auto-fill.
|
||||
@@ -399,17 +397,17 @@ fn render_block_text(b: &Block) -> String {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("[image]")
|
||||
.to_string()
|
||||
} else {
|
||||
i.alt.clone()
|
||||
};
|
||||
let ocr = i
|
||||
.ocr
|
||||
.as_ref()
|
||||
.map(|o| o.joined.as_str())
|
||||
.unwrap_or("");
|
||||
.map_or("", |o| o.joined.as_str());
|
||||
let cap = i
|
||||
.caption
|
||||
.as_ref()
|
||||
.map(|c| c.text.as_str())
|
||||
.unwrap_or("");
|
||||
.map_or("", |c| c.text.as_str());
|
||||
[alt.as_str(), ocr, cap]
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
|
||||
@@ -450,7 +450,7 @@ mod tests {
|
||||
// chunk_ids stay distinct despite identical block_ids — the
|
||||
// per-chunk policy_hash variant is doing its job.
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
ids.sort();
|
||||
ids.sort_unstable();
|
||||
let total = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), total, "all chunk_ids must be unique");
|
||||
@@ -668,7 +668,7 @@ mod tests {
|
||||
// chunk_ids stay distinct (the per-chunk hash variant keys off
|
||||
// char_start which is now strictly increasing).
|
||||
let mut ids: Vec<&str> = chunks.iter().map(|c| c.chunk_id.0.as_str()).collect();
|
||||
ids.sort();
|
||||
ids.sort_unstable();
|
||||
let total = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), total, "chunk_ids must remain unique");
|
||||
|
||||
@@ -280,9 +280,7 @@ fn k8s_oversize_splits_into_line_windows_sharing_symbol() {
|
||||
assert_eq!(
|
||||
prev_end + 1,
|
||||
next_start,
|
||||
"line ranges must be contiguous: {} → {} (got gap or overlap)",
|
||||
prev_end,
|
||||
next_start
|
||||
"line ranges must be contiguous: {prev_end} → {next_start} (got gap or overlap)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ use kebab_core::{
|
||||
AssetId, AssetStorage, Checksum, ChunkPolicy, ChunkerVersion, Chunker, MediaType,
|
||||
ParserVersion, RawAsset, SourceUri, WorkspacePath,
|
||||
};
|
||||
use kebab_normalize::build_canonical_document;
|
||||
use kebab_parse_md::{BodyHints, parse_blocks, parse_frontmatter};
|
||||
use kebab_parse_md::{BodyHints, build_canonical_document, parse_blocks, parse_frontmatter};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ fn manifest_doc(lang: &str, manifest_text: &str) -> CanonicalDocument {
|
||||
doc_id,
|
||||
source_asset_id: aid,
|
||||
workspace_path: wp,
|
||||
title: format!("Manifest ({})", lang),
|
||||
title: format!("Manifest ({lang})"),
|
||||
lang: Lang("und".into()),
|
||||
blocks: vec![block],
|
||||
metadata: Metadata {
|
||||
|
||||
@@ -50,3 +50,6 @@ tempfile = { workspace = true }
|
||||
# to simulate stale docs. `time` is the formatter used by the helper.
|
||||
rusqlite = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -250,6 +250,18 @@ enum Cmd {
|
||||
/// `answer.v1`. Off by default to preserve final-only behavior.
|
||||
#[arg(long)]
|
||||
stream: bool,
|
||||
|
||||
/// p9-fb-41: route this ask through the multi-hop pipeline
|
||||
/// — the query is decomposed into sub-questions, each
|
||||
/// retrieved independently, then synthesized over the
|
||||
/// merged chunk pool. Cost trade-off: 2–5× LLM calls
|
||||
/// (decompose + 0..N decide + synthesize) vs. single-pass.
|
||||
/// Worth it for compound questions (X 와 Y 의 관계, prereq
|
||||
/// chain, cross-doc reasoning); single-pass is faster for
|
||||
/// simple fact lookups. The full per-hop trace is exposed
|
||||
/// on `Answer.hops` in `--json` mode.
|
||||
#[arg(long)]
|
||||
multi_hop: bool,
|
||||
},
|
||||
|
||||
/// Wipe XDG data dirs (and optionally the Lance vector store) so the
|
||||
@@ -785,7 +797,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
serde_json::to_string(&item.query)?,
|
||||
)?;
|
||||
if let Some(err) = &item.error {
|
||||
writeln!(stdout, "error: {}", err)?;
|
||||
writeln!(stdout, "error: {err}")?;
|
||||
} else if let Some(resp) = &item.response {
|
||||
writeln!(
|
||||
stdout,
|
||||
@@ -973,6 +985,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
hide_citations,
|
||||
session,
|
||||
stream,
|
||||
multi_hop,
|
||||
} => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
if *stream {
|
||||
@@ -999,6 +1012,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
multi_hop: *multi_hop,
|
||||
};
|
||||
let cfg2 = cfg.clone();
|
||||
let q = query.clone();
|
||||
@@ -1074,6 +1088,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
multi_hop: *multi_hop,
|
||||
};
|
||||
let ans = match session.as_deref() {
|
||||
Some(sid) => kebab_app::ask_with_session_with_config(cfg, sid, query, opts)?,
|
||||
@@ -1156,15 +1171,13 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
let report = kebab_app::reset::execute(scope, &cfg)?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string(&wire::wire_reset(&report))?);
|
||||
} else {
|
||||
if report.orphans_purged > 0 {
|
||||
println!("orphans purged: {}", report.orphans_purged);
|
||||
for p in &report.purged_paths {
|
||||
println!(" - {}", p.0);
|
||||
}
|
||||
} else {
|
||||
println!("no orphaned docs found — store is already in sync with walker scope");
|
||||
} else if report.orphans_purged > 0 {
|
||||
println!("orphans purged: {}", report.orphans_purged);
|
||||
for p in &report.purged_paths {
|
||||
println!(" - {}", p.0);
|
||||
}
|
||||
} else {
|
||||
println!("no orphaned docs found — store is already in sync with walker scope");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1493,11 +1506,11 @@ fn confirm_destructive(
|
||||
) -> anyhow::Result<bool> {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::stderr().lock();
|
||||
writeln!(out, "kebab reset ({:?}): about to remove", scope)?;
|
||||
writeln!(out, "kebab reset ({scope:?}): about to remove")?;
|
||||
for p in paths {
|
||||
writeln!(out, " - {}", p.display())?;
|
||||
}
|
||||
writeln!(out, "estimated total: {} bytes", bytes)?;
|
||||
writeln!(out, "estimated total: {bytes} bytes")?;
|
||||
write!(out, "Proceed? [y/N] ")?;
|
||||
out.flush()?;
|
||||
|
||||
@@ -1558,19 +1571,19 @@ fn render_fetch_plain(r: &kebab_core::FetchResult) {
|
||||
if !r.context_before.is_empty() {
|
||||
println!("\n=== before ===");
|
||||
for c in &r.context_before {
|
||||
let heading = c.heading_path.last().map(|s| s.as_str()).unwrap_or("");
|
||||
let heading = c.heading_path.last().map_or("", std::string::String::as_str);
|
||||
println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text);
|
||||
}
|
||||
}
|
||||
if let Some(c) = &r.chunk {
|
||||
println!("\n=== target ===");
|
||||
let heading = c.heading_path.last().map(|s| s.as_str()).unwrap_or("");
|
||||
let heading = c.heading_path.last().map_or("", std::string::String::as_str);
|
||||
println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text);
|
||||
}
|
||||
if !r.context_after.is_empty() {
|
||||
println!("\n=== after ===");
|
||||
for c in &r.context_after {
|
||||
let heading = c.heading_path.last().map(|s| s.as_str()).unwrap_or("");
|
||||
let heading = c.heading_path.last().map_or("", std::string::String::as_str);
|
||||
println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text);
|
||||
}
|
||||
}
|
||||
@@ -1637,6 +1650,8 @@ mod tests {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
hops: None,
|
||||
verification: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ mod tests {
|
||||
v.get("next_cursor").and_then(|c| c.as_str()),
|
||||
Some("opaque-cursor-abc")
|
||||
);
|
||||
assert_eq!(v.get("truncated").and_then(|t| t.as_bool()), Some(true));
|
||||
assert_eq!(v.get("truncated").and_then(serde_json::Value::as_bool), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -88,5 +88,5 @@ max_context_tokens = 8000
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
assert_eq!(v.get("new").and_then(serde_json::Value::as_u64), Some(1));
|
||||
}
|
||||
|
||||
@@ -96,5 +96,5 @@ max_context_tokens = 8000
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
assert_eq!(v.get("new").and_then(serde_json::Value::as_u64), Some(1));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ fn cli_mcp_initialize_then_tools_list() {
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let init: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(
|
||||
init.get("id").and_then(|i| i.as_i64()),
|
||||
init.get("id").and_then(serde_json::Value::as_i64),
|
||||
Some(1),
|
||||
"unexpected id in initialize response: {init}"
|
||||
);
|
||||
@@ -57,7 +57,7 @@ fn cli_mcp_initialize_then_tools_list() {
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let list: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(
|
||||
list.get("id").and_then(|i| i.as_i64()),
|
||||
list.get("id").and_then(serde_json::Value::as_i64),
|
||||
Some(2),
|
||||
"unexpected id in tools/list response: {list}"
|
||||
);
|
||||
|
||||
@@ -76,8 +76,7 @@ fn cli_schema_json_emits_schema_v1() {
|
||||
assert!(
|
||||
v.get("kebab_version")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false),
|
||||
.is_some_and(|s| !s.is_empty()),
|
||||
"kebab_version must be a non-empty string"
|
||||
);
|
||||
|
||||
@@ -86,12 +85,12 @@ fn cli_schema_json_emits_schema_v1() {
|
||||
.and_then(|c| c.as_object())
|
||||
.expect("capabilities must be a JSON object");
|
||||
assert_eq!(
|
||||
caps.get("json_mode").and_then(|b| b.as_bool()),
|
||||
caps.get("json_mode").and_then(serde_json::Value::as_bool),
|
||||
Some(true),
|
||||
"capabilities.json_mode must be true"
|
||||
);
|
||||
assert_eq!(
|
||||
caps.get("mcp_server").and_then(|b| b.as_bool()),
|
||||
caps.get("mcp_server").and_then(serde_json::Value::as_bool),
|
||||
Some(true),
|
||||
"capabilities.mcp_server must be true (fb-30)"
|
||||
);
|
||||
|
||||
@@ -155,8 +155,8 @@ fn ingest_json_progress_lines_carry_kind_and_ts() {
|
||||
saw_completed = true;
|
||||
// Counts mirror the report.
|
||||
let counts = v.get("counts").unwrap();
|
||||
assert_eq!(counts.get("scanned").and_then(|n| n.as_u64()), Some(2));
|
||||
assert_eq!(counts.get("new").and_then(|n| n.as_u64()), Some(2));
|
||||
assert_eq!(counts.get("scanned").and_then(serde_json::Value::as_u64), Some(2));
|
||||
assert_eq!(counts.get("new").and_then(serde_json::Value::as_u64), Some(2));
|
||||
}
|
||||
}
|
||||
assert!(saw_scan_started, "missing scan_started event");
|
||||
|
||||
254
crates/kebab-cli/tests/wire_ask_multi_hop.rs
Normal file
254
crates/kebab-cli/tests/wire_ask_multi_hop.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
//! p9-fb-41 PR-4: CLI `--multi-hop` flag wiring + answer.v1 / error.v1
|
||||
//! schema additivity.
|
||||
//!
|
||||
//! Four Ollama-free pins:
|
||||
//!
|
||||
//! 1. `--multi-hop` is exposed on `kebab ask --help` so users can
|
||||
//! discover the flag at the CLI surface (clap-level smoke).
|
||||
//! 2. `answer.schema.json` parses as valid JSON and declares a
|
||||
//! `hops` property with a `HopRecord` `$defs` entry — guards
|
||||
//! against accidental schema deletion / typo in future edits.
|
||||
//! 3. `answer.schema.json`'s `refusal_reason` enum lists
|
||||
//! `multi_hop_decompose_failed` — agents validating against
|
||||
//! the schema accept the new variant on refusal answers.
|
||||
//! 4. `error.schema.json`'s `code` enum lists
|
||||
//! `multi_hop_decompose_failed` — forward-looking enum extension
|
||||
//! documented in PR-4.
|
||||
//!
|
||||
//! End-to-end multi-hop ask against a live Ollama lands in a
|
||||
//! follow-up `#[ignore]` test (same pattern as `wire_ask_stale.rs`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn schema_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("docs")
|
||||
.join("wire-schema")
|
||||
.join("v1")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
fn parse_schema(name: &str) -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(schema_path(name))
|
||||
.unwrap_or_else(|e| panic!("read {name}: {e}"));
|
||||
serde_json::from_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{name} must parse as valid JSON: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_ask_help_advertises_multi_hop_flag() {
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let out = Command::new(bin).args(["ask", "--help"]).output().unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("--multi-hop"),
|
||||
"`kebab ask --help` must advertise --multi-hop so users can discover it:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_schema_declares_hops_property_with_hop_record_defs() {
|
||||
let schema = parse_schema("answer.schema.json");
|
||||
assert!(
|
||||
schema["properties"]["hops"].is_object(),
|
||||
"`hops` property must be declared on answer.v1"
|
||||
);
|
||||
// `hops` allows array-or-null (single-pass omits the field;
|
||||
// multi-hop emits a non-empty array).
|
||||
let hops_any_of = schema["properties"]["hops"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("hops must declare anyOf (array | null)");
|
||||
assert!(
|
||||
hops_any_of.iter().any(|v| v["type"] == "array"),
|
||||
"hops anyOf must include array shape"
|
||||
);
|
||||
assert!(
|
||||
hops_any_of.iter().any(|v| v["type"] == "null"),
|
||||
"hops anyOf must include null (single-pass omits the field)"
|
||||
);
|
||||
|
||||
// HopRecord $defs entry — guards against accidental deletion or
|
||||
// structural drift in future schema edits.
|
||||
let hop_record = &schema["$defs"]["HopRecord"];
|
||||
assert!(
|
||||
hop_record.is_object(),
|
||||
"$defs.HopRecord must be declared so `hops.items` can $ref it"
|
||||
);
|
||||
let kind_enum = hop_record["properties"]["kind"]["enum"]
|
||||
.as_array()
|
||||
.expect("HopRecord.kind must be an enum");
|
||||
let kinds: Vec<&str> = kind_enum.iter().filter_map(|v| v.as_str()).collect();
|
||||
for needed in ["decompose", "decide", "synthesize"] {
|
||||
assert!(
|
||||
kinds.contains(&needed),
|
||||
"HopRecord.kind enum must include {needed:?}, got {kinds:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_schema_refusal_reason_enum_includes_multi_hop_decompose_failed() {
|
||||
let schema = parse_schema("answer.schema.json");
|
||||
let refusal_any_of = schema["properties"]["refusal_reason"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("refusal_reason must declare anyOf");
|
||||
let enum_arr = refusal_any_of
|
||||
.iter()
|
||||
.find_map(|v| v["enum"].as_array())
|
||||
.expect("one of refusal_reason.anyOf entries must declare an enum");
|
||||
let values: Vec<&str> = enum_arr.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
values.contains(&"multi_hop_decompose_failed"),
|
||||
"refusal_reason enum must include `multi_hop_decompose_failed`, got {values:?}"
|
||||
);
|
||||
// All earlier RefusalReason wire values remain on the enum —
|
||||
// guards against an accidental rewrite dropping old variants.
|
||||
for needed in [
|
||||
"score_gate",
|
||||
"llm_self_judge",
|
||||
"no_index",
|
||||
"no_chunks",
|
||||
"llm_stream_aborted",
|
||||
] {
|
||||
assert!(
|
||||
values.contains(&needed),
|
||||
"refusal_reason enum must keep prior variant {needed:?}, got {values:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_schema_code_enum_includes_multi_hop_decompose_failed() {
|
||||
let schema = parse_schema("error.schema.json");
|
||||
let code_enum = schema["properties"]["code"]["enum"]
|
||||
.as_array()
|
||||
.expect("error.v1 must declare code.enum");
|
||||
let values: Vec<&str> = code_enum.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
values.contains(&"multi_hop_decompose_failed"),
|
||||
"error.v1 code enum must include forward-looking `multi_hop_decompose_failed`, got {values:?}"
|
||||
);
|
||||
// Existing codes remain — guards against accidental deletion.
|
||||
for needed in [
|
||||
"config_invalid",
|
||||
"not_indexed",
|
||||
"model_unreachable",
|
||||
"generic",
|
||||
] {
|
||||
assert!(
|
||||
values.contains(&needed),
|
||||
"error.v1 code enum must keep prior code {needed:?}, got {values:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── p9-fb-41 PR-9c-1: NLI verification surface pins ─────────────────────
|
||||
|
||||
/// answer.v1 must declare a `verification` property AND a
|
||||
/// `$defs.VerificationSummary` entry with all three required fields.
|
||||
/// Guards against accidental schema deletion / typo in future edits.
|
||||
#[test]
|
||||
fn answer_schema_declares_verification_field_and_defs() {
|
||||
let schema = parse_schema("answer.schema.json");
|
||||
assert!(
|
||||
schema["properties"]["verification"].is_object(),
|
||||
"`verification` property must be declared on answer.v1"
|
||||
);
|
||||
// `verification` allows object-or-null (multi-hop with threshold>0
|
||||
// emits an object; everything else omits the field).
|
||||
let v_any_of = schema["properties"]["verification"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("verification must declare anyOf (object | null)");
|
||||
assert!(
|
||||
v_any_of.iter().any(|v| v["type"] == "null"),
|
||||
"verification anyOf must include null (single-pass / disabled gate omits the field)"
|
||||
);
|
||||
assert!(
|
||||
v_any_of
|
||||
.iter()
|
||||
.any(|v| v["$ref"].as_str() == Some("#/$defs/VerificationSummary")),
|
||||
"verification anyOf must $ref VerificationSummary"
|
||||
);
|
||||
|
||||
// VerificationSummary $defs entry + required fields.
|
||||
let vs = &schema["$defs"]["VerificationSummary"];
|
||||
assert!(
|
||||
vs.is_object(),
|
||||
"$defs.VerificationSummary must be declared so verification.anyOf can $ref it"
|
||||
);
|
||||
let required: Vec<&str> = vs["required"]
|
||||
.as_array()
|
||||
.expect("VerificationSummary.required must be an array")
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
for needed in ["nli_score", "nli_threshold", "nli_passed"] {
|
||||
assert!(
|
||||
required.contains(&needed),
|
||||
"VerificationSummary.required must include {needed:?}, got {required:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_schema_refusal_reason_enum_includes_nli_verification_failed() {
|
||||
let schema = parse_schema("answer.schema.json");
|
||||
let refusal_any_of = schema["properties"]["refusal_reason"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("refusal_reason must declare anyOf");
|
||||
let enum_arr = refusal_any_of
|
||||
.iter()
|
||||
.find_map(|v| v["enum"].as_array())
|
||||
.expect("one of refusal_reason.anyOf entries must declare an enum");
|
||||
let values: Vec<&str> = enum_arr.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
values.contains(&"nli_verification_failed"),
|
||||
"refusal_reason enum must include `nli_verification_failed`, got {values:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_schema_refusal_reason_enum_includes_nli_model_unavailable() {
|
||||
let schema = parse_schema("answer.schema.json");
|
||||
let refusal_any_of = schema["properties"]["refusal_reason"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("refusal_reason must declare anyOf");
|
||||
let enum_arr = refusal_any_of
|
||||
.iter()
|
||||
.find_map(|v| v["enum"].as_array())
|
||||
.expect("one of refusal_reason.anyOf entries must declare an enum");
|
||||
let values: Vec<&str> = enum_arr.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
values.contains(&"nli_model_unavailable"),
|
||||
"refusal_reason enum must include `nli_model_unavailable`, got {values:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_schema_code_enum_includes_nli_verification_failed() {
|
||||
let schema = parse_schema("error.schema.json");
|
||||
let code_enum = schema["properties"]["code"]["enum"]
|
||||
.as_array()
|
||||
.expect("error.v1 must declare code.enum");
|
||||
let values: Vec<&str> = code_enum.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
values.contains(&"nli_verification_failed"),
|
||||
"error.v1 code enum must include forward-looking `nli_verification_failed`, got {values:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_schema_code_enum_includes_nli_model_unavailable() {
|
||||
let schema = parse_schema("error.schema.json");
|
||||
let code_enum = schema["properties"]["code"]["enum"]
|
||||
.as_array()
|
||||
.expect("error.v1 must declare code.enum");
|
||||
let values: Vec<&str> = code_enum.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
values.contains(&"nli_model_unavailable"),
|
||||
"error.v1 code enum must include forward-looking `nli_model_unavailable`, got {values:?}"
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,6 @@ tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -103,6 +103,34 @@ pub struct ChunkingCfg {
|
||||
pub struct ModelsCfg {
|
||||
pub embedding: EmbeddingModelCfg,
|
||||
pub llm: LlmCfg,
|
||||
/// p9-fb-41 PR-9c-1: NLI verifier model + provider knob.
|
||||
/// `#[serde(default)]` so pre-v0.18 config files that predate the
|
||||
/// `[models.nli]` section still load with built-in defaults
|
||||
/// (`Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7` / `onnx`).
|
||||
/// The verifier itself is gated by `[rag].nli_threshold` — even
|
||||
/// with a model configured here, threshold `0.0` (the default)
|
||||
/// skips the verification step entirely.
|
||||
#[serde(default = "NliCfg::defaults")]
|
||||
pub nli: NliCfg,
|
||||
}
|
||||
|
||||
/// p9-fb-41 PR-9c-1: NLI verifier configuration. The model id flows to
|
||||
/// `OnnxNliVerifier::new` via `kebab-nli` (PR-9c-2 wiring); the provider
|
||||
/// is reserved for future verifier swap-in (currently only `"onnx"` is
|
||||
/// recognized — anything else falls back to the same path).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NliCfg {
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
}
|
||||
|
||||
impl NliCfg {
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
model: "Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7".to_string(),
|
||||
provider: "onnx".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -181,6 +209,73 @@ pub struct RagCfg {
|
||||
pub score_gate: f32,
|
||||
pub explain_default: bool,
|
||||
pub max_context_tokens: usize,
|
||||
/// p9-fb-41: hard ceiling on the number of multi-hop iterations
|
||||
/// (decompose iter + decide iters). When the LLM keeps returning
|
||||
/// `continue` past this depth the pipeline cuts to `synthesize`
|
||||
/// with `HopRecord.forced_stop = true`. Default `3` — enough for
|
||||
/// most cross-doc reasoning, low enough to bound LLM cost.
|
||||
#[serde(default = "default_multi_hop_max_depth")]
|
||||
pub multi_hop_max_depth: u32,
|
||||
/// p9-fb-41: cap on how many sub-queries the LLM may emit in a
|
||||
/// single decompose / decide call. This is the *prompt-side
|
||||
/// soft hint* — the value the pipeline injects into the
|
||||
/// decompose / decide prompts so the LLM knows what to aim for.
|
||||
/// kebab-rag enforces a separate compile-time hard ceiling
|
||||
/// (`MULTI_HOP_MAX_SUB_QUERIES_HARD_CAP`, currently 10) as a
|
||||
/// safety net against misbehaving models — if you raise this
|
||||
/// knob above the hard cap, bump the const in the same PR.
|
||||
/// Default `5`.
|
||||
#[serde(default = "default_multi_hop_max_sub_queries_per_iter")]
|
||||
pub multi_hop_max_sub_queries_per_iter: u32,
|
||||
/// p9-fb-41: hard ceiling on the deduped chunk pool. When the
|
||||
/// accumulated pool would exceed this many chunks the pipeline
|
||||
/// stops accepting new retrieval results and forces synthesize
|
||||
/// with `forced_stop = true`.
|
||||
///
|
||||
/// Default `15` — tuned down from the original 30 in the v0.18
|
||||
/// pre-cut dogfood (`tasks/HOTFIXES.md` 2026-05-25 fb-41 entry,
|
||||
/// "post-PR-7 dogfood retest + PR-8 partial mitigation" sub-section).
|
||||
/// With 30 chunks the synthesize prompt was large enough for
|
||||
/// gemma3:4b to lose the citation rule + drift into unrelated
|
||||
/// chunks; 15 keeps the prompt tight while still allowing 3-iter
|
||||
/// cross-doc reasoning over ~5 chunks per iter.
|
||||
#[serde(default = "default_multi_hop_max_pool_chunks")]
|
||||
pub multi_hop_max_pool_chunks: u32,
|
||||
/// p9-fb-41 PR-9c-1: minimum NLI entailment score required for the
|
||||
/// multi-hop synthesize answer to be returned as `grounded=true`
|
||||
/// (spec §2.6 single gate). When the post-synthesize NLI verifier
|
||||
/// returns `NliScores::faithfulness() < nli_threshold` the
|
||||
/// pipeline refuses with `RefusalReason::NliVerificationFailed`.
|
||||
///
|
||||
/// Default `0.0` = verification disabled — no NLI call, multi-hop
|
||||
/// matches its PR-3b behavior exactly. Set to e.g. `0.5` to
|
||||
/// activate the gate. Knob lives on `[rag]` (the gate is a RAG
|
||||
/// policy, not a model property); the model itself comes from
|
||||
/// `[models.nli].model`.
|
||||
///
|
||||
/// Single-pass `ask` ignores this knob entirely — only multi-hop
|
||||
/// runs through the verification step (PR-9c-2 wires it).
|
||||
#[serde(default = "default_nli_threshold")]
|
||||
pub nli_threshold: f32,
|
||||
}
|
||||
|
||||
fn default_multi_hop_max_depth() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_multi_hop_max_sub_queries_per_iter() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_multi_hop_max_pool_chunks() -> u32 {
|
||||
15
|
||||
}
|
||||
|
||||
/// p9-fb-41 PR-9c-1: NLI gate disabled by default per spec §2.6
|
||||
/// (verification opt-in — users explicitly raise the threshold once
|
||||
/// they're ready to trade refusal-rate for groundedness).
|
||||
fn default_nli_threshold() -> f32 {
|
||||
0.0
|
||||
}
|
||||
|
||||
/// Settings for the image ingest pipeline (P6). `ocr` controls OCR
|
||||
@@ -420,6 +515,7 @@ impl Config {
|
||||
seed: 0,
|
||||
request_timeout_secs: default_llm_request_timeout_secs(),
|
||||
},
|
||||
nli: NliCfg::defaults(),
|
||||
},
|
||||
search: SearchCfg {
|
||||
default_k: 10,
|
||||
@@ -434,6 +530,11 @@ impl Config {
|
||||
score_gate: 0.30,
|
||||
explain_default: false,
|
||||
max_context_tokens: 8000,
|
||||
multi_hop_max_depth: default_multi_hop_max_depth(),
|
||||
multi_hop_max_sub_queries_per_iter:
|
||||
default_multi_hop_max_sub_queries_per_iter(),
|
||||
multi_hop_max_pool_chunks: default_multi_hop_max_pool_chunks(),
|
||||
nli_threshold: default_nli_threshold(),
|
||||
},
|
||||
image: ImageCfg::defaults(),
|
||||
ui: UiCfg::defaults(),
|
||||
@@ -677,6 +778,10 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// models.nli (p9-fb-41 PR-9c-1)
|
||||
"KEBAB_MODELS_NLI_MODEL" => self.models.nli.model = v.clone(),
|
||||
"KEBAB_MODELS_NLI_PROVIDER" => self.models.nli.provider = v.clone(),
|
||||
|
||||
// search
|
||||
"KEBAB_SEARCH_DEFAULT_K" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
@@ -717,6 +822,39 @@ impl Config {
|
||||
self.rag.max_context_tokens = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_RAG_MULTI_HOP_MAX_DEPTH" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.rag.multi_hop_max_depth = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_RAG_MULTI_HOP_MAX_SUB_QUERIES_PER_ITER" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.rag.multi_hop_max_sub_queries_per_iter = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_RAG_MULTI_HOP_MAX_POOL_CHUNKS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.rag.multi_hop_max_pool_chunks = n;
|
||||
}
|
||||
}
|
||||
// p9-fb-41 PR-9c-1: NLI gate threshold. Parse failure
|
||||
// emits a `tracing::warn!` (not silent like the other
|
||||
// numeric env overrides) because this knob gates the
|
||||
// NLI verification entirely — a malformed env value
|
||||
// would silently disable a security-flavored gate the
|
||||
// user thought they enabled, which is the failure mode
|
||||
// most worth surfacing. The default (`0.0`) survives
|
||||
// on parse failure so behaviour stays well-defined.
|
||||
"KEBAB_RAG_NLI_THRESHOLD" => match v.parse::<f32>() {
|
||||
Ok(f) => self.rag.nli_threshold = f,
|
||||
Err(e) => tracing::warn!(
|
||||
target: "kebab-config",
|
||||
env_key = "KEBAB_RAG_NLI_THRESHOLD",
|
||||
env_value = %v,
|
||||
error = %e,
|
||||
"invalid KEBAB_RAG_NLI_THRESHOLD; keeping prior value (0.0 = NLI gate disabled)"
|
||||
),
|
||||
},
|
||||
|
||||
// image.ocr
|
||||
"KEBAB_IMAGE_OCR_ENABLED" => {
|
||||
@@ -1092,6 +1230,143 @@ theme = "dark"
|
||||
assert_eq!(c.image.ocr.request_timeout_secs, 300);
|
||||
}
|
||||
|
||||
// ── p9-fb-41: multi-hop RAG knobs ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_multi_hop_max_depth_is_3() {
|
||||
assert_eq!(Config::defaults().rag.multi_hop_max_depth, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_multi_hop_max_sub_queries_per_iter_is_5() {
|
||||
assert_eq!(
|
||||
Config::defaults().rag.multi_hop_max_sub_queries_per_iter,
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_multi_hop_max_pool_chunks_is_15() {
|
||||
// v0.18 dogfood (HOTFIXES 2026-05-25 fb-41 post-PR-7) tuned
|
||||
// this down from 30 → 15 to keep the synthesize prompt tight
|
||||
// enough for gemma3:4b to follow the citation rule.
|
||||
assert_eq!(Config::defaults().rag.multi_hop_max_pool_chunks, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_overrides_multi_hop_knobs() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"KEBAB_RAG_MULTI_HOP_MAX_DEPTH".to_string(),
|
||||
"5".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"KEBAB_RAG_MULTI_HOP_MAX_SUB_QUERIES_PER_ITER".to_string(),
|
||||
"7".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"KEBAB_RAG_MULTI_HOP_MAX_POOL_CHUNKS".to_string(),
|
||||
"50".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.rag.multi_hop_max_depth, 5);
|
||||
assert_eq!(c.rag.multi_hop_max_sub_queries_per_iter, 7);
|
||||
assert_eq!(c.rag.multi_hop_max_pool_chunks, 50);
|
||||
}
|
||||
|
||||
/// post-PR-3 fb-41: a config file written before the multi-hop
|
||||
/// knobs existed must still parse and fall back to the documented
|
||||
/// defaults — backwards-compat invariant. Fixture shared with the
|
||||
/// LLM / OCR timeout invariants via [`LEGACY_PRE_TIMEOUT_TOML`]
|
||||
/// (that fixture also predates the multi_hop_* fields).
|
||||
#[test]
|
||||
fn legacy_config_without_multi_hop_knobs_uses_defaults() {
|
||||
let c: Config = toml::from_str(LEGACY_PRE_TIMEOUT_TOML)
|
||||
.expect("parse legacy config");
|
||||
assert_eq!(c.rag.multi_hop_max_depth, 3);
|
||||
assert_eq!(c.rag.multi_hop_max_sub_queries_per_iter, 5);
|
||||
// v0.18 dogfood (post-PR-7): pool default 30 → 15.
|
||||
assert_eq!(c.rag.multi_hop_max_pool_chunks, 15);
|
||||
}
|
||||
|
||||
// ── p9-fb-41 PR-9c-1: NLI verification knobs ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_nli_threshold_is_zero() {
|
||||
// Spec §2.6: NLI gate disabled by default — verification is
|
||||
// opt-in. `0.0` keeps multi-hop behavior identical to PR-3b.
|
||||
assert_eq!(Config::defaults().rag.nli_threshold, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_nli_model_is_xenova_mdeberta() {
|
||||
// Pin the default model id so a refactor that touches NliCfg
|
||||
// can't silently flip to a different verifier model.
|
||||
assert_eq!(
|
||||
Config::defaults().models.nli.model,
|
||||
"Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
|
||||
);
|
||||
assert_eq!(Config::defaults().models.nli.provider, "onnx");
|
||||
}
|
||||
|
||||
/// A config file written before the `[models.nli]` / `nli_threshold`
|
||||
/// keys existed must still parse and fall back to the documented
|
||||
/// defaults. Fixture shared via [`LEGACY_PRE_TIMEOUT_TOML`] (predates
|
||||
/// all PR-9c-1 fields).
|
||||
#[test]
|
||||
fn legacy_config_without_nli_uses_defaults() {
|
||||
let c: Config = toml::from_str(LEGACY_PRE_TIMEOUT_TOML)
|
||||
.expect("parse legacy config");
|
||||
assert_eq!(c.rag.nli_threshold, 0.0);
|
||||
assert_eq!(
|
||||
c.models.nli.model,
|
||||
"Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
|
||||
);
|
||||
assert_eq!(c.models.nli.provider, "onnx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_nli_threshold() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_RAG_NLI_THRESHOLD".to_string(), "0.5".to_string());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!((c.rag.nli_threshold - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_nli_model_and_provider() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"KEBAB_MODELS_NLI_MODEL".to_string(),
|
||||
"user/custom-nli-model".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"KEBAB_MODELS_NLI_PROVIDER".to_string(),
|
||||
"candle".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.models.nli.model, "user/custom-nli-model");
|
||||
assert_eq!(c.models.nli.provider, "candle");
|
||||
}
|
||||
|
||||
/// Malformed `KEBAB_RAG_NLI_THRESHOLD` keeps the prior value (does
|
||||
/// NOT silently disable nor crash). The `tracing::warn!` surface
|
||||
/// is observable only when the user has tracing wired; the
|
||||
/// behavior contract is "default survives".
|
||||
#[test]
|
||||
fn env_malformed_nli_threshold_keeps_prior_value() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"KEBAB_RAG_NLI_THRESHOLD".to_string(),
|
||||
"not-a-float".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(
|
||||
c.rag.nli_threshold, 0.0,
|
||||
"malformed env value must keep the default unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ocr_env_overrides() {
|
||||
let mut env = HashMap::new();
|
||||
|
||||
@@ -157,7 +157,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn xdg_data_home_set_replaces_var() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = XdgGuard::capture();
|
||||
// SAFETY: lock held for the duration of this test.
|
||||
unsafe { std::env::set_var("XDG_DATA_HOME", "/custom/path") };
|
||||
@@ -168,7 +168,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn xdg_data_home_unset_uses_default() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = XdgGuard::capture();
|
||||
// SAFETY: lock held for the duration of this test.
|
||||
unsafe { std::env::remove_var("XDG_DATA_HOME") };
|
||||
@@ -181,7 +181,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn xdg_with_no_default_resolves_to_empty_when_unset() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = XdgGuard::capture();
|
||||
// SAFETY: lock held for the duration of this test.
|
||||
unsafe { std::env::remove_var("XDG_DATA_HOME") };
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn leading_tilde_expands_to_home() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let home = std::env::var("HOME").expect("HOME must be set in tests");
|
||||
let p = expand_path("~/runs", "");
|
||||
assert_eq!(p, PathBuf::from(home).join("runs"));
|
||||
@@ -229,7 +229,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tilde_path_ignores_base_dir() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let home = std::env::var("HOME").expect("HOME must be set in tests");
|
||||
let base = Path::new("/tmp/ignored-cfg");
|
||||
let p = expand_path_with_base("~/x", "", base);
|
||||
@@ -238,7 +238,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn xdg_var_path_ignores_base_dir() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = XdgGuard::capture();
|
||||
// SAFETY: lock held for the duration of this test.
|
||||
unsafe { std::env::set_var("XDG_DATA_HOME", "/xdg/data") };
|
||||
@@ -255,7 +255,7 @@ mod tests {
|
||||
// Order matters: substitute `{data_dir}` (which itself contains
|
||||
// an unexpanded `${XDG_DATA_HOME}` and `~`), then the other two
|
||||
// resolve the result.
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = XdgGuard::capture();
|
||||
// SAFETY: lock held for the duration of this test.
|
||||
unsafe { std::env::set_var("XDG_DATA_HOME", "/xdg/data") };
|
||||
|
||||
@@ -16,3 +16,6 @@ time = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
serde_json_canonicalizer = "0.3"
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -29,6 +29,37 @@ pub struct Answer {
|
||||
/// 이면 single-shot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_index: Option<u32>,
|
||||
/// p9-fb-41: multi-hop hop trace. `None` for single-pass asks.
|
||||
/// Each entry records one hop (`decompose` / `decide` / `synthesize`)
|
||||
/// — the LLM call category, the sub-queries emitted, retrieval
|
||||
/// counts, and a `forced_stop` flag for cap-driven termination.
|
||||
/// Wire-additive: `answer.v1` schema_version unchanged; consumers
|
||||
/// reading older single-pass answers see `hops: None` (or absent).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hops: Option<Vec<HopRecord>>,
|
||||
/// p9-fb-41 PR-9c-1: NLI-based post-synthesis verification summary.
|
||||
/// `None` for single-pass asks and for multi-hop runs with
|
||||
/// `[rag].nli_threshold == 0` (verification disabled — the default).
|
||||
/// Present only when the multi-hop pipeline reached the post-
|
||||
/// synthesize verification step (PR-9c-2 wires step 8.5). Wire-
|
||||
/// additive: `answer.v1` schema_version unchanged; consumers
|
||||
/// reading pre-v0.18 answers see `verification: None` (or absent).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verification: Option<VerificationSummary>,
|
||||
}
|
||||
|
||||
/// p9-fb-41 PR-9c-1: post-synthesize NLI verification summary stamped
|
||||
/// onto [`Answer::verification`] when multi-hop runs reach step 8.5
|
||||
/// (NLI gate). Three required fields ride together on every wire emit:
|
||||
/// `nli_score` is the entailment channel of the XNLI verifier,
|
||||
/// `nli_threshold` mirrors `[rag].nli_threshold` for audit, and
|
||||
/// `nli_passed` is `nli_score >= nli_threshold`. The whole struct is
|
||||
/// omitted (serde skip) when no verification ran.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VerificationSummary {
|
||||
pub nli_score: f32,
|
||||
pub nli_threshold: f32,
|
||||
pub nli_passed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -55,6 +86,79 @@ pub struct Turn {
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// p9-fb-41: one entry in [`Answer::hops`] — the per-iteration trace
|
||||
/// of a multi-hop ask. The pipeline appends a `HopRecord` per LLM
|
||||
/// call (decompose / decide / synthesize) so a `--multi-hop` user
|
||||
/// can see what sub-queries the LLM emitted, how many chunks each
|
||||
/// hop contributed, whether the iter stopped on the model's own
|
||||
/// signal or hit a cap, and the per-hop LLM latency.
|
||||
///
|
||||
/// Wire-additive — every field uses `#[serde(default)]` where it
|
||||
/// could plausibly be omitted by a future schema reader.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HopRecord {
|
||||
/// 0-based hop index within this ask. `iter=0` is always the
|
||||
/// initial decompose call; subsequent iters are decide calls;
|
||||
/// the final iter is the synthesize call.
|
||||
pub iter: u32,
|
||||
pub kind: HopKind,
|
||||
/// Sub-queries associated with this hop. The meaning depends on
|
||||
/// `kind`:
|
||||
///
|
||||
/// - [`HopKind::Decompose`]: the initial sub-queries the LLM
|
||||
/// broke the original user query into. These drive the
|
||||
/// `iter=1` retrieval round.
|
||||
/// - [`HopKind::Decide`]: the *new* sub-queries the LLM
|
||||
/// emitted to drive the next retrieval round. Empty when the
|
||||
/// LLM signalled stop OR when `forced_stop = true` (cap hit
|
||||
/// or parse-degraded).
|
||||
/// - [`HopKind::Synthesize`]: always empty — the final hop
|
||||
/// produces the user-visible answer, not more sub-queries.
|
||||
#[serde(default)]
|
||||
pub sub_queries: Vec<String>,
|
||||
/// Number of *new* chunks the retrieval round contributed to the
|
||||
/// pool (dedup'd by `chunk_id` — repeated hits from a previous
|
||||
/// iter do not count). `0` for the decompose hop (no retrieval
|
||||
/// yet) and the synthesize hop.
|
||||
pub context_chunks_added: u32,
|
||||
/// `true` when the pipeline cut the iter loop short because a
|
||||
/// safety cap fired (`max_depth` / `max_total_sub_queries` /
|
||||
/// `max_pool_chunks`) rather than because the LLM signalled
|
||||
/// stop. The user-visible answer still reflects all chunks
|
||||
/// accumulated up to that point — `forced_stop` is a tracing
|
||||
/// signal, not a refusal.
|
||||
pub forced_stop: bool,
|
||||
/// Wall-clock latency of the LLM call for this hop, in
|
||||
/// milliseconds. Useful for cost / latency analysis when a
|
||||
/// `kebab eval` run records `Answer.hops`.
|
||||
///
|
||||
/// `0` is overloaded: it means "no LLM call happened at this
|
||||
/// hop" when (a) the hop was a Decide skipped due to
|
||||
/// `forced_stop` (depth-cap or pool-cap fired before the LLM
|
||||
/// was asked) or (b) the pool was empty before any decide
|
||||
/// could run. Treat `0` as "absent or instantaneous" rather
|
||||
/// than as a genuine measurement.
|
||||
pub llm_call_ms: u32,
|
||||
}
|
||||
|
||||
/// p9-fb-41: which stage of the multi-hop pipeline a [`HopRecord`]
|
||||
/// describes. The serde tag matches the wire shape so agents /
|
||||
/// CLIs can branch on the snake_case string without referencing
|
||||
/// the Rust enum.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HopKind {
|
||||
/// First hop — LLM decomposed the user query into sub-queries.
|
||||
Decompose,
|
||||
/// Subsequent hop — LLM was asked whether more retrieval is
|
||||
/// needed and either emitted new sub-queries (`continue`) or
|
||||
/// returned an empty array (`stop`).
|
||||
Decide,
|
||||
/// Terminal hop — LLM produced the final user-visible answer
|
||||
/// over the accumulated chunk pool.
|
||||
Synthesize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RefusalReason {
|
||||
@@ -66,6 +170,24 @@ pub enum RefusalReason {
|
||||
/// 가 채워져 있을 수 있음 (사용자가 본 부분까지). RAG retrieval
|
||||
/// 자체는 정상 — 모델 generation 단계에서만 중단.
|
||||
LlmStreamAborted,
|
||||
/// p9-fb-41: multi-hop pipeline 의 decompose LLM call 이 JSON
|
||||
/// parse 가능한 sub-question array 를 반환하지 못함 (parse
|
||||
/// error, 빈 응답, 또는 잘못된 형식). retrieval / synthesize
|
||||
/// 단계 진입 못 함. CLI / MCP / TUI 가 받는 wire error code
|
||||
/// = `"multi_hop_decompose_failed"` (PR-4 의 error_wire 매핑).
|
||||
MultiHopDecomposeFailed,
|
||||
/// p9-fb-41 PR-9c-1: post-synthesize NLI verification gate fired —
|
||||
/// `NliScores::faithfulness()` (entailment channel) fell below
|
||||
/// `[rag].nli_threshold`. Wire string = `"nli_verification_failed"`
|
||||
/// (single source of truth: also the matching `error.v1.code`).
|
||||
/// Multi-hop only; behavior wiring lands in PR-9c-2.
|
||||
NliVerificationFailed,
|
||||
/// p9-fb-41 PR-9c-1: NLI verifier was configured (threshold > 0)
|
||||
/// but the model / runtime is unavailable (download failure,
|
||||
/// missing tokenizer, ONNX session init error). Treated as a soft
|
||||
/// refusal — the user sees an unverified-answer outcome rather
|
||||
/// than crashing the ask. Wire string = `"nli_model_unavailable"`.
|
||||
NliModelUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -103,6 +225,81 @@ mod tests {
|
||||
use crate::citation::Citation;
|
||||
use time::macros::datetime;
|
||||
|
||||
/// p9-fb-41 PR-9c-1: pin the wire-side spelling of the new
|
||||
/// `RefusalReason` variants. The strings here must match
|
||||
/// `answer.schema.json::refusal_reason.enum` AND
|
||||
/// `error.schema.json::code.enum` byte-for-byte (single source of
|
||||
/// truth per spec §2.4).
|
||||
#[test]
|
||||
fn refusal_reason_nli_variants_serialize_to_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&RefusalReason::NliVerificationFailed).unwrap(),
|
||||
"\"nli_verification_failed\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&RefusalReason::NliModelUnavailable).unwrap(),
|
||||
"\"nli_model_unavailable\""
|
||||
);
|
||||
}
|
||||
|
||||
/// p9-fb-41 PR-9c-1: `Answer.verification` is `Option<...>` with
|
||||
/// `skip_serializing_if = None`. A `verification: None` answer
|
||||
/// must NOT emit a `"verification"` key on the wire — the field
|
||||
/// is additive and pre-v0.18 readers see no new key.
|
||||
#[test]
|
||||
fn answer_omits_verification_field_when_none() {
|
||||
let ans = Answer {
|
||||
answer: "x".into(),
|
||||
citations: vec![],
|
||||
grounded: true,
|
||||
refusal_reason: None,
|
||||
model: ModelRef {
|
||||
id: "m".into(),
|
||||
provider: "p".into(),
|
||||
dimensions: None,
|
||||
},
|
||||
embedding: None,
|
||||
prompt_template_version: PromptTemplateVersion("rag-v2".into()),
|
||||
retrieval: AnswerRetrievalSummary {
|
||||
trace_id: TraceId("t".into()),
|
||||
mode: crate::SearchMode::Lexical,
|
||||
k: 1,
|
||||
score_gate: 0.0,
|
||||
top_score: 0.0,
|
||||
chunks_returned: 0,
|
||||
chunks_used: 0,
|
||||
},
|
||||
usage: TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
latency_ms: 0,
|
||||
},
|
||||
created_at: datetime!(2026-05-09 12:00:00 UTC),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
hops: None,
|
||||
verification: None,
|
||||
};
|
||||
let v = serde_json::to_value(&ans).unwrap();
|
||||
assert!(
|
||||
v.get("verification").is_none(),
|
||||
"verification: None must be omitted from wire output, got: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_summary_serializes_all_three_required_fields() {
|
||||
let vs = VerificationSummary {
|
||||
nli_score: 0.87,
|
||||
nli_threshold: 0.5,
|
||||
nli_passed: true,
|
||||
};
|
||||
let v = serde_json::to_value(vs).unwrap();
|
||||
assert!((v["nli_score"].as_f64().unwrap() - 0.87).abs() < 1e-5);
|
||||
assert!((v["nli_threshold"].as_f64().unwrap() - 0.5).abs() < 1e-5);
|
||||
assert_eq!(v["nli_passed"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_citation_serializes_indexed_at_and_stale() {
|
||||
let ac = AnswerCitation {
|
||||
|
||||
@@ -226,28 +226,25 @@ fn parse_hms_ms(s: &str) -> Result<u64> {
|
||||
let m: u64 = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad minutes in {:?} (input {s:?})", parts[1]))?;
|
||||
let (sec, ms) = match parts[2].split_once('.') {
|
||||
Some((s_part, ms_part)) => {
|
||||
let sec: u64 = s_part
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad seconds in {s_part:?} (input {s:?})"))?;
|
||||
// Pad/truncate to exactly 3 digits.
|
||||
let mut ms_str = ms_part.to_owned();
|
||||
while ms_str.len() < 3 {
|
||||
ms_str.push('0');
|
||||
}
|
||||
ms_str.truncate(3);
|
||||
let ms: u64 = ms_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad milliseconds in {ms_part:?} (input {s:?})"))?;
|
||||
(sec, ms)
|
||||
}
|
||||
None => {
|
||||
let sec: u64 = parts[2]
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad seconds in {:?} (input {s:?})", parts[2]))?;
|
||||
(sec, 0)
|
||||
let (sec, ms) = if let Some((s_part, ms_part)) = parts[2].split_once('.') {
|
||||
let sec: u64 = s_part
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad seconds in {s_part:?} (input {s:?})"))?;
|
||||
// Pad/truncate to exactly 3 digits.
|
||||
let mut ms_str = ms_part.to_owned();
|
||||
while ms_str.len() < 3 {
|
||||
ms_str.push('0');
|
||||
}
|
||||
ms_str.truncate(3);
|
||||
let ms: u64 = ms_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad milliseconds in {ms_part:?} (input {s:?})"))?;
|
||||
(sec, ms)
|
||||
} else {
|
||||
let sec: u64 = parts[2]
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad seconds in {:?} (input {s:?})", parts[2]))?;
|
||||
(sec, 0)
|
||||
};
|
||||
Ok(h * 3_600_000 + m * 60_000 + sec * 1000 + ms)
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ pub use search::{
|
||||
TraceCandidate, TraceFusionInput, TraceTiming,
|
||||
};
|
||||
pub use answer::{
|
||||
Answer, AnswerCitation, AnswerRetrievalSummary, ModelRef, RefusalReason, TokenUsage,
|
||||
TraceId, Turn,
|
||||
Answer, AnswerCitation, AnswerRetrievalSummary, HopKind, HopRecord, ModelRef,
|
||||
RefusalReason, TokenUsage, TraceId, Turn, VerificationSummary,
|
||||
};
|
||||
pub use ingest::{IngestItem, IngestItemKind, IngestReport, SkipExamples};
|
||||
pub use jobs::{JobFilter, JobId, JobKind, JobRow, JobStatus};
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct Metadata {
|
||||
pub git_commit: Option<String>,
|
||||
|
||||
/// p10-1A-1: programming language identifier (lowercase canonical). null
|
||||
/// for markdown / pdf / image. Set by `kebab_parse_code::lang::code_lang_for_path`.
|
||||
/// for markdown / pdf / image. Set by the local-filesystem source connector during ingest.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub code_lang: Option<String>,
|
||||
}
|
||||
|
||||
@@ -471,7 +471,7 @@ mod tests {
|
||||
doc_path: WorkspacePath("a.md".into()),
|
||||
heading_path: vec![],
|
||||
section_label: None,
|
||||
snippet: "".into(),
|
||||
snippet: String::new(),
|
||||
citation: Citation::Line {
|
||||
path: WorkspacePath("a.md".into()),
|
||||
start: 1,
|
||||
@@ -502,7 +502,7 @@ mod tests {
|
||||
doc_path: WorkspacePath("a.rs".into()),
|
||||
heading_path: vec![],
|
||||
section_label: None,
|
||||
snippet: "".into(),
|
||||
snippet: String::new(),
|
||||
citation: Citation::Code {
|
||||
path: WorkspacePath("a.rs".into()),
|
||||
line_start: 1,
|
||||
|
||||
@@ -20,3 +20,6 @@ anyhow = { workspace = true }
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Embedder for FastembedEmbedder {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let batch: Vec<Vec<f32>> = guard
|
||||
.embed(chunk_vec, Some(self.batch_size))
|
||||
.context("fastembed: embed")?;
|
||||
|
||||
@@ -28,3 +28,6 @@ mock = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn assert_vector_shape(vecs: &[Vec<f32>], expected_dims: usize) {
|
||||
/// Panics on mismatch (test-only helper — callers are tests).
|
||||
pub fn assert_unit_norm(vecs: &[Vec<f32>], tolerance: f32) {
|
||||
for (i, v) in vecs.iter().enumerate() {
|
||||
let norm_sq: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
|
||||
let norm_sq: f64 = v.iter().map(|&x| f64::from(x) * f64::from(x)).sum();
|
||||
let norm = norm_sq.sqrt() as f32;
|
||||
assert!(
|
||||
(norm - 1.0).abs() <= tolerance,
|
||||
|
||||
@@ -132,10 +132,10 @@ impl Embedder for MockEmbedder {
|
||||
.collect();
|
||||
|
||||
// L2-normalize. Skip the rare all-zero case to avoid 0/0 = NaN.
|
||||
let norm_sq: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
|
||||
let norm_sq: f64 = v.iter().map(|&x| f64::from(x) * f64::from(x)).sum();
|
||||
if norm_sq > 0.0 {
|
||||
let inv = (1.0 / norm_sq.sqrt()) as f32;
|
||||
for x in v.iter_mut() {
|
||||
for x in &mut v {
|
||||
*x *= inv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,6 @@ uuid = { workspace = true }
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -260,8 +260,8 @@ pub fn render_report_md(report: &CompareReport) -> String {
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
c.query_id,
|
||||
comparison_kind_label(c.kind),
|
||||
c.a_hit_rank.map(|r| r.to_string()).unwrap_or_else(|| "—".into()),
|
||||
c.b_hit_rank.map(|r| r.to_string()).unwrap_or_else(|| "—".into()),
|
||||
c.a_hit_rank.map_or_else(|| "—".into(), |r| r.to_string()),
|
||||
c.b_hit_rank.map_or_else(|| "—".into(), |r| r.to_string()),
|
||||
c.note.as_deref().unwrap_or(""),
|
||||
);
|
||||
}
|
||||
@@ -308,7 +308,7 @@ fn extract_chunker_version(snapshot_json: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(snapshot_json).ok()?;
|
||||
v.get("chunker_version")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_owned())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_results(
|
||||
@@ -402,8 +402,7 @@ fn classify(
|
||||
// so refusal-flow queries (no expected_*) don't appear as
|
||||
// regressions.
|
||||
let has_expected = gq
|
||||
.map(|g| !g.expected_chunk_ids.is_empty() || !g.expected_doc_ids.is_empty())
|
||||
.unwrap_or(false);
|
||||
.is_some_and(|g| !g.expected_chunk_ids.is_empty() || !g.expected_doc_ids.is_empty());
|
||||
if has_expected {
|
||||
(ComparisonKind::Regression, Some("hit→miss".into()))
|
||||
} else {
|
||||
@@ -426,7 +425,7 @@ fn build_deltas(
|
||||
if a.is_nan() || b.is_nan() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::Value::from((b - a) as f64)
|
||||
serde_json::Value::from(f64::from(b - a))
|
||||
}
|
||||
}
|
||||
let mut hit = serde_json::Map::new();
|
||||
|
||||
@@ -270,7 +270,21 @@ pub(crate) fn aggregate_from_rows(
|
||||
// recall@k_doc (doc-level, requires non-empty expected_doc_ids
|
||||
// and `>0` is the "should retrieve" condition; refusal queries
|
||||
// (`expected_doc_ids = []`) are excluded by spec).
|
||||
if !gq.expected_doc_ids.is_empty() {
|
||||
if gq.expected_doc_ids.is_empty() {
|
||||
// refusal_correctness: golden marks "should refuse" via empty
|
||||
// expected_doc_ids. We can only judge this on RAG runs — a
|
||||
// lexical-only run produces no Answer, so "refusal" is
|
||||
// undefined. Excluding such queries from the denominator
|
||||
// (rather than counting them as failures) keeps the metric
|
||||
// honest: a search-only run reports refusal_correctness as
|
||||
// NaN/null, not 0.0.
|
||||
if let Some(ans) = &qr.answer {
|
||||
refusal_denom += 1;
|
||||
if !ans.grounded {
|
||||
refusal_num += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let expected_docs: HashSet<&DocumentId> = gq.expected_doc_ids.iter().collect();
|
||||
for k in TOP_K_VARIANTS {
|
||||
let entry = recall_at_k_doc.get_mut(k).expect("init");
|
||||
@@ -285,20 +299,6 @@ pub(crate) fn aggregate_from_rows(
|
||||
let frac = covered as f64 / expected_docs.len() as f64;
|
||||
entry.0 += frac;
|
||||
}
|
||||
} else {
|
||||
// refusal_correctness: golden marks "should refuse" via empty
|
||||
// expected_doc_ids. We can only judge this on RAG runs — a
|
||||
// lexical-only run produces no Answer, so "refusal" is
|
||||
// undefined. Excluding such queries from the denominator
|
||||
// (rather than counting them as failures) keeps the metric
|
||||
// honest: a search-only run reports refusal_correctness as
|
||||
// NaN/null, not 0.0.
|
||||
if let Some(ans) = &qr.answer {
|
||||
refusal_denom += 1;
|
||||
if !ans.grounded {
|
||||
refusal_num += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// groundedness + citation_coverage (only meaningful with RAG
|
||||
@@ -532,6 +532,8 @@ mod tests {
|
||||
created_at: OffsetDateTime::UNIX_EPOCH,
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
hops: None,
|
||||
verification: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,10 @@ fn execute_query(app: &App, gq: &GoldenQuery, opts: &EvalRunOpts) -> QueryResult
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
// p9-fb-41: golden eval baseline runs are single-pass; the
|
||||
// multi-hop path is opted into per query via a future
|
||||
// fixture flag (PR-4+) once the runner learns to dispatch.
|
||||
multi_hop: false,
|
||||
};
|
||||
match app.ask(&gq.query, ask_opts) {
|
||||
Ok(ans) => Some(ans),
|
||||
|
||||
@@ -38,7 +38,44 @@ fn loads_minimal_well_formed_yaml() {
|
||||
assert_eq!(qs[1].difficulty.as_deref(), Some("easy"));
|
||||
}
|
||||
|
||||
// ── 2. duplicate IDs error lists every offender (sorted, deduplicated) ───────
|
||||
// ── 2. fb-41 multi-hop golden fixture loads + sanity-checks ─────────────────
|
||||
|
||||
/// fb-41 baseline + post-merge Δ measurement fixture. The shared
|
||||
/// loader must accept `fixtures/multi_hop_golden.yaml` and the bucket
|
||||
/// distribution must stay 5 cross-doc + 5 intra-doc + 5 single-fact
|
||||
/// negative — curators dropping or re-id'ing a question hit a clear
|
||||
/// failure mode here before it reaches the runner.
|
||||
#[test]
|
||||
fn loads_multi_hop_golden_fixture() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("fixtures")
|
||||
.join("multi_hop_golden.yaml");
|
||||
let qs = load_golden_set(&path).expect("multi_hop_golden.yaml must parse");
|
||||
|
||||
assert_eq!(qs.len(), 15, "fb-41 fixture must have 15 questions");
|
||||
|
||||
let cross_doc = qs.iter().filter(|q| q.id.starts_with("mh-c-")).count();
|
||||
let intra_doc = qs.iter().filter(|q| q.id.starts_with("mh-i-")).count();
|
||||
let single = qs.iter().filter(|q| q.id.starts_with("mh-s-")).count();
|
||||
assert_eq!(cross_doc, 5, "expected 5 mh-c-* (cross-doc) questions");
|
||||
assert_eq!(intra_doc, 5, "expected 5 mh-i-* (intra-doc) questions");
|
||||
assert_eq!(single, 5, "expected 5 mh-s-* (single-fact negative) questions");
|
||||
|
||||
// Every question carries at least one `must_contain` so the
|
||||
// rule-based answer-correctness metric (P5-2) has a signal even
|
||||
// before `expected_chunk_ids` are filled in.
|
||||
for q in &qs {
|
||||
assert!(
|
||||
!q.must_contain.is_empty(),
|
||||
"{}: must_contain is empty — baseline measurement needs a signal",
|
||||
q.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. duplicate IDs error lists every offender (sorted, deduplicated) ───────
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_ids() {
|
||||
|
||||
@@ -143,7 +143,7 @@ fn env_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static M: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -147,7 +147,7 @@ fn lexical_opts() -> EvalRunOpts {
|
||||
/// guard must outlive the call so concurrent tests don't reset the
|
||||
/// var mid-run.
|
||||
fn run_with_golden<F: FnOnce() -> R, R>(yaml: &Path, f: F) -> R {
|
||||
let _g = GOLDEN_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _g = GOLDEN_ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// SAFETY: `KEBAB_EVAL_GOLDEN` is a benign env var; the GOLDEN_ENV_LOCK
|
||||
// serializes mutations so concurrent tests don't race.
|
||||
unsafe {
|
||||
|
||||
@@ -34,3 +34,6 @@ anyhow = { workspace = true }
|
||||
# `tokio::*` symbols, so the public/runtime API stays sync.
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -400,9 +400,9 @@ impl Iterator for OllamaStream {
|
||||
// u32 saturation: even ~4G tokens is implausible for a
|
||||
// single chat turn; we still saturate rather than
|
||||
// panic on the unlikely case.
|
||||
prompt_tokens: prompt_tokens.min(u32::MAX as u64) as u32,
|
||||
completion_tokens: completion_tokens.min(u32::MAX as u64) as u32,
|
||||
latency_ms: (total_duration_ns / 1_000_000).min(u32::MAX as u64) as u32,
|
||||
prompt_tokens: prompt_tokens.min(u64::from(u32::MAX)) as u32,
|
||||
completion_tokens: completion_tokens.min(u64::from(u32::MAX)) as u32,
|
||||
latency_ms: (total_duration_ns / 1_000_000).min(u64::from(u32::MAX)) as u32,
|
||||
};
|
||||
return Some(Ok(TokenChunk::Done {
|
||||
finish_reason,
|
||||
|
||||
@@ -19,3 +19,6 @@ mock = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -27,3 +27,6 @@ kebab-core = { path = "../kebab-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn build_tools_vec() -> Vec<Tool> {
|
||||
),
|
||||
Tool::new(
|
||||
"ask",
|
||||
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
|
||||
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context. Set multi_hop=true for compound / cross-doc questions (decompose → retrieve → synthesize; 2-5× LLM cost; per-hop trace on Answer.hops).",
|
||||
schema_for_type::<tools::ask::AskInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
|
||||
@@ -20,6 +20,15 @@ pub struct AskInput {
|
||||
pub session_id: Option<String>,
|
||||
/// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid".
|
||||
pub mode: Option<String>,
|
||||
/// p9-fb-41: opt the ask into the multi-hop pipeline. Default `false`.
|
||||
/// When `true`, the query is decomposed into sub-questions, each
|
||||
/// retrieved independently, then synthesized over the merged
|
||||
/// chunk pool. Cost trade-off: 2–5× LLM calls vs. single-pass.
|
||||
/// Use for compound questions / cross-doc reasoning / prereq
|
||||
/// chains; keep `false` for simple fact lookups. The full
|
||||
/// per-hop trace (`decompose` / `decide` / `synthesize`) is
|
||||
/// exposed on `Answer.hops`.
|
||||
pub multi_hop: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult {
|
||||
@@ -38,6 +47,7 @@ pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult {
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
multi_hop: input.multi_hop.unwrap_or(false),
|
||||
};
|
||||
let cfg_clone = (*state.config).clone();
|
||||
let result = match input.session_id {
|
||||
|
||||
@@ -55,6 +55,7 @@ async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
|
||||
// Test env uses provider="none" — Hybrid would hard-error on embedding.
|
||||
// Pass Lexical explicitly so the test stays functional.
|
||||
mode: Some("lexical".to_string()),
|
||||
multi_hop: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -64,8 +65,7 @@ async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
|
||||
// Empty KB → refusal (grounded:false) is normal — NOT isError.
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false on refusal, got {:?}",
|
||||
result
|
||||
"expected isError=false on refusal, got {result:?}"
|
||||
);
|
||||
|
||||
let content = result
|
||||
@@ -85,7 +85,7 @@ async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
|
||||
"response should carry schema_version=answer.v1"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("grounded").and_then(|b| b.as_bool()),
|
||||
v.get("grounded").and_then(serde_json::Value::as_bool),
|
||||
Some(false),
|
||||
"empty KB should produce grounded=false"
|
||||
);
|
||||
|
||||
231
crates/kebab-mcp/tests/tools_call_ask_multi_hop.rs
Normal file
231
crates/kebab-mcp/tests/tools_call_ask_multi_hop.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! Pin the MCP `ask` tool's `multi_hop` argument dispatch contract.
|
||||
//!
|
||||
//! v0.18 dogfood fix (PR-7) introduced a pre-decompose score-gate probe
|
||||
//! in `RagPipeline::ask_multi_hop`: empty KB / sub-gate probe -> the
|
||||
//! single-pass NoChunks refusal envelope (`answer.v1`), not `error.v1`.
|
||||
//! The two surfaces' divergence is therefore observed *only when the probe
|
||||
//! passes* — at that point, single-pass returns retrieval + LLM call, and
|
||||
//! multi-hop calls decompose first (LLM unreachable -> `error.v1`).
|
||||
//!
|
||||
//! These two tests pin:
|
||||
//! 1. `ask_tool_routes_multi_hop_true_to_decompose_first` — probe-passing
|
||||
//! fixture, multi_hop=true → decompose (LLM error), single_pass → retrieval
|
||||
//! NoChunks. Wire shapes diverge: `error.v1` vs `answer.v1`.
|
||||
//! 2. `ask_tool_multi_hop_short_circuits_when_probe_empty` — empty KB,
|
||||
//! multi_hop=true → probe-empty short-circuit, NoChunks refusal byte-
|
||||
//! identical to single-pass. PR-7 의 intent 가 MCP layer 에 pin.
|
||||
//!
|
||||
//! A live-Ollama end-to-end multi-hop pin lands in a follow-up
|
||||
//! `#[ignore]` test (same pattern as `wire_ask_stale.rs`).
|
||||
|
||||
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;
|
||||
// Force the LLM endpoint to a known-unreachable port so this test
|
||||
// is robust against whether a real Ollama happens to be running
|
||||
// on 127.0.0.1:11434 (the developer's box; CI; etc.). The
|
||||
// `request_timeout_secs = 5` gives slow CI / Docker network stacks
|
||||
// enough headroom that *some* error fires deterministically — the
|
||||
// dispatch contract below only cares that `is_error` flipped, not
|
||||
// which specific error code surfaced.
|
||||
cfg.models.llm.endpoint = "http://127.0.0.1:1".to_string();
|
||||
cfg.models.llm.request_timeout_secs = 5;
|
||||
// Bypass the second probe gate (`top_score < score_gate`) so that the
|
||||
// probe-pass path in `RagPipeline::ask_multi_hop` (PR-7 v0.18 dogfood
|
||||
// fix) is reachable from a tiny lexical fixture whose FTS5 fusion
|
||||
// score may sit below the production default (0.30). The probe's
|
||||
// first gate (`probe_hits.is_empty()`) is unaffected — the empty-KB
|
||||
// short-circuit test below still exercises it. Production default
|
||||
// 0.30 remains untouched (test config isolation only).
|
||||
cfg.rag.score_gate = 0.0;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// The dispatch contract (post-PR-7 probe-first): with a probe-passing
|
||||
/// fixture, single-pass `ask` retrieves first and returns a NoChunks
|
||||
/// refusal Answer for an unrelated query (`grounded=false`,
|
||||
/// `isError=false`). Multi-hop's probe passes on the same fixture →
|
||||
/// decompose runs → unreachable LLM yields `error.v1` with
|
||||
/// `code=model_unreachable` (`isError=true`). The divergence confirms
|
||||
/// the `multi_hop` arg actually rerouted the dispatch *after* the
|
||||
/// probe gate.
|
||||
#[tokio::test]
|
||||
async fn ask_tool_routes_multi_hop_true_to_decompose_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path().join("data");
|
||||
let workspace_root = dir.path().join("notes");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
std::fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
// Lexical-friendly fixture so the multi-hop probe (PR-7 v0.18 dogfood
|
||||
// fix) returns at least one hit and we exercise the post-probe
|
||||
// decompose path. `build_match_string` rewrites the query
|
||||
// `"compound about X and Y"` into
|
||||
// `text : (("compound about X and Y") OR ("compound" "about" "and"))`
|
||||
// — the token_and branch is FTS5 implicit-AND, so the fixture body
|
||||
// MUST keep all three tokens (`compound`, `about`, `and`). Do not
|
||||
// collapse to a single-token body or the probe short-circuits to
|
||||
// NoChunks and the dispatch divergence below disappears.
|
||||
let fixture = workspace_root.join("note.md");
|
||||
std::fs::write(
|
||||
&fixture,
|
||||
"# Compound topic\n\nThis note is about a compound containing X and Y in detail.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = minimal_config(&data_dir, &workspace_root);
|
||||
|
||||
let scope = SourceScope {
|
||||
root: workspace_root.clone(),
|
||||
include: vec![],
|
||||
exclude: vec![],
|
||||
};
|
||||
let _ = kebab_app::ingest_with_config(cfg.clone(), scope, false).unwrap();
|
||||
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
// Multi-hop branch — decompose runs first, hits the unreachable
|
||||
// endpoint, MCP wraps as error.v1.
|
||||
let state_mh = handler.state().clone();
|
||||
let mh = tokio::task::spawn_blocking(move || {
|
||||
kebab_mcp::tools::ask::handle(
|
||||
&state_mh,
|
||||
kebab_mcp::tools::ask::AskInput {
|
||||
query: "compound about X and Y".to_string(),
|
||||
session_id: None,
|
||||
mode: Some("lexical".to_string()),
|
||||
multi_hop: Some(true),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
mh.is_error.unwrap_or(false),
|
||||
"multi_hop=true must reach the LLM (decompose first) — got {mh:?}"
|
||||
);
|
||||
let mh_text = match &mh.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => t.text.clone(),
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let mh_v: serde_json::Value = serde_json::from_str(&mh_text).unwrap();
|
||||
assert_eq!(mh_v["schema_version"], "error.v1");
|
||||
// The dispatch contract is "multi-hop's probe passed, then decompose
|
||||
// tried to talk to the LLM and failed" — i.e. `is_error` fires
|
||||
// because, *after* the PR-7 probe gate, decompose attempted an LLM
|
||||
// call against the unreachable endpoint. Which *specific* error code
|
||||
// lands (`model_unreachable` on fast ECONNREFUSED hosts, `timeout`
|
||||
// on slow connect-timeout stacks, etc.) is implementation detail of
|
||||
// the host TCP/HTTP path; pinning it here would just produce flakes
|
||||
// on slow CI.
|
||||
|
||||
// Single-pass branch — empty KB short-circuits at retrieve, no LLM
|
||||
// call happens, refusal Answer comes back as isError=false.
|
||||
let state_sp = handler.state().clone();
|
||||
let sp = tokio::task::spawn_blocking(move || {
|
||||
kebab_mcp::tools::ask::handle(
|
||||
&state_sp,
|
||||
kebab_mcp::tools::ask::AskInput {
|
||||
query: "anything".to_string(),
|
||||
session_id: None,
|
||||
mode: Some("lexical".to_string()),
|
||||
multi_hop: Some(false),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!sp.is_error.unwrap_or(false),
|
||||
"single-pass empty-KB refusal must NOT be isError — got {sp:?}"
|
||||
);
|
||||
let sp_text = match &sp.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => t.text.clone(),
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let sp_v: serde_json::Value = serde_json::from_str(&sp_text).unwrap();
|
||||
assert_eq!(sp_v["schema_version"], "answer.v1");
|
||||
assert_eq!(sp_v["grounded"], false);
|
||||
}
|
||||
|
||||
/// PR-7 의 probe-empty short-circuit 이 MCP-layer 의 wire shape 로 pin.
|
||||
/// 빈 KB + multi_hop=true → `RagPipeline::ask_multi_hop` 의 첫 probe
|
||||
/// gate (`probe_hits.is_empty()`) 에 막혀 `refuse_no_chunks` 가 single-pass
|
||||
/// 와 byte-identical 한 `answer.v1` refusal envelope 을 반환한다.
|
||||
/// kebab-rag::multi_hop_empty_probe_pool_refuses_before_any_llm_call 가
|
||||
/// RAG-layer 만 pin — MCP-layer 의 wire shape 는 본 test 만이 안전망.
|
||||
#[tokio::test]
|
||||
async fn ask_tool_multi_hop_short_circuits_when_probe_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path().join("data");
|
||||
let workspace_root = dir.path().join("notes");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
std::fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
let cfg = minimal_config(&data_dir, &workspace_root);
|
||||
let scope = SourceScope {
|
||||
root: workspace_root.clone(),
|
||||
include: vec![],
|
||||
exclude: vec![],
|
||||
};
|
||||
let _ = kebab_app::ingest_with_config(cfg.clone(), scope, false).unwrap();
|
||||
|
||||
let state = KebabAppState::new(cfg.clone(), None);
|
||||
let handler = KebabHandler::new(state);
|
||||
let state_mh = handler.state().clone();
|
||||
let mh = tokio::task::spawn_blocking(move || {
|
||||
kebab_mcp::tools::ask::handle(
|
||||
&state_mh,
|
||||
kebab_mcp::tools::ask::AskInput {
|
||||
query: "compound about X and Y".to_string(),
|
||||
session_id: None,
|
||||
mode: Some("lexical".to_string()),
|
||||
multi_hop: Some(true),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
mh.is_error,
|
||||
Some(false),
|
||||
"probe-empty short-circuit must yield refusal envelope, not error.v1 — got {mh:?}"
|
||||
);
|
||||
let mh_text = match &mh.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => t.text.clone(),
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(&mh_text).unwrap();
|
||||
assert_eq!(body["schema_version"], "answer.v1");
|
||||
assert_eq!(body["refusal_reason"], "no_chunks");
|
||||
}
|
||||
|
||||
/// AskInput's JSON-schema (rendered for tools/list) advertises the
|
||||
/// new `multi_hop` field. Pins agent / MCP host capability discovery
|
||||
/// against accidental schema-rename or omission.
|
||||
#[test]
|
||||
fn ask_input_schema_advertises_multi_hop_field() {
|
||||
let schema = schemars::schema_for!(kebab_mcp::tools::ask::AskInput);
|
||||
let v = serde_json::to_value(&schema).unwrap();
|
||||
let props = v
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.expect("AskInput schema must declare properties");
|
||||
assert!(
|
||||
props.contains_key("multi_hop"),
|
||||
"AskInput.multi_hop must surface in the JsonSchema — got keys: {:?}",
|
||||
props.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ async fn doctor_tool_returns_doctor_v1_json() {
|
||||
// `ok` boolean must be present (value may be false in CI where Ollama
|
||||
// is not reachable — that's expected and acceptable).
|
||||
assert!(
|
||||
v.get("ok").and_then(|b| b.as_bool()).is_some(),
|
||||
v.get("ok").and_then(serde_json::Value::as_bool).is_some(),
|
||||
"`ok` field missing in doctor.v1 response: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,8 +98,7 @@ async fn fetch_tool_chunk_returns_fetch_result_v1() {
|
||||
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false, got {:?}",
|
||||
result
|
||||
"expected isError=false, got {result:?}"
|
||||
);
|
||||
|
||||
let content = result
|
||||
@@ -123,7 +122,7 @@ async fn fetch_tool_chunk_returns_fetch_result_v1() {
|
||||
"kind must be 'chunk'"
|
||||
);
|
||||
assert!(
|
||||
v.get("chunk").is_some_and(|c| c.is_object()),
|
||||
v.get("chunk").is_some_and(serde_json::Value::is_object),
|
||||
"chunk payload must be populated for kind=chunk"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ async fn ingest_file_tool_returns_ingest_report_v1() {
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
assert_eq!(v.get("new").and_then(serde_json::Value::as_u64), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -91,7 +91,7 @@ async fn ingest_file_tool_idempotent_on_second_call() {
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let v1: serde_json::Value = serde_json::from_str(text1).unwrap();
|
||||
assert_eq!(v1.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
assert_eq!(v1.get("new").and_then(serde_json::Value::as_u64), Some(1));
|
||||
|
||||
// Second call — same content, expect unchanged=1.
|
||||
let r2 = tokio::task::spawn_blocking({
|
||||
@@ -112,6 +112,6 @@ async fn ingest_file_tool_idempotent_on_second_call() {
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let v2: serde_json::Value = serde_json::from_str(text2).unwrap();
|
||||
assert_eq!(v2.get("new").and_then(|n| n.as_u64()), Some(0), "{v2:?}");
|
||||
assert_eq!(v2.get("unchanged").and_then(|n| n.as_u64()), Some(1), "{v2:?}");
|
||||
assert_eq!(v2.get("new").and_then(serde_json::Value::as_u64), Some(0), "{v2:?}");
|
||||
assert_eq!(v2.get("unchanged").and_then(serde_json::Value::as_u64), Some(1), "{v2:?}");
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ async fn ingest_stdin_tool_returns_ingest_report_v1() {
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
assert_eq!(v.get("new").and_then(serde_json::Value::as_u64), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -49,8 +49,7 @@ async fn schema_tool_returns_schema_v1_json() {
|
||||
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false on healthy schema, got {:?}",
|
||||
result
|
||||
"expected isError=false on healthy schema, got {result:?}"
|
||||
);
|
||||
|
||||
let content = result.content.first().expect("expected at least one content item");
|
||||
@@ -68,7 +67,7 @@ async fn schema_tool_returns_schema_v1_json() {
|
||||
"unexpected schema_version in: {v}"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("capabilities").and_then(|c| c.get("mcp_server")).and_then(|b| b.as_bool()),
|
||||
v.get("capabilities").and_then(|c| c.get("mcp_server")).and_then(serde_json::Value::as_bool),
|
||||
Some(true),
|
||||
"mcp_server capability flag should be true after fb-30",
|
||||
);
|
||||
|
||||
@@ -71,8 +71,7 @@ async fn search_tool_returns_search_response_v1() {
|
||||
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false, got {:?}",
|
||||
result
|
||||
"expected isError=false, got {result:?}"
|
||||
);
|
||||
|
||||
let content = result
|
||||
@@ -108,7 +107,7 @@ async fn search_tool_returns_search_response_v1() {
|
||||
);
|
||||
// truncated must be present (bool); next_cursor may be null on last page.
|
||||
assert!(
|
||||
v.get("truncated").and_then(|t| t.as_bool()).is_some(),
|
||||
v.get("truncated").and_then(serde_json::Value::as_bool).is_some(),
|
||||
"envelope should carry truncated:bool"
|
||||
);
|
||||
assert!(
|
||||
@@ -172,8 +171,7 @@ async fn search_with_doc_id_filter_returns_only_target() {
|
||||
);
|
||||
assert!(
|
||||
!unfiltered.is_error.unwrap_or(false),
|
||||
"unfiltered search failed: {:?}",
|
||||
unfiltered
|
||||
"unfiltered search failed: {unfiltered:?}"
|
||||
);
|
||||
let unfiltered_text = match &unfiltered.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => t.text.clone(),
|
||||
@@ -211,8 +209,7 @@ async fn search_with_doc_id_filter_returns_only_target() {
|
||||
);
|
||||
assert!(
|
||||
!filtered.is_error.unwrap_or(false),
|
||||
"filtered search failed: {:?}",
|
||||
filtered
|
||||
"filtered search failed: {filtered:?}"
|
||||
);
|
||||
let filtered_text = match &filtered.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => t.text.clone(),
|
||||
|
||||
33
crates/kebab-nli/Cargo.toml
Normal file
33
crates/kebab-nli/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "kebab-nli"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "fb-41: NLI-based post-synthesis verification (XNLI mDeBERTa-v3). PR-9a = trait + scaffolding; ONNX inference lands in PR-9b."
|
||||
|
||||
[dependencies]
|
||||
# PR-9b: ONNX inference path activated. ort / tokenizers / hf-hub / ndarray
|
||||
# all source from `[workspace.dependencies]` so the workspace pins a single
|
||||
# version + feature set for the whole NLI + embed stack.
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
# ort: extend the workspace pin with `download-binaries` so kebab-nli
|
||||
# can link the ONNX runtime when fastembed is NOT in the build graph
|
||||
# (e.g. `cargo test -p kebab-nli` alone, where the per-crate feature
|
||||
# union excludes kebab-embed-local + fastembed). In workspace-wide
|
||||
# builds the feature gets union'd with fastembed's identical opt-in
|
||||
# so no extra runtime gets pulled.
|
||||
ort = { workspace = true, features = ["download-binaries"] }
|
||||
tokenizers = { workspace = true }
|
||||
hf-hub = { workspace = true }
|
||||
ndarray = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
129
crates/kebab-nli/src/lib.rs
Normal file
129
crates/kebab-nli/src/lib.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! `kebab-nli` — NLI-based post-synthesis verification for multi-hop RAG.
|
||||
//!
|
||||
//! fb-41 introduces a mDeBERTa-v3 XNLI verifier that runs on
|
||||
//! `(packed_chunks, generated_answer)` after synthesize. If
|
||||
//! `NliScores::faithfulness()` < threshold the rag crate refuses the answer
|
||||
//! with `NliVerificationFailed`. PR-9a (this file) is the trait surface +
|
||||
//! scaffolding only — `OnnxNliVerifier::score` returns a stub error until
|
||||
//! PR-9b adds the real ONNX inference path.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod onnx;
|
||||
|
||||
pub use onnx::OnnxNliVerifier;
|
||||
|
||||
/// Three-channel XNLI output. Channel order matches the standard XNLI
|
||||
/// `id2label` mapping `[entailment, neutral, contradiction]` shipped with
|
||||
/// the Xenova mDeBERTa-v3 model.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NliScores {
|
||||
pub entailment: f32,
|
||||
pub neutral: f32,
|
||||
pub contradiction: f32,
|
||||
}
|
||||
|
||||
impl NliScores {
|
||||
/// Faithfulness score = entailment channel. The rag crate compares this
|
||||
/// against `rag.nli_threshold` to decide whether to refuse.
|
||||
pub fn faithfulness(&self) -> f32 {
|
||||
self.entailment
|
||||
}
|
||||
|
||||
/// Wrap raw XNLI logits (`[entailment, neutral, contradiction]`) into
|
||||
/// a normalised `NliScores`. Applies a numerically-stable softmax3.
|
||||
pub fn from_xnli_logits(logits: [f32; 3]) -> Self {
|
||||
let probs = softmax3(logits);
|
||||
Self {
|
||||
entailment: probs[0],
|
||||
neutral: probs[1],
|
||||
contradiction: probs[2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract NLI verifier. `score` is called with `(premise = packed chunks,
|
||||
/// hypothesis = generated answer)` — the standard NLI direction (premise
|
||||
/// entails hypothesis ⇒ answer is grounded in retrieved evidence).
|
||||
pub trait NliVerifier: Send + Sync {
|
||||
fn score(&self, premise: &str, hypothesis: &str) -> anyhow::Result<NliScores>;
|
||||
|
||||
/// Probe-only tokenize for caller-side budget verification. S3
|
||||
/// follow-up (2026-05-26) — pipeline 의 char-budget retry loop 가
|
||||
/// 이 API 로 mDeBERTa-v3 의 `OnlyFirst` dead-end (hypothesis 단독이
|
||||
/// 512-token cap 초과 시 truncate 불가) 를 회피.
|
||||
///
|
||||
/// **Default impl 반환 `Ok(0)`** — 기존 mock implementations
|
||||
/// (`MockNliVerifier` 등) 가 trait 확장 후에도 backward-compat
|
||||
/// (compile fail 회피, retry loop immediate 통과). `OnnxNliVerifier`
|
||||
/// 는 real tokenizer 로 *trait impl 블록 안에서* override 해야 함
|
||||
/// — inherent method 는 vtable 미등록 → trait dispatch 시 default
|
||||
/// 호출 → production silent NO-OP.
|
||||
fn hypothesis_token_count(&self, _hypothesis: &str) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Numerically stable 3-way softmax (subtract max for log-sum-exp safety).
|
||||
/// Private — call sites should go through `NliScores::from_xnli_logits`.
|
||||
fn softmax3(logits: [f32; 3]) -> [f32; 3] {
|
||||
let max = logits[0].max(logits[1]).max(logits[2]);
|
||||
let e0 = (logits[0] - max).exp();
|
||||
let e1 = (logits[1] - max).exp();
|
||||
let e2 = (logits[2] - max).exp();
|
||||
let sum = e0 + e1 + e2;
|
||||
[e0 / sum, e1 / sum, e2 / sum]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
|
||||
(a - b).abs() <= eps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn softmax3_normalises_to_unit() {
|
||||
let p = softmax3([1.0, 2.0, 3.0]);
|
||||
assert!(p.iter().all(|x| *x > 0.0));
|
||||
assert!(approx_eq(p[0] + p[1] + p[2], 1.0, 1e-6));
|
||||
// Monotonic: larger logit ⇒ larger probability.
|
||||
assert!(p[0] < p[1] && p[1] < p[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn softmax3_is_invariant_to_constant_shift() {
|
||||
let a = softmax3([1.0, 2.0, 3.0]);
|
||||
let b = softmax3([101.0, 102.0, 103.0]);
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
approx_eq(a[i], b[i], 1e-6),
|
||||
"channel {i} drifted: a={a:?} b={b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nli_scores_from_xnli_logits_orders_correctly() {
|
||||
// entailment dominates ⇒ entailment is the max probability channel.
|
||||
let s = NliScores::from_xnli_logits([5.0, 1.0, 0.5]);
|
||||
assert!(s.entailment > s.neutral);
|
||||
assert!(s.entailment > s.contradiction);
|
||||
assert!(approx_eq(
|
||||
s.entailment + s.neutral + s.contradiction,
|
||||
1.0,
|
||||
1e-6
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faithfulness_returns_entailment_channel() {
|
||||
let s = NliScores {
|
||||
entailment: 0.7,
|
||||
neutral: 0.2,
|
||||
contradiction: 0.1,
|
||||
};
|
||||
assert!(approx_eq(s.faithfulness(), 0.7, f32::EPSILON));
|
||||
}
|
||||
}
|
||||
433
crates/kebab-nli/src/onnx.rs
Normal file
433
crates/kebab-nli/src/onnx.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
//! ONNX-backed `NliVerifier` adapter (mDeBERTa-v3 XNLI).
|
||||
//!
|
||||
//! `new` resolves the cache directory from
|
||||
//! `config.storage.model_dir/nli/<sanitized-model-id>/` (matching the
|
||||
//! fastembed adapter's pattern of `model_dir/fastembed/`) and stamps it
|
||||
//! on `self`. The (potentially network-bound) model + tokenizer download
|
||||
//! is deferred to the first `score` call via `OnceLock<Session>` /
|
||||
//! `OnceLock<Tokenizer>` — keeping `new` cheap so the rag crate can
|
||||
//! construct the verifier eagerly during `App` boot without paying for
|
||||
//! a model load on every CLI invocation.
|
||||
//!
|
||||
//! Per design §2.2.2 (Lazy init), §2.2.3 (truncation = `OnlyFirst`,
|
||||
//! premise truncates, hypothesis preserved). The model id flows from
|
||||
//! `config.models.nli.model`; `config.models.nli.provider` selects the
|
||||
//! verifier impl (only `"onnx"` is implemented in v0.18).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use kebab_config::expand_path;
|
||||
use ort::session::Session;
|
||||
use tokenizers::{
|
||||
Tokenizer, TruncationDirection, TruncationParams, TruncationStrategy,
|
||||
};
|
||||
|
||||
use crate::{NliScores, NliVerifier};
|
||||
|
||||
/// Filename inside the HF repo (NOT a path on disk). The Xenova repo
|
||||
/// packages the mDeBERTa-v3-base XNLI multilingual checkpoint (the
|
||||
/// default `config.models.nli.model` — see `kebab-config::NliCfg::defaults`)
|
||||
/// as ONNX under this path; the tokenizer ships at `tokenizer.json`.
|
||||
const HF_MODEL_FILE: &str = "onnx/model.onnx";
|
||||
/// Filename inside the HF repo (NOT a path on disk).
|
||||
const HF_TOKENIZER_FILE: &str = "tokenizer.json";
|
||||
|
||||
/// Subdirectory under `config.storage.model_dir` where the NLI adapter
|
||||
/// writes / reads ONNX + tokenizer files. Mirrors the fastembed
|
||||
/// adapter's `model_dir/fastembed/` layout.
|
||||
const NLI_CACHE_SUBDIR: &str = "nli";
|
||||
|
||||
/// XNLI label order in the Xenova mDeBERTa-v3 checkpoint: the model's
|
||||
/// output logits are `[entailment, neutral, contradiction]`. Pinned as
|
||||
/// a constant so a future model swap (different label order) is a
|
||||
/// single-site change.
|
||||
const LOGITS_LEN: usize = 3;
|
||||
|
||||
/// Max input length passed to the tokenizer. mDeBERTa-v3 is trained
|
||||
/// at 512-token context, matches the Xenova ONNX export's positional
|
||||
/// embedding shape. `OnlyFirst` strategy makes the premise (which is
|
||||
/// allowed to be the packed-chunks context) absorb the truncation;
|
||||
/// the hypothesis (the generated answer) is preserved.
|
||||
const MAX_TOKENS: usize = 512;
|
||||
|
||||
/// ONNX-runtime mDeBERTa-v3 XNLI verifier.
|
||||
///
|
||||
/// `session` + `tokenizer` are lazily populated by the first call to
|
||||
/// `ensure_loaded`. `new` is eager only for cache_dir create_dir_all
|
||||
/// (cheap) so that the rag crate can construct an instance during
|
||||
/// `App` boot without paying for the ~280 MB model download.
|
||||
pub struct OnnxNliVerifier {
|
||||
model_id: String,
|
||||
cache_dir: PathBuf,
|
||||
session: OnceLock<Session>,
|
||||
tokenizer: OnceLock<Tokenizer>,
|
||||
}
|
||||
|
||||
impl OnnxNliVerifier {
|
||||
/// Hypothesis-side budget. Pipeline 의
|
||||
/// `truncate_hypothesis_for_nli_with_budget` retry loop 가 char-truncate
|
||||
/// 후 token-count 재검증 시 이 값을 cap 으로 사용. = `MAX_TOKENS`
|
||||
/// (512) - 3 special tokens reserved (CLS, SEP, SEP) - 253 premise
|
||||
/// room (caller decides). 안전 마진 (S3 follow-up 2026-05-26).
|
||||
pub const HYPOTHESIS_TOKEN_BUDGET: usize = 256;
|
||||
|
||||
/// Construct a verifier from the user's `Config`. Eagerly resolves
|
||||
/// `cache_dir = config.storage.model_dir/nli/<sanitized-model-id>/`
|
||||
/// and runs `create_dir_all` so the first `score` call can drop
|
||||
/// straight into download + load without re-deriving paths.
|
||||
///
|
||||
/// Reads `config.models.nli.model` for the HuggingFace model id
|
||||
/// and `config.models.nli.provider` to select the verifier impl —
|
||||
/// only `"onnx"` is implemented in v0.18. The defaults live in
|
||||
/// `kebab-config::NliCfg::defaults` so this path always receives
|
||||
/// a non-empty model id.
|
||||
pub fn new(config: &kebab_config::Config) -> Result<Self> {
|
||||
let provider = config.models.nli.provider.as_str();
|
||||
if provider != "onnx" {
|
||||
anyhow::bail!(
|
||||
"kebab-nli: unsupported provider {provider:?} (only 'onnx' is implemented in v0.18)"
|
||||
);
|
||||
}
|
||||
let model_id = config.models.nli.model.clone();
|
||||
|
||||
// Match kebab-embed-local's two-step expansion: data_dir first,
|
||||
// then model_dir with `{data_dir}` substituted in.
|
||||
let data_dir = expand_path(&config.storage.data_dir, "");
|
||||
let model_dir = expand_path(&config.storage.model_dir, &data_dir.to_string_lossy());
|
||||
let cache_dir = model_dir
|
||||
.join(NLI_CACHE_SUBDIR)
|
||||
.join(sanitize_model_id(&model_id));
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("create kebab-nli cache dir {}", cache_dir.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
model_id,
|
||||
cache_dir,
|
||||
session: OnceLock::new(),
|
||||
tokenizer: OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Download (if needed) + load the ONNX session and tokenizer on
|
||||
/// first call; return cached refs on subsequent calls. Uses two
|
||||
/// `OnceLock`s rather than one because a single `OnceLock<(_, _)>`
|
||||
/// would need to construct both atomically — keeping them split
|
||||
/// lets us short-circuit on the (rare) hit path where only one
|
||||
/// side is missing.
|
||||
///
|
||||
/// `OnceLock::get_or_try_init` is still unstable (rust-lang/rust#109737)
|
||||
/// so we implement the fallible init by hand: probe `get`, on miss
|
||||
/// compute the value, then `set` it. The race between two threads is
|
||||
/// resolved by `OnceLock::set` — the loser gets `Err`, falls through
|
||||
/// to a second `get`, and reads the winner's value. Each thread that
|
||||
/// races + loses does pay the cost of one redundant download (rare in
|
||||
/// practice: rag boot is single-threaded today), but the cache stays
|
||||
/// consistent.
|
||||
fn ensure_loaded(&self) -> Result<(&Session, &Tokenizer)> {
|
||||
if self.session.get().is_none() {
|
||||
let s = self.load_session()?;
|
||||
let _ = self.session.set(s); // loser of a race: discard local value
|
||||
}
|
||||
if self.tokenizer.get().is_none() {
|
||||
let t = self.load_tokenizer()?;
|
||||
let _ = self.tokenizer.set(t);
|
||||
}
|
||||
// Both OnceLocks are populated at this point; `expect` is a
|
||||
// tighter post-condition than `unwrap_or_else` would be.
|
||||
let session = self.session.get().expect("session populated above");
|
||||
let tokenizer = self.tokenizer.get().expect("tokenizer populated above");
|
||||
Ok((session, tokenizer))
|
||||
}
|
||||
|
||||
/// Build an `hf_hub::api::sync::Api` rooted at `self.cache_dir` and
|
||||
/// fetch `filename` from `self.model_id`. Logs cache hits at INFO
|
||||
/// so a user reading kebab logs can see which artifact source the
|
||||
/// pipeline picked.
|
||||
fn fetch(&self, filename: &str) -> Result<PathBuf> {
|
||||
// Round-1 review N1 fix: `Api::get` triggers download on miss,
|
||||
// so we can't use it as a hit probe. `Cache::get` is fs-only —
|
||||
// returns Some(path) if cached, None otherwise. No network.
|
||||
let repo = hf_hub::Repo::new(self.model_id.clone(), hf_hub::RepoType::Model);
|
||||
let cached = hf_hub::Cache::new(self.cache_dir.clone())
|
||||
.repo(repo.clone())
|
||||
.get(filename)
|
||||
.is_some();
|
||||
if cached {
|
||||
tracing::info!(
|
||||
target: "kebab-nli",
|
||||
model_id = %self.model_id,
|
||||
file = %filename,
|
||||
"NLI artifact cache hit"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "kebab-nli",
|
||||
model_id = %self.model_id,
|
||||
file = %filename,
|
||||
cache_dir = %self.cache_dir.display(),
|
||||
"downloading NLI artifact"
|
||||
);
|
||||
}
|
||||
|
||||
let api = hf_hub::api::sync::ApiBuilder::new()
|
||||
.with_cache_dir(self.cache_dir.clone())
|
||||
.build()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"kebab-nli: hf-hub ApiBuilder::build failed (cache_dir={})",
|
||||
self.cache_dir.display()
|
||||
)
|
||||
})?;
|
||||
api.model(self.model_id.clone())
|
||||
.get(filename)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"kebab-nli: hf-hub fetch failed for {filename} (model_id={}, cache_dir={})",
|
||||
self.model_id,
|
||||
self.cache_dir.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_session(&self) -> Result<Session> {
|
||||
tracing::info!(
|
||||
target: "kebab-nli",
|
||||
model_id = %self.model_id,
|
||||
"downloading NLI model + tokenizer (first run only)"
|
||||
);
|
||||
let model_path = self.fetch(HF_MODEL_FILE)?;
|
||||
let session = Session::builder()
|
||||
.with_context(|| "kebab-nli: ort Session::builder failed")?
|
||||
.commit_from_file(&model_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"kebab-nli: ort Session::commit_from_file({}) failed",
|
||||
model_path.display()
|
||||
)
|
||||
})?;
|
||||
tracing::info!(
|
||||
target: "kebab-nli",
|
||||
model_id = %self.model_id,
|
||||
model_path = %model_path.display(),
|
||||
"NLI model ready"
|
||||
);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn load_tokenizer(&self) -> Result<Tokenizer> {
|
||||
let tokenizer_path = self.fetch(HF_TOKENIZER_FILE)?;
|
||||
let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
|
||||
.map_err(|e| anyhow!("kebab-nli: Tokenizer::from_file({}) failed: {e}", tokenizer_path.display()))?;
|
||||
tokenizer
|
||||
.with_truncation(Some(TruncationParams {
|
||||
max_length: MAX_TOKENS,
|
||||
strategy: TruncationStrategy::OnlyFirst,
|
||||
stride: 0,
|
||||
direction: TruncationDirection::Right,
|
||||
}))
|
||||
.map_err(|e| anyhow!("kebab-nli: Tokenizer::with_truncation failed: {e}"))?;
|
||||
Ok(tokenizer)
|
||||
}
|
||||
}
|
||||
|
||||
impl NliVerifier for OnnxNliVerifier {
|
||||
fn score(&self, premise: &str, hypothesis: &str) -> Result<NliScores> {
|
||||
// Defense-in-depth: spec §2.3 has the caller skip empty answers,
|
||||
// but a degenerate empty hypothesis here would tokenize to a
|
||||
// [CLS][SEP][SEP] triple that yields a near-uniform softmax —
|
||||
// misleading both faithfulness gate and any future logging.
|
||||
if hypothesis.trim().is_empty() {
|
||||
anyhow::bail!("kebab-nli: empty hypothesis");
|
||||
}
|
||||
|
||||
let (session, tokenizer) = self.ensure_loaded()?;
|
||||
|
||||
let enc = tokenizer
|
||||
.encode((premise, hypothesis), true)
|
||||
.map_err(|e| anyhow!("kebab-nli: tokenizer.encode failed: {e}"))?;
|
||||
|
||||
let ids: Vec<i64> = enc.get_ids().iter().map(|&u| i64::from(u)).collect();
|
||||
let mask: Vec<i64> = enc
|
||||
.get_attention_mask()
|
||||
.iter()
|
||||
.map(|&u| i64::from(u))
|
||||
.collect();
|
||||
let seq_len = ids.len();
|
||||
|
||||
// mDeBERTa-v3 ONNX export expects [batch, seq_len] for both
|
||||
// input_ids and attention_mask. We always feed batch=1.
|
||||
let ids_arr = ndarray::Array2::from_shape_vec((1, seq_len), ids)
|
||||
.with_context(|| "kebab-nli: input_ids ndarray shape build failed")?;
|
||||
let mask_arr = ndarray::Array2::from_shape_vec((1, seq_len), mask)
|
||||
.with_context(|| "kebab-nli: attention_mask ndarray shape build failed")?;
|
||||
|
||||
let outputs = session
|
||||
.run(ort::inputs! {
|
||||
"input_ids" => ids_arr,
|
||||
"attention_mask" => mask_arr,
|
||||
}?)
|
||||
.with_context(|| "kebab-nli: ort Session::run failed")?;
|
||||
|
||||
let logits = outputs["logits"]
|
||||
.try_extract_tensor::<f32>()
|
||||
.with_context(|| "kebab-nli: logits try_extract_tensor::<f32> failed")?;
|
||||
|
||||
// Expected shape [1, 3]. Defensive check — a model swap with a
|
||||
// different head would silently produce wrong scores otherwise.
|
||||
let shape = logits.shape();
|
||||
if shape != [1, LOGITS_LEN] {
|
||||
anyhow::bail!(
|
||||
"kebab-nli: unexpected logits shape {shape:?}, expected [1, {LOGITS_LEN}]"
|
||||
);
|
||||
}
|
||||
let l = [logits[[0, 0]], logits[[0, 1]], logits[[0, 2]]];
|
||||
Ok(NliScores::from_xnli_logits(l))
|
||||
}
|
||||
|
||||
/// **Override** the trait default `Ok(0)` with a real mDeBERTa
|
||||
/// tokenize. Pipeline 의 `truncate_hypothesis_for_nli_with_budget`
|
||||
/// retry loop 가 이 method 를 vtable 통해 호출 — production code
|
||||
/// path 에서 실 token count 측정.
|
||||
///
|
||||
/// **CRITICAL placement**: 이 method 는 *trait impl block 안* 에
|
||||
/// 위치해야 vtable 에 등록 — inherent `impl OnnxNliVerifier {}` 안에
|
||||
/// 두면 dispatch 시 trait default (`Ok(0)`) 호출 → retry loop
|
||||
/// 즉시 통과 → production silent NO-OP (S3 follow-up 2026-05-26
|
||||
/// RC1-residual closure).
|
||||
fn hypothesis_token_count(&self, hypothesis: &str) -> Result<usize> {
|
||||
let (_session, tokenizer) = self.ensure_loaded()?;
|
||||
let enc = tokenizer
|
||||
.encode(hypothesis, /*add_special_tokens=*/ false)
|
||||
.map_err(|e| anyhow!("kebab-nli: tokenizer.encode (probe) failed: {e}"))?;
|
||||
Ok(enc.get_ids().len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a HuggingFace model id (`"owner/repo"`) into a single
|
||||
/// path component safe to use as a directory name. `/` → `_` is
|
||||
/// enough for current ids; if more exotic chars appear we'll
|
||||
/// widen this then.
|
||||
fn sanitize_model_id(s: &str) -> String {
|
||||
s.replace('/', "_")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_config::Config;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Round-1 review N2 fix: redirect Config.storage.{data,model}_dir
|
||||
/// into a tempdir so unit tests don't litter the user's XDG dirs
|
||||
/// with empty `nli/` subdirs.
|
||||
fn tempdir_config() -> (TempDir, Config) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.storage.data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
cfg.storage.model_dir = "{data_dir}/models".to_string();
|
||||
(tmp, cfg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_succeeds_on_default_config() {
|
||||
let (_tmp, cfg) = tempdir_config();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("new should succeed on default config");
|
||||
// cache_dir must include the sanitized model id (no '/').
|
||||
let s = v.cache_dir.to_string_lossy();
|
||||
assert!(s.contains(NLI_CACHE_SUBDIR), "cache_dir lacks nli/: {s}");
|
||||
assert!(
|
||||
!s.contains("Xenova/mDeBERTa"),
|
||||
"cache_dir must sanitize '/' in model id: {s}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("Xenova_mDeBERTa"),
|
||||
"cache_dir should contain sanitized id: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty hypothesis takes the defense-in-depth early bail path —
|
||||
/// reaches no model load, so this is a pure unit test (no network).
|
||||
/// Replaces PR-9a's `score_returns_err_in_skeleton` (stub-only).
|
||||
#[test]
|
||||
fn score_empty_hypothesis_returns_err() {
|
||||
let (_tmp, cfg) = tempdir_config();
|
||||
let v = OnnxNliVerifier::new(&cfg).unwrap();
|
||||
let err = v.score("anything", "").expect_err("empty hypothesis must error");
|
||||
assert!(
|
||||
err.to_string().contains("empty hypothesis"),
|
||||
"unexpected error message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins that `config.models.nli.model` flows into `OnnxNliVerifier`
|
||||
/// instead of being silently overridden by a hardcoded constant.
|
||||
/// `model_id` is a private field, but this test lives in the same
|
||||
/// module so it can read it directly — the wiring contract is
|
||||
/// "whatever the user puts in TOML / KEBAB_MODELS_NLI_MODEL is the
|
||||
/// id the verifier uses".
|
||||
#[test]
|
||||
fn new_uses_config_model_id() {
|
||||
let (_tmp, mut cfg) = tempdir_config();
|
||||
cfg.models.nli.model = "custom-org/custom-nli-model".to_string();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("new should succeed with custom model id");
|
||||
assert_eq!(v.model_id, "custom-org/custom-nli-model");
|
||||
// The custom id also flows into the on-disk cache_dir layout
|
||||
// (sanitized so `/` doesn't escape the namespace).
|
||||
let s = v.cache_dir.to_string_lossy();
|
||||
assert!(
|
||||
s.contains("custom-org_custom-nli-model"),
|
||||
"cache_dir should embed sanitized custom model id: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins that a non-`"onnx"` provider value errors out at `new` —
|
||||
/// the field is no longer silently ignored.
|
||||
#[test]
|
||||
fn new_rejects_unsupported_provider() {
|
||||
let (_tmp, mut cfg) = tempdir_config();
|
||||
cfg.models.nli.provider = "candle".to_string();
|
||||
let result = OnnxNliVerifier::new(&cfg);
|
||||
assert!(result.is_err(), "non-onnx provider must error");
|
||||
let msg = result.err().unwrap().to_string();
|
||||
assert!(
|
||||
msg.contains("unsupported provider") && msg.contains("candle"),
|
||||
"error should name the rejected provider: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── sanitize_model_id pure-fn coverage ────────────────────────────────
|
||||
//
|
||||
// Three tests pin the behavior of the private `sanitize_model_id`
|
||||
// helper. These are orthogonal to the H1 executor tests above
|
||||
// (which cover config-wiring); these cover the transformation
|
||||
// contract of the sanitizer itself.
|
||||
|
||||
#[test]
|
||||
fn sanitize_model_id_replaces_slash_with_underscore() {
|
||||
let input = "Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7";
|
||||
let expected = "Xenova_mDeBERTa-v3-base-xnli-multilingual-nli-2mil7";
|
||||
assert_eq!(sanitize_model_id(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_model_id_is_idempotent_on_already_sanitized() {
|
||||
// Input with no '/' must come back byte-for-byte unchanged.
|
||||
let input = "Xenova_mDeBERTa-v3-base-xnli-multilingual-nli-2mil7";
|
||||
assert_eq!(sanitize_model_id(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_model_id_leaves_other_chars_untouched() {
|
||||
// Hyphens, digits, dots, and underscores must all pass through
|
||||
// unchanged — only '/' is replaced with '_'.
|
||||
let input = "org_name/model-name_v2.3-alpha";
|
||||
let got = sanitize_model_id(input);
|
||||
assert_eq!(got, "org_name_model-name_v2.3-alpha");
|
||||
assert!(!got.contains('/'), "no slash must remain after sanitize");
|
||||
assert!(got.contains('-'), "hyphens must be preserved");
|
||||
assert!(got.contains('.'), "dots must be preserved");
|
||||
assert!(got.contains('_'), "underscores must be preserved");
|
||||
}
|
||||
}
|
||||
197
crates/kebab-nli/tests/inference.rs
Normal file
197
crates/kebab-nli/tests/inference.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Integration tests for `OnnxNliVerifier` against the real
|
||||
//! mDeBERTa-v3 XNLI model. Every test is `#[ignore]` — plain
|
||||
//! `cargo test -p kebab-nli` skips them; run explicitly with
|
||||
//! `cargo test -p kebab-nli --test inference -- --ignored` to
|
||||
//! exercise the (slow + network-bound on first run) inference path.
|
||||
//!
|
||||
//! First test in the file triggers the ~280 MB ONNX + ~16 MB
|
||||
//! tokenizer download into `config.storage.model_dir/nli/...`;
|
||||
//! subsequent tests hit the OnceLock cache for free.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_nli::{NliVerifier, OnnxNliVerifier};
|
||||
|
||||
/// Test 1: an English statement entails itself with high confidence.
|
||||
/// Smoke evidence captured for the PR description's `## 검증` section.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn en_self_entailment_high_score() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
let premise = "Caffeine is a stimulant.";
|
||||
let hypothesis = "Caffeine is a stimulant.";
|
||||
let s = v.score(premise, hypothesis).expect("score should succeed");
|
||||
eprintln!(
|
||||
"[test1 en_self_entailment_high_score] premise={premise:?} hypothesis={hypothesis:?} \
|
||||
scores: entailment={:.4}, neutral={:.4}, contradiction={:.4}",
|
||||
s.entailment, s.neutral, s.contradiction
|
||||
);
|
||||
assert!(
|
||||
s.entailment > 0.8,
|
||||
"expected entailment > 0.8, got {:.4} (full scores: {:?})",
|
||||
s.entailment,
|
||||
s
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 2: an unrelated chemistry fact does NOT entail the premise.
|
||||
/// Entailment should be low — neutral / contradiction wins.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn en_unrelated_low_entailment() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
let premise = "Caffeine is a stimulant.";
|
||||
let hypothesis = "The chemical formula of caffeine is C8H10N4O2.";
|
||||
let s = v.score(premise, hypothesis).expect("score should succeed");
|
||||
eprintln!(
|
||||
"[test2 en_unrelated_low_entailment] \
|
||||
scores: entailment={:.4}, neutral={:.4}, contradiction={:.4}",
|
||||
s.entailment, s.neutral, s.contradiction
|
||||
);
|
||||
// spec §3 PR-9b: "entailment 낮음 — neutral/contradiction 이 winning channel" 의
|
||||
// *spirit* 은 *neutral 이 max* 임. 실측 mDeBERTa 의 noise (entailment≈0.42, neutral≈0.53,
|
||||
// contradiction≈0.05) 에서 두 문장 모두 caffeine 의 *사실* 이라 entailment 가 0.3 미만으로
|
||||
// 떨어지지 않음 — 그러나 neutral 이 winning. multilingual NLI 의 자연스러운 동작.
|
||||
assert!(
|
||||
s.neutral > s.entailment && s.neutral > s.contradiction,
|
||||
"expected neutral to win (no entailment, no contradiction), got {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 3: Korean entailment. The threshold is intentionally generous
|
||||
/// (> 0.5) because cross-lingual XNLI is noisier than English-only.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ko_entailment_high_score() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
let premise = "사과는 빨갛다.";
|
||||
let hypothesis = "사과는 색이 있다.";
|
||||
let s = v.score(premise, hypothesis).expect("score should succeed");
|
||||
eprintln!(
|
||||
"[test3 ko_entailment_high_score] \
|
||||
scores: entailment={:.4}, neutral={:.4}, contradiction={:.4}",
|
||||
s.entailment, s.neutral, s.contradiction
|
||||
);
|
||||
assert!(
|
||||
s.entailment > 0.5,
|
||||
"expected entailment > 0.5, got {:.4} (full scores: {:?})",
|
||||
s.entailment,
|
||||
s
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 4: a > 24 000-char premise must not panic. mDeBERTa-v3 is
|
||||
/// trained at 512 tokens; the `OnlyFirst` truncation strategy keeps
|
||||
/// the premise side from blowing the positional embedding cap.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn long_premise_truncates_without_panic() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
let premise = "foo bar baz ".repeat(2000); // ~24 000 chars
|
||||
let hypothesis = "foo";
|
||||
let s = v
|
||||
.score(&premise, hypothesis)
|
||||
.expect("score should succeed on long premise");
|
||||
eprintln!(
|
||||
"[test4 long_premise_truncates_without_panic] premise_len={} \
|
||||
scores: entailment={:.4}, neutral={:.4}, contradiction={:.4}",
|
||||
premise.len(),
|
||||
s.entailment,
|
||||
s.neutral,
|
||||
s.contradiction
|
||||
);
|
||||
// No NaN / infinity in any channel.
|
||||
for (name, x) in [
|
||||
("entailment", s.entailment),
|
||||
("neutral", s.neutral),
|
||||
("contradiction", s.contradiction),
|
||||
] {
|
||||
assert!(
|
||||
x.is_finite(),
|
||||
"channel {name} non-finite: {x} (full scores: {s:?})"
|
||||
);
|
||||
}
|
||||
// Softmax invariant — the three channels sum to ~1.
|
||||
let sum = s.entailment + s.neutral + s.contradiction;
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 1e-3,
|
||||
"softmax channels must sum to ~1, got {sum:.6}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 5: an empty hypothesis triggers the defense-in-depth bail
|
||||
/// path BEFORE the tokenizer runs. Hits no network — fast, even on
|
||||
/// a fresh machine.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn empty_hypothesis_returns_err() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
let err = v
|
||||
.score("anything", "")
|
||||
.expect_err("empty hypothesis must error");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("empty hypothesis"),
|
||||
"expected 'empty hypothesis' in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 6 (S3 follow-up 2026-05-26): EN-long hypothesis alone exceeds
|
||||
/// max_length. Without pipeline-side truncation, `OnlyFirst` strategy
|
||||
/// dead-ends. Pin raw nli crate behavior so any future regression in
|
||||
/// the pipeline-side budget surfaces as a clear nli-level err.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn score_long_en_hypothesis_returns_err_without_pipeline_truncation() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
let premise = "short premise";
|
||||
let hypothesis = "lorem ipsum ".repeat(500); // ~6 000 chars / >>512 tokens
|
||||
let result = v.score(premise, &hypothesis);
|
||||
assert!(result.is_err(), "long hypothesis should err under OnlyFirst");
|
||||
let msg = result.err().unwrap().to_string();
|
||||
assert!(
|
||||
msg.contains("Truncation error") || msg.contains("too short to respect"),
|
||||
"expected tokenizer truncation err, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 7 (S3 follow-up 2026-05-26): `hypothesis_token_count` helper —
|
||||
/// pure tokenizer probe. **vtable dispatch 검증** (RC1-residual pin) —
|
||||
/// concrete type 호출은 inherent method 우선이라 RC1-residual 버그
|
||||
/// 잡지 못함; `&dyn NliVerifier` 통해 dispatch 해야 vtable 등록 검증.
|
||||
/// inherent-only 배치 시 default `Ok(0)` 반환 → `assert!(count > 0)`
|
||||
/// 실패. trait impl block 배치 시 real tokenizer → PASS. Pipeline 이
|
||||
/// retry budget 결정에 사용하는 API 의 정확성 pin.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hypothesis_token_count_dispatches_correctly_via_dyn_trait() {
|
||||
let cfg = Config::defaults();
|
||||
let v = OnnxNliVerifier::new(&cfg).expect("verifier construction");
|
||||
// ★ vtable dispatch — &dyn NliVerifier 통해 호출. inherent-only
|
||||
// 배치 시 default `Ok(0)` 반환 → assert!(count > 0) 실패.
|
||||
// trait impl block 배치 시 real tokenizer → PASS. RC1-residual
|
||||
// 의 코드-수준 regression pin.
|
||||
let v_dyn: &dyn NliVerifier = &v;
|
||||
// 짧은 EN — 4 chars/token 추정 (27 chars / 4 = ~6 tokens)
|
||||
let en_count = v_dyn
|
||||
.hypothesis_token_count("short english test sentence")
|
||||
.expect("EN dyn dispatch must reach real tokenizer (vtable check)");
|
||||
assert!(
|
||||
en_count > 0 && en_count < 20,
|
||||
"EN ~6 tokens expected via vtable dispatch, got {en_count} \
|
||||
(Ok(0) signals inherent-only placement bug — RC1-residual)"
|
||||
);
|
||||
// 짧은 KR — 1-2 chars/token (15 chars / 1.5 = ~10 tokens)
|
||||
let kr_count = v_dyn
|
||||
.hypothesis_token_count("짧은 한국어 테스트 문장입니다")
|
||||
.expect("KR dyn dispatch must reach real tokenizer");
|
||||
assert!(
|
||||
kr_count > 0 && kr_count < 30,
|
||||
"KR ~10 tokens expected, got {kr_count}"
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
[package]
|
||||
name = "kebab-normalize"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "Lift parser output (kb-parse-types) into kb-core::CanonicalDocument with deterministic IDs (§3.4, §4.2, §4.3)"
|
||||
|
||||
[dependencies]
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
kebab-parse-types = { path = "../kebab-parse-types" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
unicode-normalization = "0.1"
|
||||
time = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# kb-parse-md is permitted as a *dev*-dependency only — used by the
|
||||
# integration snapshot test to drive a fixture through the real parser.
|
||||
# Forbidden as a regular dep per design §8 (kb-normalize must not depend
|
||||
# on any specific parser); `cargo tree -p kb-normalize --depth 1` (the
|
||||
# default scope, excluding dev-deps) confirms this.
|
||||
kebab-parse-md = { path = "../kebab-parse-md" }
|
||||
serde_json = { workspace = true }
|
||||
@@ -27,3 +27,6 @@ tree-sitter-cpp = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -310,7 +310,7 @@ fn build_blocks(
|
||||
// If there is only glue (no real unit) the single pushed "<top-level>"
|
||||
// label should be "<module>" — rename it now.
|
||||
if !has_real_unit {
|
||||
for (sym, _, _, _) in units.iter_mut() {
|
||||
for (sym, _, _, _) in &mut units {
|
||||
if sym == "<top-level>" {
|
||||
*sym = "<module>".to_string();
|
||||
}
|
||||
@@ -329,7 +329,7 @@ fn build_blocks(
|
||||
lang: Some("c".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
@@ -704,11 +704,11 @@ void print_result(int v) {
|
||||
|
||||
#[test]
|
||||
fn c_extractor_deterministic_across_runs() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
struct Node { int val; };
|
||||
int sum(int a, int b) { return a + b; }
|
||||
void noop(void) {}
|
||||
"#;
|
||||
";
|
||||
let a = tests_support::extract_c(src, "x/det.c");
|
||||
for _ in 0..20 {
|
||||
assert_eq!(
|
||||
|
||||
@@ -224,7 +224,7 @@ fn build_blocks_top(
|
||||
units.push(("<module>".to_string(), 1, total.max(1), false));
|
||||
}
|
||||
if !has_real_unit {
|
||||
for (sym, _, _, _) in units.iter_mut() {
|
||||
for (sym, _, _, _) in &mut units {
|
||||
if sym == "<top-level>" {
|
||||
*sym = "<module>".to_string();
|
||||
}
|
||||
@@ -243,7 +243,7 @@ fn build_blocks_top(
|
||||
lang: Some("cpp".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
@@ -696,7 +696,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn namespace_and_class() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
namespace ns {
|
||||
class Foo {
|
||||
public:
|
||||
@@ -706,7 +706,7 @@ namespace ns {
|
||||
int operator+(const Foo& o) { return 0; }
|
||||
};
|
||||
}
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(s.iter().any(|x| x == "ns::Foo"), "ns::Foo missing: {s:?}");
|
||||
@@ -718,11 +718,11 @@ namespace ns {
|
||||
|
||||
#[test]
|
||||
fn anonymous_namespace() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
namespace {
|
||||
void hidden_fn() {}
|
||||
}
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(
|
||||
@@ -733,11 +733,11 @@ namespace {
|
||||
|
||||
#[test]
|
||||
fn nested_namespace_specifier() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
namespace outer::inner {
|
||||
void fn_in_nested() {}
|
||||
}
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(
|
||||
@@ -748,9 +748,9 @@ namespace outer::inner {
|
||||
|
||||
#[test]
|
||||
fn out_of_class_method_def() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
void ns::Foo::method() { }
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(
|
||||
@@ -761,7 +761,7 @@ void ns::Foo::method() { }
|
||||
|
||||
#[test]
|
||||
fn template_declaration() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
template<typename T>
|
||||
class Bar {
|
||||
void tmpl_method() {}
|
||||
@@ -769,7 +769,7 @@ class Bar {
|
||||
|
||||
template<typename T>
|
||||
void tmpl_free_fn(T x) {}
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(s.iter().any(|x| x == "Bar"), "Bar class missing: {s:?}");
|
||||
@@ -785,12 +785,12 @@ void tmpl_free_fn(T x) {}
|
||||
|
||||
#[test]
|
||||
fn enum_and_concept() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
enum class Color { Red, Green };
|
||||
|
||||
template<typename T>
|
||||
concept Printable = requires(T t) { t.print(); };
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(s.iter().any(|x| x == "Color"), "Color missing: {s:?}");
|
||||
@@ -813,11 +813,11 @@ extern "C" {
|
||||
|
||||
#[test]
|
||||
fn conversion_operator() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
class Foo {
|
||||
operator bool() const { return true; }
|
||||
};
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(
|
||||
@@ -852,11 +852,11 @@ class Foo {
|
||||
|
||||
#[test]
|
||||
fn ref_returning_operator() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
class Foo {
|
||||
Foo& operator=(const Foo& o) { return *this; }
|
||||
};
|
||||
"#;
|
||||
";
|
||||
let doc = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
let s = syms(&doc);
|
||||
assert!(
|
||||
@@ -867,14 +867,14 @@ class Foo {
|
||||
|
||||
#[test]
|
||||
fn deterministic_across_runs() {
|
||||
let src = r#"
|
||||
let src = r"
|
||||
namespace ns {
|
||||
class Foo {
|
||||
void method() {}
|
||||
};
|
||||
}
|
||||
void free_fn() {}
|
||||
"#;
|
||||
";
|
||||
let a = tests_support::extract_cpp(src, "x/foo.cpp");
|
||||
for _ in 0..20 {
|
||||
assert_eq!(tests_support::extract_cpp(src, "x/foo.cpp").blocks, a.blocks);
|
||||
|
||||
@@ -315,7 +315,7 @@ fn build_blocks(
|
||||
// mod-prefix-agnostic.
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
if !*is_real && sym.ends_with("<module>") {
|
||||
let pre = &sym[..sym.len() - "<module>".len()];
|
||||
*sym = format!("{pre}<top-level>");
|
||||
@@ -335,7 +335,7 @@ fn build_blocks(
|
||||
lang: Some("go".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -248,7 +248,7 @@ fn build_blocks(
|
||||
// post-pass as 1B / 1C-Go).
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
if !*is_real && sym.ends_with("<module>") {
|
||||
let pre = &sym[..sym.len() - "<module>".len()];
|
||||
*sym = format!("{pre}<top-level>");
|
||||
@@ -268,7 +268,7 @@ fn build_blocks(
|
||||
lang: Some("java".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -293,7 +293,7 @@ fn build_blocks(
|
||||
let inner_kind = inner.kind();
|
||||
match inner_kind {
|
||||
"function_declaration" | "class_declaration" => {
|
||||
let name_opt = name_text(&inner, src).map(|s| s.to_string());
|
||||
let name_opt = name_text(&inner, src).map(std::string::ToString::to_string);
|
||||
if let Some(name) = name_opt {
|
||||
glue.retain(|(_, gs, _)| *gs < outer_s);
|
||||
flush_glue(glue, units, mod_prefix, mod_path);
|
||||
@@ -332,7 +332,7 @@ fn build_blocks(
|
||||
| "function_declaration"
|
||||
| "class"
|
||||
| "class_declaration" => {
|
||||
let name_opt = name_text(&value, src).map(|s| s.to_string());
|
||||
let name_opt = name_text(&value, src).map(std::string::ToString::to_string);
|
||||
let leaf =
|
||||
name_opt.as_deref().unwrap_or("default").to_string();
|
||||
glue.retain(|(_, gs, _)| *gs < outer_s);
|
||||
@@ -402,7 +402,7 @@ fn build_blocks(
|
||||
// post-pass as 1A Gap 1 / Python / TS).
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
if !*is_real && sym.ends_with("<module>") {
|
||||
let pre = &sym[..sym.len() - "<module>".len()];
|
||||
*sym = format!("{pre}<top-level>");
|
||||
@@ -422,7 +422,7 @@ fn build_blocks(
|
||||
lang: Some("javascript".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -290,7 +290,7 @@ fn build_blocks(
|
||||
// post-pass as 1B / 1C-Go / Java).
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
if !*is_real && sym.ends_with("<module>") {
|
||||
let pre = &sym[..sym.len() - "<module>".len()];
|
||||
*sym = format!("{pre}<top-level>");
|
||||
@@ -310,7 +310,7 @@ fn build_blocks(
|
||||
lang: Some("kotlin".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -1,69 +1,6 @@
|
||||
//! Canonical extension → language identifier mapping (spec §3.5).
|
||||
//!
|
||||
//! Lowercase canonical identifiers, matching tree-sitter parser conventions:
|
||||
//! `rust`, `python`, `typescript`, `javascript`, `go`, `java`, `kotlin`, `c`,
|
||||
//! `cpp`, `yaml`, `toml`, `json`, `shell`, `make`, `dockerfile`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Returns the canonical language identifier for a given file path, or
|
||||
/// `None` if the extension / filename is not recognized.
|
||||
///
|
||||
/// Matching priority:
|
||||
/// 1. Tier 1 basename exact match (e.g. `Dockerfile`, `Makefile`)
|
||||
/// 2. Tier 2 basename match (e.g. `Cargo.toml`, `package.json`, `build.gradle`)
|
||||
/// 3. Tier 2 `Dockerfile.*` prefix variant
|
||||
/// 4. Tier 1 + Tier 2 extension fallback (lowercase)
|
||||
pub fn code_lang_for_path(path: &Path) -> Option<&'static str> {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
// Tier 1 basename exact match
|
||||
match name {
|
||||
"Dockerfile" => return Some("dockerfile"),
|
||||
"Makefile" | "GNUmakefile" => return Some("make"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Tier 2 basename match (configuration / manifest files)
|
||||
match name {
|
||||
"Cargo.toml" | "pyproject.toml" => return Some("toml"),
|
||||
"package.json" | "tsconfig.json" => return Some("json"),
|
||||
"go.mod" => return Some("go-mod"),
|
||||
"pom.xml" => return Some("xml"),
|
||||
"build.gradle" => return Some("groovy"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Tier 2: `Dockerfile.*` prefix variant (e.g. `Dockerfile.dev`, `Dockerfile.prod`)
|
||||
if name.starts_with("Dockerfile.") && name.len() > "Dockerfile.".len() {
|
||||
return Some("dockerfile");
|
||||
}
|
||||
}
|
||||
|
||||
// Extension fallback (Tier 1 + Tier 2)
|
||||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
// Tier 1 extensions
|
||||
"rs" => Some("rust"),
|
||||
"py" | "pyi" => Some("python"),
|
||||
"ts" | "tsx" | "mts" | "cts" => Some("typescript"),
|
||||
"js" | "mjs" | "cjs" | "jsx" => Some("javascript"),
|
||||
"go" => Some("go"),
|
||||
"java" => Some("java"),
|
||||
"kt" | "kts" => Some("kotlin"),
|
||||
"c" | "h" => Some("c"),
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => Some("cpp"),
|
||||
"sh" | "bash" | "zsh" => Some("shell"),
|
||||
"mk" => Some("make"),
|
||||
// Tier 2 extensions
|
||||
"yaml" | "yml" => Some("yaml"),
|
||||
"toml" => Some("toml"),
|
||||
"json" => Some("json"),
|
||||
"xml" => Some("xml"),
|
||||
"dockerfile" => Some("dockerfile"),
|
||||
"gradle" => Some("groovy"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
//! Workspace-relative path → module-path conversion for P10-1B AST extractors
|
||||
//! (Python dotted form / TS+JS slash form). 본 module 의 `code_lang_for_path`
|
||||
//! 는 v0.18.0+ 부터 `kebab-source-fs::code_meta` 로 이동.
|
||||
|
||||
/// p10-1B: workspace-relative Python file path → dotted module-path prefix.
|
||||
/// See plan §Task C for the exact rules + tasks/p10/p10-1b for the §3.4
|
||||
@@ -142,28 +79,4 @@ mod tests {
|
||||
assert_eq!(module_path_for_tsjs("a/b/c.ts"), "a/b/c");
|
||||
assert_eq!(module_path_for_tsjs("packages/x/src/Foo.ts"), "packages/x/src/Foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier2_basename_takes_precedence_over_extension() {
|
||||
assert_eq!(code_lang_for_path(Path::new("Dockerfile")), Some("dockerfile"));
|
||||
assert_eq!(code_lang_for_path(Path::new("foo/Dockerfile.dev")), Some("dockerfile"));
|
||||
assert_eq!(code_lang_for_path(Path::new("myapp.dockerfile")), Some("dockerfile"));
|
||||
assert_eq!(code_lang_for_path(Path::new("repo/Cargo.toml")), Some("toml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("pyproject.toml")), Some("toml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("repo/package.json")), Some("json"));
|
||||
assert_eq!(code_lang_for_path(Path::new("tsconfig.json")), Some("json"));
|
||||
assert_eq!(code_lang_for_path(Path::new("go.mod")), Some("go-mod"));
|
||||
assert_eq!(code_lang_for_path(Path::new("pom.xml")), Some("xml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("build.gradle")), Some("groovy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier2_extension_fallback() {
|
||||
assert_eq!(code_lang_for_path(Path::new("k8s/deploy.yaml")), Some("yaml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("k8s/deploy.yml")), Some("yaml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("foo/bar.toml")), Some("toml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("foo/bar.json")), Some("json"));
|
||||
assert_eq!(code_lang_for_path(Path::new("foo/bar.xml")), Some("xml"));
|
||||
assert_eq!(code_lang_for_path(Path::new("foo/bar.gradle")), Some("groovy"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
//! `kebab-parse-code` — language-aware parsing for code corpora.
|
||||
//!
|
||||
//! Phase 1A-1 ships infrastructure only:
|
||||
//! Repo metadata (`detect_repo`) + per-language AST extractors (Rust = P10-1A-2, Python/TS/JS = P10-1B, Go = P10-1C-Go, Java+Kotlin = P10-1C-JK, C+C++ = P10-1D).
|
||||
//!
|
||||
//! - [`lang::code_lang_for_path`] — extension → language identifier.
|
||||
//! - [`repo::detect_repo`] — `.git/` walk-up → repo / branch / commit metadata.
|
||||
//! - [`skip::is_generated_file`] / [`skip::is_oversized`] — pre-ingest skip
|
||||
//! helpers consulted by `kebab-source-fs`.
|
||||
//! - [`skip::BUILTIN_BLACKLIST`] — 6-entry safety-net pattern list.
|
||||
//! lang detect (`code_lang_for_path`) + pre-ingest skip helpers (`is_generated_file`, `is_oversized`, `BUILTIN_BLACKLIST`) 는 v0.18.0+ 부터 `kebab-source-fs::code_meta` 로 이동 — refactor 2026-05-26.
|
||||
//!
|
||||
//! Per-language parser modules (`rust`, `python`, `typescript`, …) land in
|
||||
//! later phases (1A-2 onwards). The crate boundary follows other
|
||||
//! `kebab-parse-*` crates per design §8: must NOT depend on store / embed
|
||||
//! / llm / rag.
|
||||
//! 본 crate 의 boundary 는 design §8 — store / embed / llm / rag / UI 의존 금지.
|
||||
|
||||
pub mod c;
|
||||
pub mod cpp;
|
||||
@@ -24,7 +17,6 @@ pub mod python;
|
||||
pub mod repo;
|
||||
pub mod rust;
|
||||
pub(crate) mod scaffold;
|
||||
pub mod skip;
|
||||
pub mod typescript;
|
||||
|
||||
pub use c::{PARSER_VERSION as C_PARSER_VERSION, CAstExtractor};
|
||||
@@ -33,9 +25,8 @@ pub use go::{PARSER_VERSION as GO_PARSER_VERSION, GoAstExtractor};
|
||||
pub use java::{PARSER_VERSION as JAVA_PARSER_VERSION, JavaAstExtractor};
|
||||
pub use javascript::{PARSER_VERSION as JS_PARSER_VERSION, JavascriptAstExtractor};
|
||||
pub use kotlin::{PARSER_VERSION as KOTLIN_PARSER_VERSION, KotlinAstExtractor};
|
||||
pub use lang::{code_lang_for_path, module_path_for_python, module_path_for_tsjs};
|
||||
pub use lang::{module_path_for_python, module_path_for_tsjs};
|
||||
pub use python::{PARSER_VERSION as PYTHON_PARSER_VERSION, PythonAstExtractor};
|
||||
pub use repo::{RepoMeta, detect_repo};
|
||||
pub use rust::{PARSER_VERSION as RUST_PARSER_VERSION, RustAstExtractor};
|
||||
pub use skip::{BUILTIN_BLACKLIST, is_generated_file, is_oversized};
|
||||
pub use typescript::{PARSER_VERSION as TS_PARSER_VERSION, TypescriptAstExtractor};
|
||||
|
||||
@@ -333,7 +333,7 @@ fn build_blocks(
|
||||
// future-proofed) still demotes correctly.
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
if !*is_real && sym.ends_with("<module>") {
|
||||
let pre = &sym[..sym.len() - "<module>".len()];
|
||||
*sym = format!("{pre}<top-level>");
|
||||
@@ -353,7 +353,7 @@ fn build_blocks(
|
||||
lang: Some("python".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -336,7 +336,7 @@ fn build_blocks(
|
||||
// group is `<top-level>`, even a pure mod-decl group.
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
// Match on the *suffix*: a glue group may now carry a module
|
||||
// prefix (`inner::<module>`), so demote any `…<module>` to the
|
||||
// same-prefixed `…<top-level>` rather than only the bare form.
|
||||
@@ -359,7 +359,7 @@ fn build_blocks(
|
||||
lang: Some("rust".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Pre-ingest skip helpers (spec §5.2 + §5.3 + §5.4).
|
||||
//!
|
||||
//! - [`BUILTIN_BLACKLIST`] — 6 gitignore-style patterns universal across
|
||||
//! ecosystems. Source of truth: spec §5.2.
|
||||
//! - [`is_generated_file`] — reads first ~512 bytes, checks for 7
|
||||
//! case-insensitive markers.
|
||||
//! - [`is_oversized`] — byte cap then line cap.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// 6 built-in gitignore-style patterns. Applied in addition to `.gitignore`
|
||||
/// + `.kebabignore`. User can override via `.kebabignore` negation
|
||||
/// (`!pattern`).
|
||||
pub const BUILTIN_BLACKLIST: &[&str] = &[
|
||||
"**/node_modules/**",
|
||||
"**/target/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
"**/env/**",
|
||||
];
|
||||
|
||||
/// Read first 512 bytes, check for any of 7 case-insensitive generated-file
|
||||
/// markers. Returns Ok(true) on match, Ok(false) otherwise.
|
||||
pub fn is_generated_file(path: &Path) -> Result<bool> {
|
||||
let mut buf = [0u8; 512];
|
||||
let mut f = File::open(path)?;
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let head = std::str::from_utf8(&buf[..n]).unwrap_or("");
|
||||
let lower: String = head.lines().take(10).collect::<Vec<_>>().join("\n").to_ascii_lowercase();
|
||||
Ok(
|
||||
lower.contains("@generated")
|
||||
|| lower.contains("code generated by")
|
||||
|| lower.contains("do not edit")
|
||||
|| lower.contains("do not modify")
|
||||
|| lower.contains("automatically generated")
|
||||
|| lower.contains("auto-generated")
|
||||
|| lower.contains("autogenerated"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if `path` exceeds `max_bytes` or `max_lines`. Byte cap first
|
||||
/// (cheap), then line cap (streaming with early exit).
|
||||
pub fn is_oversized(path: &Path, max_bytes: u64, max_lines: u32) -> Result<bool> {
|
||||
let meta = std::fs::metadata(path)?;
|
||||
if meta.len() > max_bytes {
|
||||
return Ok(true);
|
||||
}
|
||||
let reader = BufReader::new(File::open(path)?);
|
||||
let mut count: u32 = 0;
|
||||
for line in reader.lines() {
|
||||
let _ = line?;
|
||||
count = count.saturating_add(1);
|
||||
if count > max_lines {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -326,7 +326,7 @@ fn build_blocks(
|
||||
| "interface_declaration"
|
||||
| "type_alias_declaration"
|
||||
| "enum_declaration" => {
|
||||
let name_opt = name_text(&inner, src).map(|s| s.to_string());
|
||||
let name_opt = name_text(&inner, src).map(std::string::ToString::to_string);
|
||||
if let Some(name) = name_opt {
|
||||
glue.retain(|(_, gs, _)| *gs < outer_s);
|
||||
flush_glue(glue, units, mod_prefix, mod_path);
|
||||
@@ -376,7 +376,7 @@ fn build_blocks(
|
||||
| "class"
|
||||
| "class_declaration" => {
|
||||
let name_opt =
|
||||
name_text(&value, src).map(|s| s.to_string());
|
||||
name_text(&value, src).map(std::string::ToString::to_string);
|
||||
let leaf = name_opt
|
||||
.as_deref()
|
||||
.unwrap_or("default")
|
||||
@@ -461,7 +461,7 @@ fn build_blocks(
|
||||
// post-pass as 1A Gap 1 / Python).
|
||||
let has_real_unit = units.iter().any(|(_, _, _, is_real)| *is_real);
|
||||
if has_real_unit {
|
||||
for (sym, _, _, is_real) in units.iter_mut() {
|
||||
for (sym, _, _, is_real) in &mut units {
|
||||
if !*is_real && sym.ends_with("<module>") {
|
||||
let pre = &sym[..sym.len() - "<module>".len()];
|
||||
*sym = format!("{pre}<top-level>");
|
||||
@@ -481,7 +481,7 @@ fn build_blocks(
|
||||
lang: Some("typescript".to_string()),
|
||||
};
|
||||
let block_id = id_for_block(doc_id, "code", &[], ordinal as u32, &span);
|
||||
let code = lines[(line_start as usize - 1)..=(line_end as usize - 1)].join("\n");
|
||||
let code = lines[(line_start as usize - 1)..(line_end as usize)].join("\n");
|
||||
blocks.push(Block::Code(CodeBlock {
|
||||
common: CommonBlock {
|
||||
block_id,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
use kebab_parse_code::code_lang_for_path;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn known_extensions_map_to_canonical_identifiers() {
|
||||
let cases = [
|
||||
("foo.rs", Some("rust")),
|
||||
("foo.py", Some("python")),
|
||||
("foo.pyi", Some("python")),
|
||||
("foo.ts", Some("typescript")),
|
||||
("foo.tsx", Some("typescript")),
|
||||
("foo.mts", Some("typescript")), // ESM TS — same grammar
|
||||
("foo.cts", Some("typescript")), // CommonJS TS — same grammar
|
||||
("foo.js", Some("javascript")),
|
||||
("foo.mjs", Some("javascript")),
|
||||
("foo.cjs", Some("javascript")),
|
||||
("foo.jsx", Some("javascript")),
|
||||
("foo.go", Some("go")),
|
||||
("foo.java", Some("java")),
|
||||
("foo.kt", Some("kotlin")),
|
||||
("foo.kts", Some("kotlin")),
|
||||
("foo.c", Some("c")),
|
||||
("foo.h", Some("c")),
|
||||
("foo.cpp", Some("cpp")),
|
||||
("foo.cc", Some("cpp")),
|
||||
("foo.cxx", Some("cpp")),
|
||||
("foo.hpp", Some("cpp")),
|
||||
("foo.hh", Some("cpp")),
|
||||
("foo.hxx", Some("cpp")),
|
||||
("foo.yaml", Some("yaml")),
|
||||
("foo.yml", Some("yaml")),
|
||||
("foo.toml", Some("toml")),
|
||||
("foo.json", Some("json")),
|
||||
("foo.sh", Some("shell")),
|
||||
("foo.bash", Some("shell")),
|
||||
("foo.zsh", Some("shell")),
|
||||
("foo.mk", Some("make")),
|
||||
];
|
||||
for (path, expected) in cases {
|
||||
assert_eq!(
|
||||
code_lang_for_path(Path::new(path)),
|
||||
expected,
|
||||
"path = {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_filenames_map_to_identifiers() {
|
||||
assert_eq!(code_lang_for_path(Path::new("Dockerfile")), Some("dockerfile"));
|
||||
assert_eq!(code_lang_for_path(Path::new("foo.dockerfile")), Some("dockerfile"));
|
||||
assert_eq!(code_lang_for_path(Path::new("Makefile")), Some("make"));
|
||||
assert_eq!(code_lang_for_path(Path::new("GNUmakefile")), Some("make"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_extension_returns_none() {
|
||||
assert_eq!(code_lang_for_path(Path::new("foo.docx")), None);
|
||||
assert_eq!(code_lang_for_path(Path::new("foo")), None);
|
||||
assert_eq!(code_lang_for_path(Path::new("foo.unknown")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive() {
|
||||
assert_eq!(code_lang_for_path(Path::new("Foo.RS")), Some("rust"));
|
||||
assert_eq!(code_lang_for_path(Path::new("FOO.YAML")), Some("yaml"));
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
use kebab_parse_code::skip::{BUILTIN_BLACKLIST, is_generated_file, is_oversized};
|
||||
use std::fs;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn generated_header_markers_trigger_skip() {
|
||||
let cases = [
|
||||
"// @generated\nfn foo() {}\n",
|
||||
"// Code generated by tonic-build. DO NOT EDIT.\nfn x() {}\n",
|
||||
"/* DO NOT EDIT */\nfn x() {}\n",
|
||||
"/* do not modify */\nfn x() {}\n",
|
||||
"// AUTOMATICALLY GENERATED\nfn x() {}\n",
|
||||
"# auto-generated\ndef x(): pass\n",
|
||||
"// autogenerated\nfn x() {}\n",
|
||||
];
|
||||
for content in cases {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
fs::write(f.path(), content).unwrap();
|
||||
assert!(is_generated_file(f.path()).unwrap(), "content: {content:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_code_is_not_flagged_generated() {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
fs::write(f.path(), "fn main() {\n println!(\"hi\");\n}\n").unwrap();
|
||||
assert!(!is_generated_file(f.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_generated_returns_false_for_empty_file() {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
fs::write(f.path(), "").unwrap();
|
||||
assert!(!is_generated_file(f.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_by_bytes_returns_true() {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
let body: String = "x".repeat(300_000);
|
||||
fs::write(f.path(), &body).unwrap();
|
||||
assert!(is_oversized(f.path(), 262_144, 5_000).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_by_lines_returns_true() {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
let body: String = "x\n".repeat(6_000);
|
||||
fs::write(f.path(), &body).unwrap();
|
||||
assert!(is_oversized(f.path(), 262_144, 5_000).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_file_returns_false_for_oversize() {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
fs::write(f.path(), "fn foo() {}\n").unwrap();
|
||||
assert!(!is_oversized(f.path(), 262_144, 5_000).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_blacklist_has_exactly_six_entries() {
|
||||
assert_eq!(BUILTIN_BLACKLIST.len(), 6);
|
||||
let expected = [
|
||||
"**/node_modules/**",
|
||||
"**/target/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
"**/env/**",
|
||||
];
|
||||
for pat in expected {
|
||||
assert!(BUILTIN_BLACKLIST.contains(&pat), "missing pattern: {pat}");
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,6 @@ base64 = { workspace = true }
|
||||
# at runtime) is preserved.
|
||||
kebab-llm = { path = "../kebab-llm", features = ["mock"] }
|
||||
kebab-llm-local = { path = "../kebab-llm-local" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -198,7 +198,7 @@ pub fn apply_caption(
|
||||
/// language; everything else falls through to English.
|
||||
fn build_prompt(lang_hint: Option<&str>) -> (String, String) {
|
||||
match lang_hint {
|
||||
Some("ko") | Some("kor") => (
|
||||
Some("ko" | "kor") => (
|
||||
"이미지를 한 문장으로 객관적으로 설명한다. 추측은 피하고, \
|
||||
보이는 것만 적는다. 마크다운 / 따옴표 / 부가 설명 없이 \
|
||||
한 문장만 출력."
|
||||
|
||||
@@ -103,7 +103,7 @@ fn ascii_field(exif: &exif::Exif, tag: Tag) -> Option<String> {
|
||||
fn u32_field(exif: &exif::Exif, tag: Tag) -> Option<u32> {
|
||||
let f = exif.get_field(tag, In::PRIMARY)?;
|
||||
match &f.value {
|
||||
Value::Short(v) => v.first().map(|x| *x as u32),
|
||||
Value::Short(v) => v.first().map(|x| u32::from(*x)),
|
||||
Value::Long(v) => v.first().copied(),
|
||||
_ => None,
|
||||
}
|
||||
@@ -177,7 +177,7 @@ fn rational_to_f64(r: &exif::Rational) -> Option<f64> {
|
||||
if r.denom == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(r.num as f64 / r.denom as f64)
|
||||
Some(f64::from(r.num) / f64::from(r.denom))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user