feat(rag): fb-41 PR-3a — HopRecord wire + RagCfg multi-hop knobs
PR-3 의 분할 첫 PR. wire additive (HopRecord + HopKind + Answer.hops field) + RagCfg 의 multi_hop_* 3 노브. RAG pipeline 동작 미변경 — 모든 Answer literal 의 `hops = None`. PR-3b (후속) 가 ask_multi_hop 의 happy path 에서 dynamic decide loop 구현 + hops trace 채움. 분할 이유: 원래 PR-3 가 wire + cfg + decide loop + ScriptedLm + helper refactor + 5+ tests 단일 PR 였는데 ~1500 줄 단일 patch 가 review 부담 + 회기 위험 ↑. additive foundation 부터 ship 후 decide loop 별 PR — 사용자 결정 (2026-05-25). - `kebab_core::HopRecord` (iter, kind, sub_queries, context_chunks_added, forced_stop, llm_call_ms) + `HopKind` (Decompose / Decide / Synthesize) — wire-additive shape. - `kebab_core::Answer.hops: Option<Vec<HopRecord>>` — `#[serde(default, skip_serializing_if = "Option::is_none")]`, single-pass / refusal path 는 None, PR-3b 의 multi-hop happy path 가 Some. - `kebab_config::RagCfg` 에 3 신규 노브: - `multi_hop_max_depth: u32` (default 3) - `multi_hop_max_sub_queries_per_iter: u32` (default 5) - `multi_hop_max_pool_chunks: u32` (default 30) 3 모두 `#[serde(default)]` + env override (`KEBAB_RAG_MULTI_HOP_MAX_*`) + legacy parse 핀 (`LEGACY_PRE_TIMEOUT_TOML` 공유). - 9 Answer literal site (pipeline.rs ×6 + kebab-cli + kebab-tui tests + kebab-eval test) 에 `hops: None` 명시 추가. exhaustive field check 가 자동 guard — 빠진 site 시 compile fail. - plan 의 PR-3 단락 → PR-3a / PR-3b 분할 명시 + scope 정정. Tests (163 passing across kebab-config + kebab-core + kebab-rag): - 5 신규 multi-hop knob test (default / env override / legacy parse). - 기존 50+57+31+19+3+3 test 모두 hops:None 추가 후도 통과. Wire 영향: `answer.v1` 의 optional `hops` 필드 — `skip_serializing_ if = None` 이라 single-pass response 에 emit 안 됨. wire breaking 아님, JSON Schema 갱신은 PR-3b 또는 PR-4 (실제 emit 시점). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -531,6 +531,11 @@ impl RagPipeline {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
// p9-fb-41 Step 2 of PR-3: every Answer literal carries
|
||||
// `hops`. Single-pass + refusal paths leave it `None`;
|
||||
// only the multi-hop happy path will set `Some(...)` in
|
||||
// Step 5 once the decide loop populates a hop trace.
|
||||
hops: None,
|
||||
};
|
||||
|
||||
// Drop the moved `finish_reason` early into a tracing breadcrumb; the
|
||||
@@ -843,6 +848,11 @@ impl RagPipeline {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
// p9-fb-41 Step 2 of PR-3: every Answer literal carries
|
||||
// `hops`. Single-pass + refusal paths leave it `None`;
|
||||
// only the multi-hop happy path will set `Some(...)` in
|
||||
// Step 5 once the decide loop populates a hop trace.
|
||||
hops: None,
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
@@ -979,6 +989,11 @@ impl RagPipeline {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
// p9-fb-41 Step 2 of PR-3: every Answer literal carries
|
||||
// `hops`. Single-pass + refusal paths leave it `None`;
|
||||
// only the multi-hop happy path will set `Some(...)` in
|
||||
// Step 5 once the decide loop populates a hop trace.
|
||||
hops: None,
|
||||
};
|
||||
if let Some(sink) = &opts.stream_sink {
|
||||
let _ = sink.send(StreamEvent::Final {
|
||||
@@ -1100,6 +1115,11 @@ impl RagPipeline {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
// p9-fb-41 Step 2 of PR-3: every Answer literal carries
|
||||
// `hops`. Single-pass + refusal paths leave it `None`;
|
||||
// only the multi-hop happy path will set `Some(...)` in
|
||||
// Step 5 once the decide loop populates a hop trace.
|
||||
hops: 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");
|
||||
@@ -1182,6 +1202,11 @@ impl RagPipeline {
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
// p9-fb-41 Step 2 of PR-3: every Answer literal carries
|
||||
// `hops`. Single-pass + refusal paths leave it `None`;
|
||||
// only the multi-hop happy path will set `Some(...)` in
|
||||
// Step 5 once the decide loop populates a hop trace.
|
||||
hops: 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");
|
||||
@@ -1789,6 +1814,7 @@ mod stream_event_serde_tests {
|
||||
created_at: datetime!(2026-05-09 12:00:00 UTC),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
hops: None,
|
||||
};
|
||||
let ev = StreamEvent::Final { answer };
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user