refactor: multi-turn 세션 제거 (ask --session, chat_sessions/turns) + V015 drop migration

This commit is contained in:
2026-06-24 09:43:26 +00:00
parent 470e94bb2d
commit 1d32aa645a
25 changed files with 56 additions and 1054 deletions

View File

@@ -118,8 +118,8 @@ pub struct App {
llm: OnceLock<Arc<dyn LanguageModel>>,
/// 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
/// consumed by `RagPipeline::with_verifier` on every `ask` call.
/// `None` when the gate is disabled
/// (default, threshold = 0) — multi-hop skips step 8.5 entirely
/// and single-pass never touches the verifier.
///
@@ -526,8 +526,8 @@ impl App {
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
/// p9-fb-41 PR-9c-2: shared pipeline builder used by [`Self::ask`].
/// 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.
@@ -543,8 +543,7 @@ impl App {
}
}
/// p9-fb-18: shared retriever-stack builder used by [`Self::ask`]
/// and [`Self::ask_with_session`]. Lexical mode uses the FTS5
/// Shared retriever-stack builder used by [`Self::ask`]. Lexical mode uses the FTS5
/// retriever directly; vector / hybrid require embeddings (and
/// surface the same "switch to --mode lexical" error from
/// [`Self::require_embeddings`] when disabled).
@@ -590,119 +589,6 @@ impl App {
})
}
/// p9-fb-18: ask under a persistent chat session. Loads the
/// session's prior turns (if any), runs the query through
/// `RagPipeline::ask_with_history`, then appends the new turn
/// + (auto-)creates the session row on first use.
///
/// `session_id` is caller-supplied. If the session doesn't
/// exist yet, a new `chat_sessions` row is created with title
/// derived from the first question (≤40 chars, trimmed and
/// NFC-normalized). Subsequent calls with the same
/// `session_id` extend the conversation.
///
/// The returned `Answer` carries `conversation_id = Some(
/// session_id)` and `turn_index = Some(n)` per p9-fb-15. The
/// new `chat_turns` row is committed before this method
/// returns; on persistence error, the answer is still returned
/// (don't lose the user's compute) but the error is logged so
/// the operator notices.
pub fn ask_with_session(&self, session_id: &str, query: &str, opts: AskOpts) -> Result<Answer> {
use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow};
use std::time::{SystemTime, UNIX_EPOCH};
// Load (or create) the session header.
let now_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let existing = self.sqlite.get_session(session_id)?;
let prior_turns = match &existing {
Some(_) => self.sqlite.list_turns(session_id)?,
None => Vec::new(),
};
let next_index = u32::try_from(prior_turns.len()).unwrap_or(u32::MAX);
// Build history Vec<Turn> from the persisted rows. Citations
// are decoded best-effort — a corrupted citations_json
// becomes an empty Vec rather than a panic (history is
// advisory, not authoritative).
let history: Vec<kebab_core::Turn> = prior_turns
.iter()
.map(|row| kebab_core::Turn {
question: row.question.clone(),
answer: row.answer.clone(),
citations: serde_json::from_str(&row.citations_json).unwrap_or_default(),
created_at: time::OffsetDateTime::from_unix_timestamp(row.created_at)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
})
.collect();
// 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 = self.build_pipeline(retriever, llm);
let answer =
pipeline.ask_with_history(query, history, session_id.to_string(), next_index, opts)?;
// Auto-create the session header on first use. Title from
// the first question (≤40 chars after trim).
if existing.is_none() {
let title = first_question_title(query);
let session_row = ChatSessionRow {
session_id: session_id.to_string(),
created_at: now_unix,
updated_at: now_unix,
title: Some(title),
config_snapshot_json: serde_json::json!({
"prompt_template_version": self.config.rag.prompt_template_version,
"llm.model": self.config.models.llm.model,
"max_context_tokens": self.config.rag.max_context_tokens,
})
.to_string(),
};
if let Err(e) = self.sqlite.create_session(&session_row) {
tracing::warn!(
target: "kebab-app",
error = %e,
session_id = %session_id,
"ask_with_session: create_session failed; continuing — turn append will surface a more useful error"
);
}
}
// Append the new turn. Failure is logged but does NOT mask
// the answer — the user still gets their response, the
// operator sees the persistence error in the warn log.
let turn_id = format!(
"{:032x}",
blake3_truncate(&format!("{session_id}:{next_index}")),
);
let turn_row = ChatTurnRow {
turn_id,
session_id: session_id.to_string(),
turn_index: next_index,
question: query.to_string(),
answer: answer.answer.clone(),
citations_json: serde_json::to_string(&answer.citations)
.unwrap_or_else(|_| "[]".to_string()),
created_at: now_unix,
};
if let Err(e) = self.sqlite.append_turn(&turn_row) {
tracing::warn!(
target: "kebab-app",
error = %e,
session_id = %session_id,
turn_index = next_index,
"ask_with_session: append_turn failed; answer returned regardless"
);
}
Ok(answer)
}
/// Returns `true` when the workspace has embeddings turned off
/// (`provider = "none"` or `dimensions = 0`). Lexical-only mode.
pub(crate) fn embeddings_disabled(&self) -> bool {
@@ -904,33 +790,6 @@ fn vector_index_version(embedder: &dyn Embedder) -> IndexVersion {
))
}
/// p9-fb-18: derive a chat-session title from the first question.
/// Trim, NFC, take first ~40 chars. Always non-empty (falls back
/// to `"untitled"`) — same defensive shape as kebab-normalize's
/// derive_title.
fn first_question_title(question: &str) -> String {
use unicode_normalization::UnicodeNormalization;
let nfc: String = question.trim().nfc().collect();
let truncated: String = nfc.chars().take(40).collect();
if truncated.is_empty() {
"untitled".to_string()
} else {
truncated
}
}
/// p9-fb-18: 32-hex `turn_id` derived from session_id + turn_index.
/// blake3 hash truncated to first 16 bytes; format as 32-char lowercase
/// hex so it slots into the `chat_turns.turn_id` column without
/// collision concerns under any realistic per-session turn count.
fn blake3_truncate(input: &str) -> u128 {
let hash = blake3::hash(input.as_bytes());
let bytes = hash.as_bytes();
let mut buf = [0u8; 16];
buf.copy_from_slice(&bytes[..16]);
u128::from_be_bytes(buf)
}
/// p9-fb-34: trim `s` to at most `n` Unicode scalar chars. Cheap
/// alternative to a `.chars().take(n).collect::<String>()` pattern;
/// reserves capacity proportional to UTF-8 worst case (4 bytes / char)
@@ -1191,49 +1050,6 @@ impl App {
}
}
#[cfg(test)]
mod tests {
use super::*;
/// p9-fb-18: title trims, NFC-normalizes, caps at 40 chars.
#[test]
fn first_question_title_trims_and_caps() {
assert_eq!(first_question_title(" hello "), "hello");
let long = "a".repeat(100);
assert_eq!(first_question_title(&long).chars().count(), 40);
}
/// p9-fb-18: empty / whitespace-only question falls back to
/// `"untitled"` (never returns empty).
#[test]
fn first_question_title_falls_back_to_untitled() {
assert_eq!(first_question_title(""), "untitled");
assert_eq!(first_question_title(" "), "untitled");
assert_eq!(first_question_title("\t\n"), "untitled");
}
/// p9-fb-18: korean NFD → NFC.
#[test]
fn first_question_title_nfc_normalizes_korean() {
let nfd = "\u{1100}\u{1161}".to_string(); // 가 (NFD)
let title = first_question_title(&nfd);
assert_eq!(title, "\u{AC00}", "expected NFC composed form");
}
/// p9-fb-18: blake3_truncate is deterministic and differs across
/// distinct inputs.
#[test]
fn blake3_truncate_deterministic_and_distinct() {
let a = blake3_truncate("session-x:0");
let b = blake3_truncate("session-x:0");
let c = blake3_truncate("session-x:1");
let d = blake3_truncate("session-y:0");
assert_eq!(a, b, "same input → same hash");
assert_ne!(a, c, "different turn_index → different hash");
assert_ne!(a, d, "different session_id → different hash");
}
}
#[cfg(test)]
mod tests_trace {
use super::*;

View File

@@ -3501,23 +3501,6 @@ pub fn ask_with_config(
App::open_with_config(config)?.ask(query, opts)
}
/// p9-fb-18: ask under a persistent chat session. Loads prior turns
/// from `chat_sessions[session_id]`, runs the query as a follow-up
/// (via `RagPipeline::ask_with_history`), and appends the new turn
/// — auto-creating the session header on first use. Returns an
/// `Answer` with `conversation_id = Some(session_id)` and
/// `turn_index` set to the new (post-append) index. CLI `kebab
/// ask --session <id>` entry point (p9-fb-18).
#[doc(hidden)]
pub fn ask_with_session_with_config(
config: kebab_config::Config,
session_id: &str,
query: &str,
opts: AskOpts,
) -> anyhow::Result<Answer> {
App::open_with_config(config)?.ask_with_session(session_id, query, opts)
}
/// Run the doctor checks against the explicit config path the user
/// requested via `--config` (or the XDG default if `None`). The
/// `config_loaded` check reports the actual path probed and the

View File

@@ -30,9 +30,6 @@ fn ask_lexical_smoke() {
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: false,
};
// The fixture workspace contains "ownership" content; the model's

View File

@@ -271,16 +271,6 @@ enum Cmd {
#[arg(long)]
hide_citations: bool,
/// p9-fb-18: persistent multi-turn chat session id. First call
/// auto-creates the session in SQLite (`chat_sessions`), each
/// subsequent call with the same id loads prior turns as
/// history and appends the new Q/A. Without this flag, ask
/// is single-shot (no persistence). The session id is
/// caller-supplied — pick anything stable per conversation
/// (e.g. `kebab-rust-async-2026-05`).
#[arg(long, value_name = "ID")]
session: Option<String>,
/// p9-fb-33: emit ndjson `answer_event.v1` events on stderr
/// while streaming. Final stdout line is the existing
/// `answer.v1`. Off by default to preserve final-only behavior.
@@ -1119,7 +1109,6 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
seed,
show_citations,
hide_citations,
session,
stream,
multi_hop,
} => {
@@ -1156,19 +1145,12 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
temperature: *temperature,
seed: *seed,
stream_sink: Some(tx),
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: *multi_hop,
};
let cfg2 = cfg.clone();
let q = query.clone();
let session2 = session.clone();
let handle = std::thread::spawn(move || -> anyhow::Result<kebab_core::Answer> {
match session2.as_deref() {
Some(sid) => kebab_app::ask_with_session_with_config(cfg2, sid, &q, opts),
None => kebab_app::ask_with_config(cfg2, &q, opts),
}
kebab_app::ask_with_config(cfg2, &q, opts)
});
// Drain receiver, write ndjson to stderr until
@@ -1223,20 +1205,9 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
// takes the branch above; the TUI ask pane (P9-3)
// wires up its own `mpsc::Sender`.
stream_sink: None,
// p9-fb-18: when `--session` is set, the facade
// (`ask_with_session_with_config`) loads prior turns
// from SQLite and stuffs them into AskOpts.history
// before calling `ask_with_history`. Single-shot path
// (no `--session`) keeps the empty defaults.
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)?,
None => kebab_app::ask_with_config(cfg, query, opts)?,
};
let ans = kebab_app::ask_with_config(cfg, query, opts)?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_answer(&ans))?);
} else {
@@ -1856,8 +1827,6 @@ mod tests {
latency_ms: 0,
},
created_at: OffsetDateTime::now_utc(),
conversation_id: None,
turn_index: None,
hops: None,
verification: None,
}

