feat(rag): multi-turn ask — Turn + ask_with_history + token budget (p9-fb-15) #60
Reference in New Issue
Block a user
Delete Branch "feat/p9-fb-15-rag-multiturn"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Spec PR #59 의 §3.8 multi-turn behaviour 구현. RAG facade 가 prior turns 받아 prompt 에 prepend, retrieval query 확장,
Answer에conversation_id/turn_index채움. p9-fb-16 (TUI conversation UI) + p9-fb-17/18 (V004 storage + CLI session) 가 같은 facade 위에 build. 도그푸딩 항목 13 (꼬리 물기) 의 backend layer.변경
kebab-core::Answerskip_serializing_if로 single-shot answer 의 wire output 변경 0 — 기존 외부 wrapper (Claude Code skill / MCP) 영향 없음.kebab-rag::AskOpts+RagPipeline::ask_with_historyprompt 빌드 + retrieval
expand_query_with_history:history.last().answer첫 200 chars 를 query 에 concat — retriever 가 이전 turn 의 named entity ("Y" in "Y vs X" 의 차이?) 도 잡아냄.serialize_history+remaining_history_budget_chars: spec §3.8 priority —system+question필수,retrieved chunks가pack_contextbudget 으로 fit, 남은 char budget (max_context_tokens * 4) 안에서 newest-first 로 history prepend, oldest drop.[이전 대화]블록 형식:Q: ...\nA: ...\n반복, chronological order (oldest at top, newest just above current question).기존 caller (single-shot 동작 동일)
kebab-cliCmd::Ask:history=Vec::new(), conversation_id=None, turn_index=None명시. multi-turn CLI 는 p9-fb-18.kebab-tuiask spawn: 동일 (multi-turn UI = p9-fb-16).kebab-evalrunner: golden eval 은 single-shot per query.kebab-store-sqliterefusal_reason_label가LlmStreamAborted→\"llm_stream_aborted\".Test plan
cargo test -p kebab-rag --lib— 12 PASS (9 신규: expand_query 4 / serialize_history 3 / remaining_budget 2 + 기존 3).cargo test -p kebab-core -p kebab-eval -p kebab-store-sqlite— 회귀 0.cargo clippy --workspace --all-targets -- -D warningsclean.후속
tasks/p9/p9-fb-15-rag-multi-turn-core.mdstatusin_progress→completed한 줄 commit.RagPipeline::ask_with_history호출하는 worker thread spawn.회차 1 — yagni nit 1건 + 칭찬 4건.
핵심 actionable:
AskOpts::single_shot(mode)helper 가 dead code. 본 PR 안 caller 0 — 모든 사���처 (5 site) 가 struct literal. CLAUDE.md "Don't add abstractions beyond what the task requires" 룰. 권장: helper 제거 (caller 들은 struct literal 그대로).총평: spec PR #59 의 §3.8 multi-turn behaviour 를 정확히 구현.
skip_serializing_if로 single-shot wire 변경 0 / refuse path 3 곳 모두 conversation_id 채움 /serialize_history의 newest-first fitness + chronological output 패턴 / char-based budget 보수성 — 4 칭찬. 위 nit 1건만 정리하면 머지.(칭찬)
#[serde(default, skip_serializing_if = "Option::is_none")]— single-shot answer 의 wire JSON 에 두 새 field 가 안 나타남. spec PR #59 의 "기존 외부 wrapper 영향 없음" 약속을 코드 레벨에서 강제. multi-turn 모르는 Claude Code skill / MCP 가 그대로 동작 + multi-turn answer 만 새 field 노출 — 두 모드의 wire output 자연 분리.(yagni / dead helper)
AskOpts::single_shot(mode)가 정의됐지만 본 PR 안에서 호출 site 0 — 모든 caller (CLI / TUI / eval / test / kebab-app smoke) 가 직접 struct literalAskOpts { ..., history: Vec::new(), conversation_id: None, turn_index: None }사용. 미래 entry slot 의도지만 caller 들이 struct 직접 사용 → helper 가 dead code 가까움.Why: CLAUDE.md 의
Don't add abstractions beyond what the task requires룰. helper 가 진짜 가치 있다면 본 PR 의 caller 들도 같이 이걸로 migrate 해야 명확. 그렇지 않으면 미래 reader 가 "struct literal 과 single_shot() 중 어느 것이 canonical?" 의문 생김.How to apply 두 가지 중:
AskOpts::single_shot(mode)로 migrate + 추가 옵션은 mut + field 갱신. 한 패턴.권장: 1. 5 site 의 struct literal 가 명시적 + 새 caller 가 IDE 자동완성으로 모든 field 보임 — single_shot helper 의 가치 ↓.
(칭찬) Refuse path 3 곳 (정상 / NoChunks / ScoreGate) 모두
conversation_id: opts.conversation_id.clone()채움 — refuse 시도 multi-turn 의 한 turn 으로 SQLiteanswers에 기록. 다음 turn 의 history 에 "이전에 거절했음" 컨텍스트로 들어감 (LLM 이 같은 거절 반복 회피). single-shot 처럼 None 으로 비우면 conversation 의 "실패한 turn" 흔적이 사라져 reconstruct 어려움.(칭찬) char-based budget (
max_context_tokens * 4) — Ollama 의 실제 token count 보다 보수적 (한국어 평균 1 char = 1 token, 영어 평균 4 chars = 1 token, 평균 2~3 chars/token). 4 chars/token 은 영어 기준 conservative — 한국어 쓰면 더 작은 budget 사용 → history 더 많이 drop. 보수적이라 LM context overflow 위험 ↓. token-precise budget 은tiktoken-rs같은 dep 필요 — out of scope.(칭찬)
serialize_history의 reverse iter + 최종 reverse 패턴이 newest-first fitness check + chronological output 둘 다 한 함수에. 알고리즘:history.iter().rev()— newest 부터 budget 검사.included_rev.iter().rev()— chronological order 로 출력.결과: budget 안에서 최대한 많은 newest turn 을 포함, LM 은 oldest→newest 자연 순서로 읽음. spec §3.8 "newest 우선 보존, oldest drop" priority 정확 매핑.
회차 2 — yagni helper (AskOpts::single_shot) 제거. CLAUDE.md '추상화 최소' 룰 정합. APPROVE.