KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/retrieval.rs
Parfii-bot b61b17ea7b feat(kei-buddy): conversational LLM-driven flow + kei-sage retrieval (graph-RAG)
Replaces the rigid FSM after Intro/AskLanguage with a single LLM call per
turn that sees:
  * persona (what's already known — slots not re-asked)
  * recent 10 chat_log messages (history)
  * top-5 kei-sage atoms relevant to user_text (graph-RAG, not embeddings)
  * raw user_text

LLM returns JSON {slot_updates, response_text, done, focus} which drives
the next state + persona patch + reply. No embeddings, no vector store —
kei-sage's FTS5 + Obsidian-style atom graph is the retrieval layer.

New files:
  * src/retrieval.rs (101 LOC) — retrieve_context(chat_log, topics,
    chat_id, query, history_n, atoms_k) -> RetrievalContext
  * src/conversational.rs (157 LOC) — conversational_step
    (state, persona, context, text, extractor, lang) -> StepOutput

Modified:
  * src/serve.rs::run_fsm — branch on state: Intro/AskLanguage still go
    through legacy handle_step (jump-start); everything else routes to
    conversational_step with retrieval context.
  * src/lib.rs — module declarations.

Tests (5 new, 60 total passing):
  * parses_well_formed_llm_response
  * done_true_transitions_to_ready
  * invalid_json_falls_back_gracefully
  * retrieve_returns_empty_on_empty_stores
  * retrieve_finds_seeded_data

Verify:
  * cargo check -p kei-buddy: PASS
  * cargo test -p kei-buddy --lib: 60/0 (was 55, +5)

Why graph-RAG instead of embeddings: kei-sage already in tree (atoms +
edges + BFS + PageRank + FTS5). Explicit edges (message → topic →
contact) beat opaque cosine similarity for personal-assistant memory
where relationships are typed. No sqlite-vec dep, no embedding cost.

NOT deployed yet — needs server rebuild.
2026-05-12 19:00:27 +08:00

101 lines
3.5 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Retrieval context builder — history from ChatLog + atoms from Topics.
//! Constructor Pattern: one responsibility — gather context for conversational_step.
use std::sync::Arc;
use tracing::warn;
use crate::{chat_log::ChatLog, topics::Topics};
/// Pre-assembled context for `conversational_step`.
pub struct RetrievalContext {
/// Last N messages formatted as "u: ..." / "b: ..." (latest first).
pub history: Vec<String>,
/// Top-K kei-sage Units formatted as "[topic] title: content[..200]".
pub atoms: Vec<String>,
}
/// Gather conversation history and relevant atoms for the current turn.
///
/// All errors are swallowed and logged at `warn` — the caller must always
/// receive a usable (possibly empty) context.
pub async fn retrieve_context(
chat_log: &Arc<ChatLog>,
topics: &Arc<Topics>,
chat_id: i64,
query: &str,
history_n: usize,
atoms_k: i64,
) -> RetrievalContext {
let history = fetch_history(chat_log, chat_id, query, history_n).await;
let atoms = fetch_atoms(topics, query, atoms_k).await;
RetrievalContext { history, atoms }
}
async fn fetch_history(
chat_log: &Arc<ChatLog>,
chat_id: i64,
query: &str,
n: usize,
) -> Vec<String> {
let limit = n.max(1) as i64;
match chat_log.search(query, Some(chat_id), limit).await {
Ok(msgs) => msgs
.iter()
.map(|m| {
let prefix = if m.role == "user" { "u" } else { "b" };
format!("{prefix}: {}", m.content)
})
.collect(),
Err(e) => {
warn!(chat_id, error = %e, "retrieve_context: history fetch failed");
vec![]
}
}
}
async fn fetch_atoms(topics: &Arc<Topics>, query: &str, k: i64) -> Vec<String> {
match topics.search(query, k).await {
Ok(units) => units
.iter()
.map(|u| {
let snippet: String = u.content.chars().take(200).collect();
format!("[{}] {}: {}", u.unit_type, u.title, snippet)
})
.collect(),
Err(e) => {
warn!(error = %e, "retrieve_context: atom search failed");
vec![]
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn retrieve_returns_empty_on_empty_stores() {
let log = Arc::new(ChatLog::from_memory().unwrap());
let topics = Arc::new(Topics::from_memory().unwrap());
let ctx = retrieve_context(&log, &topics, 42, "anything", 10, 5).await;
assert!(ctx.history.is_empty(), "expected empty history");
assert!(ctx.atoms.is_empty(), "expected empty atoms");
}
#[tokio::test]
async fn retrieve_finds_seeded_data() {
let log = Arc::new(ChatLog::from_memory().unwrap());
let topics = Arc::new(Topics::from_memory().unwrap());
// Seed chat log
log.log_user(99, "rust programming").await.unwrap();
log.log_bot(99, "great choice").await.unwrap();
// Seed topics
topics.add_topic(99, "rust", "Rust language", "rust systems programming").await.unwrap();
let ctx = retrieve_context(&log, &topics, 99, "rust", 10, 5).await;
assert!(!ctx.history.is_empty(), "expected history entries after seeding");
assert!(!ctx.atoms.is_empty(), "expected atom entries after seeding");
}
}