feat(rag): fb-41 PR-9c-1 — core types + wire scaffolding (NLI verification)

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>
This commit is contained in:
2026-05-25 23:27:36 +00:00
parent 79ad6e376f
commit 546c1564b0
15 changed files with 479 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
kebab-search = { path = "../kebab-search" }
kebab-llm = { path = "../kebab-llm" }
kebab-nli = { path = "../kebab-nli" }
kebab-store-sqlite = { path = "../kebab-store-sqlite" }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -197,6 +197,14 @@ pub struct RagPipeline {
retriever: Arc<dyn Retriever>,
llm: Arc<dyn LanguageModel>,
docs: Arc<SqliteStore>,
/// p9-fb-41 PR-9c-1: optional NLI verifier injected via
/// [`Self::with_verifier`]. Not yet read — PR-9c-2 wires the
/// `ask_multi_hop` step 8.5 (post-synthesize gate) that consumes
/// it. Until then the field is `#[allow(dead_code)]`; the
/// attribute is removed in the PR-9c-2 commit that adds the
/// read site so leftover dead code can never sneak in.
#[allow(dead_code)]
verifier: Option<Arc<dyn kebab_nli::NliVerifier>>,
}
impl RagPipeline {
@@ -204,6 +212,10 @@ impl RagPipeline {
/// validated here — callers are expected to pass already-built
/// `Arc`'d trait objects (kb-app builds them from config; tests
/// inject mocks).
///
/// The NLI verifier is NOT a constructor arg — it threads in via
/// the [`Self::with_verifier`] builder so the historical 4-arg
/// signature stays stable across the PR-9c-1 surface bump.
pub fn new(
config: kebab_config::Config,
retriever: Arc<dyn Retriever>,
@@ -215,9 +227,26 @@ impl RagPipeline {
retriever,
llm,
docs,
verifier: None,
}
}
/// p9-fb-41 PR-9c-1: inject the post-synthesize NLI verifier.
/// Caller (kebab-app facade, PR-9c-2) builds an
/// `Arc<OnnxNliVerifier>` from `cfg.models.nli` when
/// `cfg.rag.nli_threshold > 0`, then chains
/// `RagPipeline::new(...).with_verifier(v)`.
///
/// Currently unused — PR-9c-2 wires the read site (step 8.5 of
/// `ask_multi_hop`). `#[allow(dead_code)]` survives only until
/// that PR's commit, which removes it together with adding the
/// hook that reads `self.verifier`.
#[allow(dead_code)]
pub fn with_verifier(mut self, v: Arc<dyn kebab_nli::NliVerifier>) -> Self {
self.verifier = Some(v);
self
}
/// p9-fb-15: convenience for multi-turn ask. Stuffs `history`,
/// `conversation_id`, `turn_index` into a fresh `AskOpts` (built
/// from `opts.mode` + carried-through knobs) and forwards to
@@ -537,6 +566,10 @@ impl RagPipeline {
// only the multi-hop happy path will set `Some(...)` in
// Step 5 once the decide loop populates a hop trace.
hops: None,
// p9-fb-41 PR-9c-1: surface-only field — single-pass
// never verifies (multi-hop step 8.5 is the only path
// that stamps `Some(...)`, wired in PR-9c-2).
verification: None,
};
// Drop the moved `finish_reason` early into a tracing breadcrumb; the
@@ -1068,6 +1101,11 @@ impl RagPipeline {
// currently lose the trace (cleanup deferred — would
// require widening helper signatures, PR-3b-ii / follow-up).
hops: Some(hops),
// p9-fb-41 PR-9c-1: surface-only field — PR-9c-2 wires
// step 8.5 between citation-validate and Answer-build to
// stamp this with the actual NLI score when
// `cfg.rag.nli_threshold > 0`. Until then, stays None.
verification: None,
};
tracing::debug!(
@@ -1276,6 +1314,9 @@ impl RagPipeline {
// only the multi-hop happy path will set `Some(...)` in
// Step 5 once the decide loop populates a hop trace.
hops: None,
// p9-fb-41 PR-9c-1: surface-only field — decompose-failure
// refusal never reaches the NLI gate.
verification: None,
};
if let Some(sink) = &opts.stream_sink {
let _ = sink.send(StreamEvent::Final {
@@ -1411,6 +1452,9 @@ impl RagPipeline {
// stays `skip_serializing_if = None`, so single-pass
// wire output is unchanged.
hops,
// p9-fb-41 PR-9c-1: NoChunks refusal never reaches the
// synthesize / NLI gate.
verification: None,
};
if let Err(e) = self.docs.put_answer(&answer, query, None) {
tracing::warn!(target: "kebab-rag", error = %e, "kb-rag: put_answer (NoChunks) failed");
@@ -1501,6 +1545,9 @@ impl RagPipeline {
turn_index: opts.turn_index,
// p9-fb-41 PR-3b-ii: see refuse_no_chunks' identical comment.
hops,
// p9-fb-41 PR-9c-1: ScoreGate refusal never reaches the
// synthesize / NLI gate.
verification: None,
};
if let Err(e) = self.docs.put_answer(&answer, query, None) {
tracing::warn!(target: "kebab-rag", error = %e, "kb-rag: put_answer (ScoreGate) failed");
@@ -2134,6 +2181,7 @@ mod stream_event_serde_tests {
conversation_id: None,
turn_index: None,
hops: None,
verification: None,
};
let ev = StreamEvent::Final { answer };
let v = serde_json::to_value(&ev).unwrap();