View File

@@ -20,15 +20,6 @@ pub struct Answer {
pub usage: TokenUsage,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
/// p9-fb-15: same conversation 의 turn 들이 공유. CLI single-shot
/// (history 없음) / TUI 첫 turn 은 None. blake3 해시 또는 사용자
/// 명시 (`kebab ask --session <id>`, p9-fb-18).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// p9-fb-15: 같은 conversation 안 0-based 순서. 첫 turn = 0. None
/// 이면 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
@@ -73,19 +64,6 @@ pub struct AnswerCitation {
pub stale: bool,
}
/// p9-fb-15: history 가 prompt 에 들어갈 때의 한 turn. RAG facade 가
/// `Vec<Turn>` 받아 system + history + retrieval + new question 으로
/// prompt 빌드. token budget 안에 fit 안 되면 oldest turn 부터 drop
/// (newest 우선 보존).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Turn {
pub question: String,
pub answer: String,
pub citations: Vec<AnswerCitation>,
#[serde(with = "time::serde::rfc3339")]
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
@@ -275,8 +253,6 @@ mod tests {
latency_ms: 0,
},
created_at: datetime!(2026-05-09 12:00:00 UTC),
conversation_id: None,
turn_index: None,
hops: None,
verification: None,
};

View File

@@ -31,7 +31,7 @@ pub mod versions;
pub use answer::{
Answer, AnswerCitation, AnswerRetrievalSummary, HopKind, HopRecord, ModelRef, RefusalReason,
TokenUsage, TraceId, Turn, VerificationSummary,
TokenUsage, TraceId, VerificationSummary,
};
pub use asset::{AssetStorage, RawAsset, SourceUri, WorkspacePath};
pub use chunk::Chunk;
@@ -60,7 +60,7 @@ pub use search::{
SearchQuery, SearchTrace, TraceCandidate, TraceFusionInput, TraceTiming,
};
pub use traits::{
ChatSessionRepo, ChatSessionRow, ChatTurnRow, ChunkPolicy, Chunker, DocumentStore, Embedder,
ChunkPolicy, Chunker, DocumentStore, Embedder,
EmbeddingInput, EmbeddingKind, ExtractConfig, ExtractContext, Extractor, FinishReason,
GenerateRequest, JobRepo, LanguageModel, Retriever, SourceConnector, SourceScope, TokenChunk,
VectorStore,

View File

@@ -232,67 +232,3 @@ pub trait JobRepo {
fn list(&self, filter: &JobFilter) -> anyhow::Result<Vec<JobRow>>;
}
// ── p9-fb-17: chat session persistence ────────────────────────────────
/// Persistent multi-turn chat session — header row in `chat_sessions`.
/// Per-turn rows live in `chat_turns` (see [`ChatTurnRow`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatSessionRow {
pub session_id: String,
/// Unix epoch seconds at session creation time.
pub created_at: i64,
/// Unix epoch seconds, bumped on every `append_turn`.
pub updated_at: i64,
/// Optional human-readable label — defaults to the first
/// question's first ~40 chars on creation.
pub title: Option<String>,
/// Snapshot of `prompt_template_version`, `llm.model`,
/// `max_context_tokens`, etc. — same shape as
/// `eval_runs.config_snapshot_json`. JSON string so the schema
/// can grow without an SQLite ALTER.
pub config_snapshot_json: String,
}
/// One Q/A pair inside a `ChatSessionRow`. `turn_index` is monotonic
/// per session (0-based).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatTurnRow {
/// `blake3(session_id || turn_index)` (32 hex). Stable per (session,
/// turn) so a re-append at the same index is rejected via PK.
pub turn_id: String,
pub session_id: String,
pub turn_index: u32,
pub question: String,
pub answer: String,
/// `Vec<Citation>` JSON-encoded so a session resume can replay
/// the same citation markers the user saw originally.
pub citations_json: String,
pub created_at: i64,
}
/// Persistence trait for multi-turn chat sessions. Implemented by
/// `kebab-store-sqlite::SqliteStore`; consumed by `kebab-app` and the
/// future CLI / TUI session UIs (p9-fb-18).
pub trait ChatSessionRepo {
/// Create a new session. `session_id` is caller-supplied — auto
/// derivation lives in `kebab-app`. Errors on PK collision.
fn create_session(&self, row: &ChatSessionRow) -> anyhow::Result<()>;
/// Look up a session by id; `Ok(None)` when missing.
fn get_session(&self, session_id: &str) -> anyhow::Result<Option<ChatSessionRow>>;
/// Most-recent-updated-first list of sessions, capped at `limit`.
fn list_sessions(&self, limit: usize) -> anyhow::Result<Vec<ChatSessionRow>>;
/// Delete a session and (CASCADE) every turn under it.
fn delete_session(&self, session_id: &str) -> anyhow::Result<()>;
/// Append a turn at `turn.turn_index`. Bumps the parent's
/// `updated_at`. PK collision (same session_id + turn_index) is
/// an error — the caller assigns the next monotonic index.
fn append_turn(&self, turn: &ChatTurnRow) -> anyhow::Result<()>;
/// All turns for `session_id`, ordered by `turn_index ASC`.
/// Empty vec when the session has no turns yet.
fn list_turns(&self, session_id: &str) -> anyhow::Result<Vec<ChatTurnRow>>;
}

