Surface-only PR (no behavior wiring — that's PR-9c-2):
- kebab-core: RefusalReason::NliVerificationFailed + NliModelUnavailable (serde rename_all="snake_case", wire = identical strings).
- kebab-core: Answer.verification: Option<VerificationSummary> field (additive minor wire — pre-v0.18 reader 무영향).
- kebab-core: VerificationSummary { nli_score: f32, nli_threshold: f32, nli_passed: bool } struct + lib.rs 재-export.
- kebab-config: NliCfg { model, provider } + ModelsCfg.nli (default Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7).
- kebab-config: RagCfg.nli_threshold: f32 (default 0.0 = disabled, spec §2.6 single gate).
- kebab-config: env override KEBAB_MODELS_NLI_MODEL/PROVIDER + KEBAB_RAG_NLI_THRESHOLD (parse 실패 시 tracing::warn + default 유지).
- kebab-rag: RagPipeline.verifier: Option<Arc<dyn NliVerifier>> field + with_verifier builder (모두 #[allow(dead_code)] — PR-9c-2 의 step 8.5 hook 가 활성화 시 제거). RagPipeline::new signature 유지 (round-2 NEW-M1 Option B).
- kebab-rag: Cargo.toml 에 kebab-nli path 의존 추가.
- kebab-store-sqlite + kebab-tui: 두 신규 RefusalReason variant 에 대한 exhaustive match arm 추가 (snake_case label / 표시 문구).
- 모든 Answer 구축 site (rag 6 + cli/tui/eval 3 fixture) 에 verification: None 추가.
- wire schemas: answer.schema.json verification field + \$defs.VerificationSummary + refusal_reason.enum 2 추가. error.schema.json code.enum + details.description 2 추가 (forward-looking reserved).
- docs/ARCHITECTURE.md: Mermaid Adapters subgraph 의 nli 노드 + rag→nli + app→nli (forward-looking) + nli→config edges. nli→core edge 는 skip (kebab-nli/Cargo.toml direct dep 가 config 만, ARCHITECTURE 컨벤션 = direct deps only). 디렉토리 트리에 crates/kebab-nli/ 추가.
Tests: kebab-core 3 (serde rename + verification skip + struct shape) + kebab-config 6 (defaults + legacy + env + malformed env) + kebab-cli wire 5 (schema verification + enum 검증).
검증: cargo test --workspace -j 1 회귀 0 (pre-existing kebab-mcp::tools_call_ask_multi_hop flaky 1개 동일 — spec 에 명시된 known-flaky). cargo clippy --workspace --all-targets -D warnings clean.
Wire 영향: additive minor — answer.v1 의 verification optional + refusal_reason.enum 확장 + error.v1.code 확장.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
9.4 KiB
Rust
255 lines
9.4 KiB
Rust
//! 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:?}"
|
|
);
|
|
}
|