feat(kebab-core + kebab-store-sqlite): p9-fb-17 chat session storage (V005)
도그푸딩 item 13/14 (multi-turn 영속화) — TUI Ask 의 "이전 대화
이어가기" + 향후 CLI `--session foo` (p9-fb-18) backing store. session
header + per-turn 두 테이블, ON DELETE CASCADE 로 reset --data-only 가
한꺼번에 wipe.
## 핵심 변경
- **SQLite V005 migration** `chat_sessions` (session_id PK + created_at
+ updated_at + title + config_snapshot_json) + `chat_turns` (turn_id
PK + session_id FK ON DELETE CASCADE + turn_index + question +
answer + citations_json + created_at + UNIQUE(session_id, turn_index))
+ `idx_chat_turns_session(session_id, turn_index)`. 모두 `STRICT`.
- **`kebab_core::ChatSessionRepo`** trait (6 method): create_session /
get_session / list_sessions(limit, ORDER BY updated_at DESC) /
delete_session / append_turn / list_turns(ORDER BY turn_index ASC)
- **`kebab_core::{ChatSessionRow, ChatTurnRow}`** structs — Serialize
+ Deserialize 둘 다 (CLI / wire 출력 호환)
- **`kebab-store-sqlite::SqliteStore`** impl 신규 모듈 `chat_sessions.rs`.
`append_turn` 이 insert + parent updated_at bump 같은 connection
에서 처리.
- **frozen design §5** 에 §5.7a chat_sessions / chat_turns 절 신설
(full schema + trait 메서드 6 개 명시).
## HOTFIXES (V004 → V005)
spec p9-fb-17 의 `V004__chat_sessions.sql` 가 p9-fb-19 의
`V004__kv.sql` (이미 머지) 와 refinery migration number 충돌. 무중단
정정: `V005__chat_sessions.sql` 로 시프트. schema / 동작 동일, 파일명
만 이동. HOTFIXES entry 추가.
## 테스트
- 9 신규 integration unit (create/get roundtrip, missing→None, PK
collision error, append+list ordered, dup turn_index error,
append bumps updated_at, delete CASCADE turns, list_sessions
ORDER BY updated_at DESC, list_sessions LIMIT)
- workspace 전체 `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy --workspace --all-targets -- -D warnings` clean
## 문서
- frozen design §5.7a 신설
- HANDOFF: 2026-05-03 entry
- HOTFIXES: V004 → V005 rename rationale
- spec status planned → in_progress
## Out of scope
- session 검색 / 필터 UI (p9-fb-18 의 `kebab ask --session list`
같은 admin command 가 후속)
- 다른 store backend (postgres 등) — trait 만 정의, impl 은 SQLite
unblocks p9-fb-18 (CLI session/repl).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
|||||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState` 에 `generation: u64` + `worker_thread: Option<JoinHandle>` + `worker_rx: Option<Receiver<SearchWorkerMessage>>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`.
|
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState` 에 `generation: u64` + `worker_thread: Option<JoinHandle>` + `worker_rx: Option<Receiver<SearchWorkerMessage>>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`.
|
||||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option<PathBuf>` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load` 가 `path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`.
|
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option<PathBuf>` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load` 가 `path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`.
|
||||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable` 가 `new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`.
|
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable` 가 `new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`.
|
||||||
|
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-17)** — Multi-turn chat session 영속화 (storage 만 — UI 는 p9-fb-18). SQLite V005 migration (spec 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트, HOTFIXES) 으로 `chat_sessions` (session_id PK + created_at + updated_at + title + config_snapshot_json) + `chat_turns` (turn_id PK + session_id FK ON DELETE CASCADE + turn_index + question + answer + citations_json + created_at, UNIQUE(session_id, turn_index)) + `idx_chat_turns_session` 추가. `kebab_core::ChatSessionRepo` trait 6 메서드 (create_session / get_session / list_sessions / delete_session / append_turn / list_turns) + `kebab_core::{ChatSessionRow, ChatTurnRow}` 신규 export. `kebab-store-sqlite::SqliteStore` impl (별 `chat_sessions.rs` 모듈) — append_turn 이 insert + parent updated_at bump 을 같은 conn 에서 처리. frozen design §5 storage 에 §5.7a chat_sessions/turns 절 신설. spec: `tasks/p9/p9-fb-17-chat-session-storage.md`. unblocks p9-fb-18 (CLI session/repl).
|
||||||
|
|
||||||
## 다음 task 후보
|
## 다음 task 후보
|
||||||
|
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ pub use jobs::{JobFilter, JobId, JobKind, JobRow, JobStatus};
|
|||||||
pub use vector::{VectorHit, VectorRecord};
|
pub use vector::{VectorHit, VectorRecord};
|
||||||
pub use errors::CoreError;
|
pub use errors::CoreError;
|
||||||
pub use traits::{
|
pub use traits::{
|
||||||
ChunkPolicy, Chunker, DocumentStore, Embedder, EmbeddingInput,
|
ChatSessionRepo, ChatSessionRow, ChatTurnRow, ChunkPolicy, Chunker, DocumentStore,
|
||||||
EmbeddingKind, ExtractConfig, ExtractContext, Extractor, FinishReason,
|
Embedder, EmbeddingInput, EmbeddingKind, ExtractConfig, ExtractContext, Extractor,
|
||||||
GenerateRequest, JobRepo, LanguageModel, Retriever, SourceConnector,
|
FinishReason, GenerateRequest, JobRepo, LanguageModel, Retriever, SourceConnector,
|
||||||
SourceScope, TokenChunk, VectorStore,
|
SourceScope, TokenChunk, VectorStore,
|
||||||
};
|
};
|
||||||
pub use normalize::{nfc, to_posix};
|
pub use normalize::{nfc, to_posix};
|
||||||
|
|||||||
@@ -196,3 +196,68 @@ pub trait JobRepo {
|
|||||||
) -> anyhow::Result<()>;
|
) -> anyhow::Result<()>;
|
||||||
fn list(&self, filter: &JobFilter) -> anyhow::Result<Vec<JobRow>>;
|
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>>;
|
||||||
|
}
|
||||||
|
|||||||
168
crates/kebab-store-sqlite/src/chat_sessions.rs
Normal file
168
crates/kebab-store-sqlite/src/chat_sessions.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! 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 conn = self.lock_conn();
|
||||||
|
// Wrap insert + parent updated_at in one transaction so a
|
||||||
|
// crash between the two never leaves a turn under a stale
|
||||||
|
// `updated_at`.
|
||||||
|
let tx_result: Result<()> = (|| {
|
||||||
|
conn.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")?;
|
||||||
|
conn.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")?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
tx_result
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +18,7 @@
|
|||||||
//! round-trip test off a real Markdown fixture.)
|
//! round-trip test off a real Markdown fixture.)
|
||||||
|
|
||||||
mod answers;
|
mod answers;
|
||||||
|
mod chat_sessions;
|
||||||
mod documents;
|
mod documents;
|
||||||
mod embeddings;
|
mod embeddings;
|
||||||
mod error;
|
mod error;
|
||||||
|
|||||||
175
crates/kebab-store-sqlite/tests/chat_sessions.rs
Normal file
175
crates/kebab-store-sqlite/tests/chat_sessions.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! 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-v1","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);
|
||||||
|
}
|
||||||
@@ -1045,6 +1045,38 @@ CREATE TABLE eval_query_results (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5.7a Chat sessions / turns (p9-fb-17)
|
||||||
|
|
||||||
|
multi-turn 대화 영속화 — `kebab ask --session foo` 의 backing store.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE chat_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
title TEXT, -- 첫 question 의 N 자
|
||||||
|
config_snapshot_json TEXT NOT NULL -- prompt_template_version, llm.model 등
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE chat_turns (
|
||||||
|
turn_id TEXT PRIMARY KEY NOT NULL, -- blake3(session_id || turn_index)
|
||||||
|
session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
|
||||||
|
turn_index INTEGER NOT NULL, -- monotonic per session, 0-based
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
citations_json TEXT NOT NULL, -- Vec<Citation> JSON
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(session_id, turn_index)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_turns_session ON chat_turns(session_id, turn_index);
|
||||||
|
```
|
||||||
|
|
||||||
|
`kebab_core::ChatSessionRepo` trait 가 6 메서드 (create_session,
|
||||||
|
get_session, list_sessions, delete_session, append_turn, list_turns).
|
||||||
|
`kebab-store-sqlite::SqliteStore` impl 가 V005 migration 위에서 동작.
|
||||||
|
`kebab reset --data-only` (p9-fb-06) 가 양 테이블 wipe.
|
||||||
|
|
||||||
### 5.8 트랜잭션 정책
|
### 5.8 트랜잭션 정책
|
||||||
|
|
||||||
- ingest 1 doc = 1 트랜잭션.
|
- ingest 1 doc = 1 트랜잭션.
|
||||||
|
|||||||
54
migrations/V005__chat_sessions.sql
Normal file
54
migrations/V005__chat_sessions.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- V005__chat_sessions.sql — multi-turn conversation persistence.
|
||||||
|
--
|
||||||
|
-- p9-fb-17 introduces session-level storage for the multi-turn `Ask`
|
||||||
|
-- conversation primitive (p9-fb-15 facade, p9-fb-16 TUI). Each session
|
||||||
|
-- groups N consecutive Q/A turns under one `session_id`; the TUI
|
||||||
|
-- "이전 대화 이어가기" + the future `kebab ask --session foo` flag
|
||||||
|
-- (p9-fb-18) read+append against these tables.
|
||||||
|
--
|
||||||
|
-- Schema notes:
|
||||||
|
--
|
||||||
|
-- * `session_id` is user-supplied (`--session foo`) or auto-derived
|
||||||
|
-- from `blake3(first_question || first_ts)` as a 32-hex string. No
|
||||||
|
-- foreign-key into another table — sessions are sovereign.
|
||||||
|
--
|
||||||
|
-- * `chat_turns.turn_index` is monotonic per session (0-based). The
|
||||||
|
-- `UNIQUE(session_id, turn_index)` pair enforces the invariant on
|
||||||
|
-- the storage side so a buggy caller cannot double-append turn 3.
|
||||||
|
--
|
||||||
|
-- * `ON DELETE CASCADE` so `kebab reset --data-only` (p9-fb-06)
|
||||||
|
-- wipes both tables together — orphan turns can never outlive
|
||||||
|
-- their session.
|
||||||
|
--
|
||||||
|
-- * `config_snapshot_json` mirrors `eval_runs.config_snapshot_json`
|
||||||
|
-- (P5-1) — captures the prompt_template_version, llm.model, and
|
||||||
|
-- max_context_tokens that produced the session so a retroactive
|
||||||
|
-- answer-quality regression can be re-traced.
|
||||||
|
--
|
||||||
|
-- * `citations_json` carries `Vec<Citation>` so the answer can be
|
||||||
|
-- redisplayed with the same citation markers a future session
|
||||||
|
-- sees on resume.
|
||||||
|
--
|
||||||
|
-- * `INTEGER` timestamps (unix epoch seconds) — same convention the
|
||||||
|
-- rest of the schema uses (P1-7 baselines this).
|
||||||
|
|
||||||
|
CREATE TABLE chat_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
config_snapshot_json TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE chat_turns (
|
||||||
|
turn_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
|
||||||
|
turn_index INTEGER NOT NULL,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
citations_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(session_id, turn_index)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_turns_session ON chat_turns(session_id, turn_index);
|
||||||
@@ -14,6 +14,19 @@ historical contract that was implemented; this file accumulates the
|
|||||||
deltas so phase 5+ readers can find the live behavior without diffing
|
deltas so phase 5+ readers can find the live behavior without diffing
|
||||||
git history.
|
git history.
|
||||||
|
|
||||||
|
## 2026-05-03 — p9-fb-17 migration number V004 → V005
|
||||||
|
|
||||||
|
**Spec amended**: `tasks/p9/p9-fb-17-chat-session-storage.md` (frozen —
|
||||||
|
original contract calls the migration `V004__chat_sessions.sql`).
|
||||||
|
|
||||||
|
**Why renamed**: `V004__kv.sql` was already taken by p9-fb-19's `kv`
|
||||||
|
table for the `corpus_revision` counter (merged earlier the same day,
|
||||||
|
PR #78). Refinery numbers must be globally unique + monotonically
|
||||||
|
increasing, so chat-session storage shifts to `V005__chat_sessions.sql`.
|
||||||
|
|
||||||
|
**Behavior unchanged**: identical schema to the spec (chat_sessions +
|
||||||
|
chat_turns + idx_chat_turns_session); only the file name moved.
|
||||||
|
|
||||||
## 2026-05-03 — p9-fb-19 spec `index_version` → impl `corpus_revision` rename
|
## 2026-05-03 — p9-fb-19 spec `index_version` → impl `corpus_revision` rename
|
||||||
|
|
||||||
**Spec amended**: `tasks/p9/p9-fb-19-search-cache.md` (frozen — original
|
**Spec amended**: `tasks/p9/p9-fb-19-search-cache.md` (frozen — original
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ phase: P9
|
|||||||
component: kebab-store-sqlite
|
component: kebab-store-sqlite
|
||||||
task_id: p9-fb-17
|
task_id: p9-fb-17
|
||||||
title: "SQLite V004 — chat_sessions / chat_turns"
|
title: "SQLite V004 — chat_sessions / chat_turns"
|
||||||
status: planned
|
status: in_progress
|
||||||
depends_on: [p9-fb-15]
|
depends_on: [p9-fb-15]
|
||||||
unblocks: [p9-fb-18]
|
unblocks: [p9-fb-18]
|
||||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||||
|
|||||||
Reference in New Issue
Block a user