View File

@@ -569,8 +569,6 @@ mod tests {
latency_ms: 1,
},
created_at: OffsetDateTime::UNIX_EPOCH,
conversation_id: None,
turn_index: None,
hops: None,
verification: None,
}

View File

@@ -174,11 +174,6 @@ fn execute_query(app: &App, gq: &GoldenQuery, opts: &EvalRunOpts) -> QueryResult
temperature: opts.temperature,
seed: opts.seed,
stream_sink: None,
// p9-fb-15: golden eval is single-shot per query; no
// conversational history.
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.

View File

@@ -1,6 +1,5 @@
//! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot) or
//! `kebab_app::ask_with_session_with_config` when `session_id` is provided.
//! Input: { query, session_id?, mode? }. Output: answer.v1 JSON.
//! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot).
//! Input: { query, mode? }. Output: answer.v1 JSON.
//!
//! `Answer` (kebab-core) does NOT carry a `schema_version` field; we tag
//! it inline here, matching the pattern from `search.rs`.
@@ -16,8 +15,6 @@ use crate::state::KebabAppState;
pub struct AskInput {
/// The user question.
pub query: String,
/// Optional session id for multi-turn RAG context.
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`.
@@ -44,16 +41,10 @@ pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult {
temperature: None,
seed: None,
stream_sink: None,
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 {
Some(sid) => kebab_app::ask_with_session_with_config(cfg_clone, &sid, &input.query, opts),
None => kebab_app::ask_with_config(cfg_clone, &input.query, opts),
};
let result = kebab_app::ask_with_config(cfg_clone, &input.query, opts);
match result {
Ok(answer) => {
// `Answer` does not carry `schema_version`; tag inline (idempotent

View File

@@ -42,7 +42,7 @@ use kebab_core::versions::PromptTemplateVersion;
use kebab_core::{
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, FinishReason, GenerateRequest,
HopKind, HopRecord, LanguageModel, ModelRef, RefusalReason, Retriever, SearchFilters,
SearchHit, SearchMode, SearchQuery, TokenChunk, TokenUsage, TraceId, TrustLevel, Turn,
SearchHit, SearchMode, SearchQuery, TokenChunk, TokenUsage, TraceId, TrustLevel,
VerificationSummary,
};
use kebab_store_sqlite::SqliteStore;
@@ -102,7 +102,6 @@ pub enum StreamEvent {
},
Token {
delta: String,
turn_index: Option<u32>,
},
Final {
answer: Answer,
@@ -138,22 +137,6 @@ pub struct AskOpts {
/// `Final`) is forwarded synchronously. A dropped receiver
/// triggers cancel — see `RagPipeline::ask` for the break path.
pub stream_sink: Option<std::sync::mpsc::Sender<StreamEvent>>,
/// p9-fb-15: prior turns of the same conversation. Empty for
/// single-shot ask. The pipeline prepends a serialized `[이전
/// 대화]` block to the user prompt and uses the most-recent
/// answer's first 200 chars to expand the retrieval query
/// (cheap concat — LLM-based standalone-question rewriting is
/// out of scope per spec §3.8). Newest-first prepended; older
/// turns drop when the prompt would otherwise exceed
/// `cfg.rag.max_context_tokens`.
pub history: Vec<Turn>,
/// p9-fb-15: same conversation 의 turn 들이 공유. Filled into
/// `Answer.conversation_id`. None for single-shot ask.
pub conversation_id: Option<String>,
/// p9-fb-15: 0-based index within `conversation_id`. Caller
/// (TUI / CLI session) computes from `history.len()`. None for
/// single-shot ask.
pub turn_index: Option<u32>,
/// p9-fb-41: multi-hop mode toggle. When `true`,
/// [`RagPipeline::ask`] dispatches to [`RagPipeline::ask_multi_hop`]
/// — the query is decomposed into sub-questions, each retrieved
@@ -170,8 +153,8 @@ pub struct AskOpts {
/// `AskOpts { ... }` literals can switch to `AskOpts { ..Default::default() }`
/// without behaviour change. Mirrors the single-shot defaults that
/// every previous caller spelled out: lexical k=0 (pipeline applies
/// its own floor), no explain, no history, no streaming, no
/// temperature / seed overrides, no multi-hop.
/// its own floor), no explain, no streaming, no temperature / seed
/// overrides, no multi-hop.
impl Default for AskOpts {
fn default() -> Self {
Self {
@@ -181,9 +164,6 @@ impl Default for AskOpts {
temperature: None,
seed: None,
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: false,
}
}
@@ -240,29 +220,6 @@ impl RagPipeline {
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
/// [`Self::ask`]. The returned `Answer` carries the same
/// `conversation_id` / `turn_index`. CLI / TUI sessions call this
/// once per follow-up question.
pub fn ask_with_history(
&self,
query: &str,
history: Vec<Turn>,
conversation_id: String,
turn_index: u32,
opts: AskOpts,
) -> Result<Answer> {
let combined = AskOpts {
history,
conversation_id: Some(conversation_id),
turn_index: Some(turn_index),
..opts
};
self.ask(query, combined)
}
/// Run one query through the full pipeline. Always persists an
/// `answers` row (including refusals); the row write is best-effort
/// — a persistence error is surfaced via `tracing::warn!` so the
@@ -281,14 +238,8 @@ impl RagPipeline {
// ── 1. Retrieve ────────────────────────────────────────────────────
// floor at config default — see `AskOpts::k` doc for rationale.
let k_effective = opts.k.max(self.config.search.default_k);
// p9-fb-15: query expansion when history is present.
// Concat the most-recent answer's first 200 chars so the
// retriever sees the full conversational context. Cheap —
// LLM-based standalone-question rewriting is out of scope
// (spec §3.8 marks it P+).
let expanded_query = expand_query_with_history(query, &opts.history);
let search_query = SearchQuery {
text: expanded_query,
text: query.to_string(),
mode: opts.mode,
k: k_effective,
filters: SearchFilters::default(),
@@ -355,23 +306,7 @@ impl RagPipeline {
// ── 4. Render prompt ───────────────────────────────────────────────
let system = system_prompt_for(&self.config.rag.prompt_template_version)?.to_string();
// p9-fb-15: prepend `[이전 대화]` block when history is
// present. `serialize_history` enforces the spec §3.8
// priority — system+question stay untouched, retrieved
// chunks already fit (`pack_context` honoured the budget),
// so the budget remaining for history is what's left over.
let history_budget_chars = remaining_history_budget_chars(
self.config.rag.max_context_tokens,
&system,
query,
&packed_text,
);
let history_block = serialize_history(&opts.history, history_budget_chars);
let user = if history_block.is_empty() {
format!("[질문]\n{query}\n\n[근거]\n{packed_text}")
} else {
format!("{history_block}\n\n[질문]\n{query}\n\n[근거]\n{packed_text}")
};
let user = format!("[질문]\n{query}\n\n[근거]\n{packed_text}");
// ── 5. Generate ────────────────────────────────────────────────────
// Completion budget is bounded only by what the LM context window
@@ -426,7 +361,6 @@ impl RagPipeline {
if sink
.send(StreamEvent::Token {
delta: t,
turn_index: opts.turn_index,
})
.is_err()
{
@@ -545,8 +479,7 @@ impl RagPipeline {
},
usage: usage_final,
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
@@ -872,21 +805,9 @@ impl RagPipeline {
.map(|(i, q)| format!("{}. {q}", i + 1))
.collect::<Vec<_>>()
.join("\n");
let history_budget_chars = remaining_history_budget_chars(
self.config.rag.max_context_tokens,
&system,
query,
&packed_text,
);
let history_block = serialize_history(&opts.history, history_budget_chars);
let body = format!(
let user = format!(
"[원본 질문]\n{query}\n\n[분해된 sub-question]\n{sub_queries_summary}\n\n[근거]\n{packed_text}"
);
let user = if history_block.is_empty() {
body
} else {
format!("{history_block}\n\n{body}")
};
// ── 6. Generate ────────────────────────────────────────────────────
let llm_ctx = self.llm.context_tokens();
@@ -934,7 +855,6 @@ impl RagPipeline {
&& sink
.send(StreamEvent::Token {
delta: t,
turn_index: opts.turn_index,
})
.is_err()
{
@@ -1112,8 +1032,7 @@ impl RagPipeline {
},
usage: usage_final,
created_at: OffsetDateTime::now_utc(),
conversation_id: opts.conversation_id.clone(),
turn_index: opts.turn_index,
// p9-fb-41 PR-3b: multi-hop happy path stamps the hop
// trace. Refusal paths inside `ask_multi_hop` go through
// `refuse_*` helpers shared with single-pass `ask` and
@@ -1326,8 +1245,7 @@ impl RagPipeline {
latency_ms: elapsed_ms,
},
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
@@ -1468,8 +1386,7 @@ impl RagPipeline {
latency_ms: elapsed_ms,
},
created_at: OffsetDateTime::now_utc(),
conversation_id: opts.conversation_id.clone(),
turn_index: opts.turn_index,
// p9-fb-41 PR-3b-ii: single-pass callers pass `None`;
// `ask_multi_hop` forwards the partial hop trace it
// built up to the refusal point. Either way `Answer.hops`
@@ -1563,8 +1480,7 @@ impl RagPipeline {
latency_ms: elapsed_ms,
},
created_at: OffsetDateTime::now_utc(),
conversation_id: opts.conversation_id.clone(),
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
@@ -1619,8 +1535,7 @@ impl RagPipeline {
latency_ms: elapsed_ms,
},
created_at: OffsetDateTime::now_utc(),
conversation_id: opts.conversation_id.clone(),
turn_index: opts.turn_index,
// PR-9c-2: NLI refusal still carries the hop trace built
// up to step 8.5 — synthesize ran, so the trace is the
// full decompose+decide chain (terminal Synthesize hop is
@@ -1687,8 +1602,7 @@ impl RagPipeline {
latency_ms: elapsed_ms,
},
created_at: OffsetDateTime::now_utc(),
conversation_id: opts.conversation_id.clone(),
turn_index: opts.turn_index,
hops: Some(hops),
// No VerificationSummary — verification didn't happen.
verification: None,
@@ -1943,80 +1857,6 @@ fn est_tokens(s: &str) -> usize {
s.chars().count().div_ceil(4)
}
/// p9-fb-15: expand the retrieval query with the most-recent answer's
/// first 200 chars when history is non-empty. Cheap concat per spec
/// §3.8 — LLM-based standalone-question rewriting is P+. The retriever
/// sees `<question> <last answer prefix>` so embedding / FTS hit on
/// names from the prior turn ("Y" in "Y vs X 의 차이?") still surfaces
/// the right chunks.
fn expand_query_with_history(query: &str, history: &[Turn]) -> String {
let Some(last) = history.last() else {
return query.to_string();
};
let prefix: String = last.answer.chars().take(200).collect();
if prefix.is_empty() {
query.to_string()
} else {
format!("{query} {prefix}")
}
}
/// p9-fb-15: how many *chars* of history block we may afford. The
/// budget is `cfg.rag.max_context_tokens * BYTES_PER_TOKEN` minus the
/// chars already committed to system + question + retrieved chunks.
/// Returns 0 (history fully dropped) when budget already exhausted.
fn remaining_history_budget_chars(
max_context_tokens: usize,
system: &str,
question: &str,
packed_text: &str,
) -> usize {
let total_chars = max_context_tokens.saturating_mul(4);
let used = system.chars().count()
+ question.chars().count()
+ packed_text.chars().count()
// Account for the format-string overhead: `[질문]\n` + `\n\n[근거]\n`
// + `\n\n` between history and question. Round up to ~32 chars
// to keep the maths simple.
+ 32;
total_chars.saturating_sub(used)
}
/// p9-fb-15: serialize history into the `[이전 대화]` block. Newest
/// turn first per spec §3.8 — the loop walks `history` in reverse and
/// stops as soon as appending the next turn would exceed `budget_chars`.
/// Empty when history is empty or no turn fits.
fn serialize_history(history: &[Turn], budget_chars: usize) -> String {
if history.is_empty() || budget_chars == 0 {
return String::new();
}
// Build newest-first, then reverse so the LM reads chronological
// order ("Q1/A1\nQ2/A2 → newest at the bottom, just above the
// current question").
let mut included_rev: Vec<String> = Vec::new();
let mut used = 0usize;
let header = "[이전 대화]\n";
let header_len = header.chars().count();
for turn in history.iter().rev() {
let block = format!("Q: {}\nA: {}\n", turn.question, turn.answer);
let blen = block.chars().count();
if used + blen + header_len > budget_chars {
break;
}
used += blen;
included_rev.push(block);
}
if included_rev.is_empty() {
return String::new();
}
let mut out = String::with_capacity(used + header_len);
out.push_str(header);
for block in included_rev.iter().rev() {
out.push_str(block);
}
out
}
/// Strict marker regex per design §1 / spec line 107: `[#1]` … `[#999]`.
/// Matches without `#`, with whitespace, or with non-digit content are
/// intentionally ignored (see test plan rows 56).
@@ -2244,103 +2084,6 @@ mod tests {
assert_eq!(est_tokens("abcdefgh"), 2);
}
// ── p9-fb-15: multi-turn helpers ───────────────────────────────────────
fn fake_turn(question: &str, answer: &str) -> Turn {
Turn {
question: question.into(),
answer: answer.into(),
citations: Vec::new(),
created_at: OffsetDateTime::now_utc(),
}
}
#[test]
fn expand_query_with_history_empty_returns_query_unchanged() {
assert_eq!(expand_query_with_history("hi", &[]), "hi");
}
#[test]
fn expand_query_with_history_concats_last_answer_prefix() {
let h = vec![fake_turn("Q1", "first answer body")];
let expanded = expand_query_with_history("follow-up", &h);
assert!(expanded.starts_with("follow-up "), "got: {expanded}");
assert!(expanded.contains("first answer body"), "got: {expanded}");
}
#[test]
fn expand_query_caps_last_answer_at_200_chars() {
let long = "x".repeat(500);
let h = vec![fake_turn("Q", &long)];
let expanded = expand_query_with_history("q", &h);
// query (1 char) + space (1) + 200 of x = 202.
assert_eq!(expanded.chars().count(), 1 + 1 + 200);
}
#[test]
fn expand_query_uses_last_turn_only() {
let h = vec![
fake_turn("Q1", "FIRST ANSWER"),
fake_turn("Q2", "LATEST ANSWER"),
];
let expanded = expand_query_with_history("q3", &h);
assert!(expanded.contains("LATEST ANSWER"), "got: {expanded}");
assert!(!expanded.contains("FIRST ANSWER"), "got: {expanded}");
}
#[test]
fn serialize_history_empty_returns_empty_string() {
assert_eq!(serialize_history(&[], 1000), "");
let h = vec![fake_turn("q", "a")];
assert_eq!(serialize_history(&h, 0), "");
}
#[test]
fn serialize_history_chronological_order_with_header() {
let h = vec![
fake_turn("Q1", "A1"),
fake_turn("Q2", "A2"),
fake_turn("Q3", "A3"),
];
let s = serialize_history(&h, 1000);
assert!(s.starts_with("[이전 대화]\n"), "got: {s:?}");
let q1_pos = s.find("Q1").unwrap();
let q3_pos = s.find("Q3").unwrap();
assert!(q1_pos < q3_pos, "chronological: oldest first; got: {s:?}");
}
#[test]
fn serialize_history_drops_oldest_when_budget_tight() {
// Budget tight enough that only 1 of 3 turns fits.
let h = vec![
fake_turn("Q1", "A1"),
fake_turn("Q2", "A2"),
fake_turn("Q3", "A3"),
];
// Header is "[이전 대화]\n" (8 chars) + 1 turn ("Q: Q3\nA: A3\n" = 12 chars) ≈ 20.
let s = serialize_history(&h, 25);
assert!(s.contains("Q3"), "newest must be kept: {s:?}");
assert!(!s.contains("Q1"), "oldest dropped: {s:?}");
}
#[test]
fn remaining_history_budget_subtracts_known_pieces() {
// total = 100 tokens * 4 chars = 400 chars budget.
// system 100 chars + question 50 chars + packed 150 chars + 32 overhead = 332. left = 68.
let s = "x".repeat(100);
let q = "y".repeat(50);
let p = "z".repeat(150);
let left = remaining_history_budget_chars(100, &s, &q, &p);
assert_eq!(left, 400 - 100 - 50 - 150 - 32);
}
#[test]
fn remaining_history_budget_clamps_to_zero_when_overrun() {
let s = "x".repeat(1000);
let left = remaining_history_budget_chars(10, &s, "q", "p");
assert_eq!(left, 0);
}
#[test]
fn system_prompt_for_unknown_version_returns_err_with_hint() {
let err = super::system_prompt_for("rag-v99").unwrap_err();
@@ -2542,12 +2285,10 @@ mod stream_event_serde_tests {
fn stream_event_token_serializes_with_kind_discriminator() {
let ev = StreamEvent::Token {
delta: "안녕".into(),
turn_index: Some(0),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["kind"], "token");
assert_eq!(v["delta"], "안녕");
assert_eq!(v["turn_index"], 0);
}
#[test]
@@ -2589,8 +2330,6 @@ mod stream_event_serde_tests {
latency_ms: 0,
},
created_at: datetime!(2026-05-09 12:00:00 UTC),
conversation_id: None,
turn_index: None,
hops: None,
verification: None,
};

View File

@@ -43,9 +43,6 @@ fn multi_hop_opts() -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: true,
}
}

View File

@@ -33,9 +33,6 @@ fn multi_hop_opts() -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: true,
}
}

View File

@@ -56,9 +56,6 @@ fn multi_hop_opts_with_sink(tx: mpsc::Sender<StreamEvent>) -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: Some(tx),
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: true,
}
}

View File

@@ -42,9 +42,6 @@ fn multi_hop_opts() -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: true,
}
}

View File

@@ -70,9 +70,6 @@ fn default_opts() -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: false,
}
}

View File

@@ -87,9 +87,6 @@ fn lexical_opts() -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: false,
}
}

View File

@@ -67,9 +67,6 @@ fn opts_with_sink(tx: mpsc::Sender<StreamEvent>) -> AskOpts {
temperature: Some(0.0),
seed: Some(0),
stream_sink: Some(tx),
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: false,
}
}

View File

@@ -1,176 +0,0 @@
//! p9-fb-17: `ChatSessionRepo` impl for `SqliteStore`.
//!
//! `chat_sessions` + `chat_turns` tables (V005 migration) back the
//! multi-turn conversation primitive (p9-fb-15 facade, p9-fb-16 TUI,
//! p9-fb-18 CLI `--session`). The trait + row types live in
//! `kebab-core::traits` so other store backends (postgres, …) can
//! plug in without depending on this crate.
use anyhow::{Context, Result};
use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow};
use rusqlite::{OptionalExtension, params};
use crate::error::StoreError;
use crate::store::SqliteStore;
impl ChatSessionRepo for SqliteStore {
fn create_session(&self, row: &ChatSessionRow) -> Result<()> {
let conn = self.lock_conn();
conn.execute(
"INSERT INTO chat_sessions
(session_id, created_at, updated_at, title, config_snapshot_json)
VALUES (?, ?, ?, ?, ?)",
params![
row.session_id,
row.created_at,
row.updated_at,
row.title,
row.config_snapshot_json,
],
)
.map_err(StoreError::from)
.context("create_session")?;
Ok(())
}
fn get_session(&self, session_id: &str) -> Result<Option<ChatSessionRow>> {
let conn = self.read_conn();
let row = conn
.query_row(
"SELECT session_id, created_at, updated_at, title, config_snapshot_json
FROM chat_sessions WHERE session_id = ?",
params![session_id],
|r| {
Ok(ChatSessionRow {
session_id: r.get(0)?,
created_at: r.get(1)?,
updated_at: r.get(2)?,
title: r.get(3)?,
config_snapshot_json: r.get(4)?,
})
},
)
.optional()
.map_err(StoreError::from)
.context("get_session")?;
Ok(row)
}
fn list_sessions(&self, limit: usize) -> Result<Vec<ChatSessionRow>> {
let conn = self.read_conn();
let mut stmt = conn
.prepare(
"SELECT session_id, created_at, updated_at, title, config_snapshot_json
FROM chat_sessions
ORDER BY updated_at DESC
LIMIT ?",
)
.map_err(StoreError::from)
.context("list_sessions: prepare")?;
let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
let rows = stmt
.query_map(params![limit_i64], |r| {
Ok(ChatSessionRow {
session_id: r.get(0)?,
created_at: r.get(1)?,
updated_at: r.get(2)?,
title: r.get(3)?,
config_snapshot_json: r.get(4)?,
})
})
.map_err(StoreError::from)
.context("list_sessions: query")?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(StoreError::from).context("list_sessions: row")?);
}
Ok(out)
}
fn delete_session(&self, session_id: &str) -> Result<()> {
let conn = self.lock_conn();
// ON DELETE CASCADE in V005 migration sweeps `chat_turns`.
conn.execute(
"DELETE FROM chat_sessions WHERE session_id = ?",
params![session_id],
)
.map_err(StoreError::from)
.context("delete_session")?;
Ok(())
}
fn append_turn(&self, turn: &ChatTurnRow) -> Result<()> {
let mut conn = self.lock_conn();
// p9-fb-17 R1 fix: real transaction. The pre-fix code called
// `conn.execute` twice in auto-commit mode, so a failure in
// the second statement (UPDATE chat_sessions.updated_at) would
// leave the first (INSERT chat_turns row) committed —
// inconsistent state where the turn exists under a stale
// session updated_at. `conn.transaction()` opens BEGIN, both
// statements share it, `commit()` lands them atomically.
let tx = conn
.transaction()
.map_err(StoreError::from)
.context("append_turn: begin transaction")?;
tx.execute(
"INSERT INTO chat_turns
(turn_id, session_id, turn_index, question, answer,
citations_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
params![
turn.turn_id,
turn.session_id,
turn.turn_index,
turn.question,
turn.answer,
turn.citations_json,
turn.created_at,
],
)
.map_err(StoreError::from)
.context("append_turn: insert")?;
tx.execute(
"UPDATE chat_sessions SET updated_at = ? WHERE session_id = ?",
params![turn.created_at, turn.session_id],
)
.map_err(StoreError::from)
.context("append_turn: bump updated_at")?;
tx.commit()
.map_err(StoreError::from)
.context("append_turn: commit")?;
Ok(())
}
fn list_turns(&self, session_id: &str) -> Result<Vec<ChatTurnRow>> {
let conn = self.read_conn();
let mut stmt = conn
.prepare(
"SELECT turn_id, session_id, turn_index, question, answer,
citations_json, created_at
FROM chat_turns
WHERE session_id = ?
ORDER BY turn_index ASC",
)
.map_err(StoreError::from)
.context("list_turns: prepare")?;
let rows = stmt
.query_map(params![session_id], |r| {
Ok(ChatTurnRow {
turn_id: r.get(0)?,
session_id: r.get(1)?,
turn_index: r.get(2)?,
question: r.get(3)?,
answer: r.get(4)?,
citations_json: r.get(5)?,
created_at: r.get(6)?,
})
})
.map_err(StoreError::from)
.context("list_turns: query")?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(StoreError::from).context("list_turns: row")?);
}
Ok(out)
}
}

