refactor: multi-turn 세션 제거 (ask --session, chat_sessions/turns) + V015 drop migration
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -569,8 +569,6 @@ mod tests {
|
||||
latency_ms: 1,
|
||||
},
|
||||
created_at: OffsetDateTime::UNIX_EPOCH,
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
hops: None,
|
||||
verification: None,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 5–6).
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
//! round-trip test off a real Markdown fixture.)
|
||||
|
||||
mod answers;
|
||||
mod chat_sessions;
|
||||
mod derivation_cache;
|
||||
mod documents;
|
||||
mod embeddings;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
3
migrations/V015__drop_chat_sessions.sql
Normal file
3
migrations/V015__drop_chat_sessions.sql
Normal 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;
|
||||
Reference in New Issue
Block a user