View File

@@ -18,7 +18,6 @@
//! round-trip test off a real Markdown fixture.)
mod answers;
mod chat_sessions;
mod derivation_cache;
mod documents;
mod embeddings;

View File

@@ -1,176 +0,0 @@
//! p9-fb-17: `ChatSessionRepo` impl for `SqliteStore`. Verifies the
//! V005 schema, insert/list/delete, monotonic turn_index, and
//! ON DELETE CASCADE.
use kebab_config::Config;
use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow};
use kebab_store_sqlite::SqliteStore;
use tempfile::TempDir;
fn config_for(tmp: &TempDir) -> Config {
let mut c = Config::defaults();
c.storage.data_dir = tmp.path().to_string_lossy().into_owned();
c
}
fn open_store(tmp: &TempDir) -> SqliteStore {
let cfg = config_for(tmp);
let store = SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
store
}
fn make_session(id: &str) -> ChatSessionRow {
ChatSessionRow {
session_id: id.to_string(),
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
title: Some(format!("Title for {id}")),
config_snapshot_json: r#"{"prompt_template_version":"rag-v2","llm.model":"gemma4:e4b"}"#
.to_string(),
}
}
fn make_turn(session_id: &str, index: u32) -> ChatTurnRow {
ChatTurnRow {
turn_id: format!("turn-{session_id}-{index:08x}"),
session_id: session_id.to_string(),
turn_index: index,
question: format!("Q{index} for {session_id}?"),
answer: format!("A{index} for {session_id}."),
citations_json: "[]".to_string(),
created_at: 1_700_000_000 + i64::from(index),
}
}
#[test]
fn create_get_roundtrip() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
let session = make_session("sess-1");
store.create_session(&session).unwrap();
let fetched = store
.get_session("sess-1")
.unwrap()
.expect("session present");
assert_eq!(fetched, session);
}
#[test]
fn get_missing_session_returns_none() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
assert!(store.get_session("nope").unwrap().is_none());
}
#[test]
fn create_session_pk_collision_errors() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
let session = make_session("dup");
store.create_session(&session).unwrap();
let err = store.create_session(&session).unwrap_err();
assert!(
format!("{err:#}").contains("UNIQUE")
|| format!("{err:#}").contains("constraint")
|| format!("{err:#}").to_lowercase().contains("primary key"),
"expected PK collision error: {err:#}"
);
}
#[test]
fn append_turn_then_list_in_order() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
store.create_session(&make_session("multi")).unwrap();
for i in 0..3 {
store.append_turn(&make_turn("multi", i)).unwrap();
}
let turns = store.list_turns("multi").unwrap();
assert_eq!(turns.len(), 3);
for (i, t) in turns.iter().enumerate() {
assert_eq!(t.turn_index as usize, i);
assert_eq!(t.question, format!("Q{i} for multi?"));
}
}
#[test]
fn append_turn_collides_on_same_index() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
store.create_session(&make_session("dup-turn")).unwrap();
store.append_turn(&make_turn("dup-turn", 0)).unwrap();
let err = store.append_turn(&make_turn("dup-turn", 0)).unwrap_err();
assert!(
format!("{err:#}").to_lowercase().contains("unique")
|| format!("{err:#}").to_lowercase().contains("constraint")
|| format!("{err:#}").to_lowercase().contains("primary key"),
"expected unique constraint: {err:#}"
);
}
#[test]
fn append_turn_bumps_session_updated_at() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
let session = make_session("bump");
store.create_session(&session).unwrap();
let pre = store.get_session("bump").unwrap().unwrap().updated_at;
let mut t = make_turn("bump", 0);
t.created_at = pre + 100;
store.append_turn(&t).unwrap();
let post = store.get_session("bump").unwrap().unwrap().updated_at;
assert_eq!(
post,
pre + 100,
"updated_at must follow latest turn's created_at"
);
}
#[test]
fn delete_session_cascades_to_turns() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
store.create_session(&make_session("cascade")).unwrap();
for i in 0..2 {
store.append_turn(&make_turn("cascade", i)).unwrap();
}
store.delete_session("cascade").unwrap();
assert!(store.get_session("cascade").unwrap().is_none());
assert_eq!(
store.list_turns("cascade").unwrap().len(),
0,
"ON DELETE CASCADE must wipe orphan turns"
);
}
#[test]
fn list_sessions_orders_by_updated_at_desc() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
let mut a = make_session("a");
a.updated_at = 100;
let mut b = make_session("b");
b.updated_at = 300;
let mut c = make_session("c");
c.updated_at = 200;
store.create_session(&a).unwrap();
store.create_session(&b).unwrap();
store.create_session(&c).unwrap();
let listed = store.list_sessions(10).unwrap();
let ids: Vec<_> = listed.iter().map(|s| s.session_id.clone()).collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[test]
fn list_sessions_respects_limit() {
let tmp = TempDir::new().unwrap();
let store = open_store(&tmp);
for i in 0..5 {
store
.create_session(&make_session(&format!("s{i}")))
.unwrap();
}
assert_eq!(store.list_sessions(2).unwrap().len(), 2);
assert_eq!(store.list_sessions(100).unwrap().len(), 5);
}

View File

@@ -193,13 +193,8 @@ impl Default for SearchState {
/// `RetrievalDone` and `Final` are ignored (citations render from
/// `last_answer` after the worker join).
///
/// p9-fb-16: completed `Turn`s accumulate in `turns`; the worker
/// passes a snapshot of `turns` as `history` to
/// `RagPipeline::ask_with_history`, so each follow-up question sees
/// the full prior conversation. `conversation_id` is auto-generated
/// on the first submission (timestamp-based — unique per session,
/// not cryptographic). `Ctrl-L` clears `turns + conversation_id` to
/// start a fresh conversation.
/// p9-fb-16: completed turns accumulate in `turns` for in-pane
/// display. `Ctrl-L` clears `turns` to start a fresh conversation.
pub struct AskState {
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
/// alongside content so wide chars (Hangul, CJK) place the
@@ -236,15 +231,11 @@ pub struct AskState {
/// turn (the one being generated right now) lives in
/// `current_question` + `partial` and only graduates into
/// `turns` on `poll_worker` completion.
pub turns: Vec<kebab_core::Turn>,
pub turns: Vec<crate::ask::TuiTurn>,
/// p9-fb-16: question text for the in-flight turn. Cleared at
/// submission (input → current_question, input → empty),
/// finalized into the new Turn at completion.
pub current_question: Option<String>,
/// p9-fb-16: shared id stamped onto every `Answer` of this
/// conversation. Auto-generated on first submission, cleared by
/// `Ctrl-L` (next submission generates a fresh id).
pub conversation_id: Option<String>,
/// p9-fb-16: most-recent `Answer` for citation / status display
/// in the right panel. Same data also lives inside the last
/// `Turn`; this slot is just the easiest place for the panel
@@ -275,7 +266,6 @@ impl Default for AskState {
last_error: None,
turns: Vec::new(),
current_question: None,
conversation_id: None,
last_answer: None,
multi_hop: false,
}

View File

@@ -25,6 +25,18 @@ use std::thread;
use crate::app::{App, AskState, KeyOutcome, Pane};
/// In-memory turn for the TUI conversation display. Not persisted —
/// session storage was removed in spine-phase0. Kept as a local type
/// so the Ask pane can render prior Q/A pairs without depending on
/// a now-deleted `kebab_core::Turn`.
#[derive(Clone, Debug)]
pub struct TuiTurn {
pub question: String,
pub answer: String,
pub citations: Vec<kebab_core::AnswerCitation>,
pub created_at: time::OffsetDateTime,
}
/// Render the Ask pane. Layout:
/// - top input bar
/// - middle answer area (scrollable when content overflows)
@@ -373,15 +385,14 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
}
match (key.code, key.modifiers) {
// p9-fb-16: Ctrl-L clears the in-pane conversation (turns +
// conversation_id). Doesn't kill the in-flight worker — that
// turn still finishes and its result is silently discarded
// (joined into a new conversation that didn't exist when the
// worker was spawned). Behaviour mirrors `:new` slash command.
// p9-fb-16: Ctrl-L clears the in-pane conversation (turns).
// Doesn't kill the in-flight worker — that turn still finishes
// and its result is silently discarded (joined into a new
// conversation that didn't exist when the worker was spawned).
// Behaviour mirrors `:new` slash command.
(KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => {
let s = state.ask.as_mut().unwrap();
s.turns.clear();
s.conversation_id = None;
s.last_answer = None;
s.partial.clear();
s.current_question = None;
@@ -564,16 +575,8 @@ fn spawn_ask_worker(state: &mut App) {
// streaming answer auto-scrolls into view as tokens arrive.
s.follow_tail = true;
s.rx = Some(rx);
// p9-fb-16: graduate the typed input into the in-flight turn,
// clear the input box, ensure conversation_id exists, snapshot
// history for the worker.
// Graduate the typed input into the in-flight turn.
s.current_question = Some(query.clone());
if s.conversation_id.is_none() {
s.conversation_id = Some(make_conversation_id());
}
let conversation_id = s.conversation_id.clone().unwrap();
let turn_index = u32::try_from(s.turns.len()).unwrap_or(u32::MAX);
let history = s.turns.clone();
let opts = kebab_app::AskOpts {
k: 0, // facade clamps to config.search.default_k floor
@@ -582,26 +585,12 @@ fn spawn_ask_worker(state: &mut App) {
temperature: None,
seed: None,
stream_sink: Some(tx),
history,
conversation_id: Some(conversation_id),
turn_index: Some(turn_index),
multi_hop,
};
let handle = thread::spawn(move || kebab_app::ask_with_config(cfg, &query, opts));
s.thread = Some(handle);
}
/// Generate a fresh conversation_id. Timestamp-based — unique per
/// session, not cryptographic. spec p9-fb-16 calls for blake3 of
/// (first_question + ts) but the only guarantee we need is
/// per-session uniqueness; nanosecond ts hex is enough.
fn make_conversation_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
format!("conv_{nanos:032x}")
}
/// Run-loop hook: drain the streaming channel into `partial`. Called
/// on every render frame so the answer area updates as tokens arrive.
pub(crate) fn drain_stream(state: &mut App) {
@@ -640,13 +629,11 @@ pub(crate) fn poll_worker(state: &mut App) {
s.rx = None;
match result {
Ok(Ok(answer)) => {
// p9-fb-16: graduate the in-flight (current_question +
// partial / answer) into a completed Turn appended to
// `turns`. Next submission's spawn_ask_worker reads
// `turns` as history and stamps turn_index.
// Graduate the in-flight (current_question + partial /
// answer) into a completed TuiTurn for display.
let question = s.current_question.take().unwrap_or_default();
s.partial.clear();
let turn = kebab_core::Turn {
let turn = crate::ask::TuiTurn {
question,
answer: answer.answer.clone(),
citations: answer.citations.clone(),

View File

@@ -388,10 +388,8 @@ fn dynamic_status(app: &App) -> String {
"idle".to_string()
}
/// Short form of the Ask `conversation_id` for the status bar
/// (`conv_<first 8 hex chars>…`). Returns `None` when not in Ask, or
/// when the Ask pane has no context (no in-flight question and no
/// completed turns).
/// Short status for the Ask pane: turn count when there is context.
/// Returns `None` when not in Ask or when no turns / in-flight question.
fn ask_conv_id_short(app: &App) -> Option<String> {
if app.focus != Pane::Ask {
return None;
@@ -401,10 +399,8 @@ fn ask_conv_id_short(app: &App) -> Option<String> {
if !has_context {
return None;
}
let id = s.conversation_id.as_deref()?;
let hex = id.strip_prefix("conv_").unwrap_or(id);
let head: String = hex.chars().take(8).collect();
Some(format!("conv_{head}"))
let count = s.turns.len() + usize::from(s.current_question.is_some());
Some(format!("{count} turn(s)"))
}
fn render_key_hints(f: &mut Frame, area: Rect, app: &App) {

View File

@@ -0,0 +1,3 @@
-- spine-phase0: drop multi-turn chat session tables (V005 was the creator).
DROP TABLE IF EXISTS chat_turns;
DROP TABLE IF EXISTS chat_sessions;