use std::fs;
use std::path::Path;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::config::Provider;
use crate::error::RecallError;
use crate::graph::llm::LlmProvider as GraphLlmProvider;
use crate::graph::types::{MatchSource, QueryOptions, QueryResult};
use crate::graph::GraphMemory;
use crate::search;
const ARCHIVE_BUDGET_DIVISOR: usize = 4;
#[derive(Debug, Clone)]
pub struct AnswerOpts {
pub graph_depth: usize,
pub graph_limit: usize,
pub episode_top_k: usize,
pub archive_top_k: usize,
pub episode_char_budget: usize,
pub include_episodes: bool,
pub provider_override: Option<Provider>,
pub model_override: Option<String>,
pub max_tokens: u32,
}
impl Default for AnswerOpts {
fn default() -> Self {
Self {
graph_depth: 2,
graph_limit: 20,
episode_top_k: 20,
archive_top_k: 5,
episode_char_budget: 28_000,
include_episodes: true,
provider_override: None,
model_override: None,
max_tokens: 512,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RetrievedFact {
pub name: String,
pub entity_type: String,
pub abstract_text: String,
pub overview: String,
pub score: f64,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RetrievedEpisode {
pub source: String,
pub abstract_text: String,
pub session_id: Option<String>,
pub log_number: Option<i64>,
pub score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BenchAnswer {
pub answer: String,
pub retrieved_facts: Vec<RetrievedFact>,
pub retrieved_episodes: Vec<RetrievedEpisode>,
pub model: String,
pub provider: String,
pub tokens_in: u32,
pub tokens_out: u32,
pub latency_ms: u64,
}
pub const NO_INFO_ANSWER: &str = "I don't have enough information to answer.";
pub async fn answer_question(
entity_root: &Path,
question: &str,
opts: AnswerOpts,
) -> Result<BenchAnswer, RecallError> {
let memory_dir = entity_root.join("memory");
let (provider, model) = crate::llm_provider::create_provider(
&memory_dir,
opts.provider_override
.as_ref()
.map(|p| p.to_string())
.as_deref(),
opts.model_override.as_deref(),
)?;
let provider_label = opts
.provider_override
.clone()
.unwrap_or_else(|| crate::config::load(&memory_dir).llm.provider)
.to_string();
answer_with_provider(
entity_root,
question,
&opts,
provider.as_ref(),
model,
provider_label,
)
.await
}
pub async fn answer_with_provider(
entity_root: &Path,
question: &str,
opts: &AnswerOpts,
llm: &dyn GraphLlmProvider,
model: String,
provider_label: String,
) -> Result<BenchAnswer, RecallError> {
let memory_dir = entity_root.join("memory");
let started = Instant::now();
let facts = retrieve_facts(&memory_dir, question, opts).await?;
let episodes = retrieve_episodes(&memory_dir, question, opts).await?;
let memory_md = read_memory_md(&memory_dir);
let system_prompt = build_system_prompt();
let user_message = build_user_message(&memory_md, &facts, &episodes, question);
let answer_text = llm
.complete(&system_prompt, &user_message, opts.max_tokens)
.await?;
let latency_ms = started.elapsed().as_millis() as u64;
Ok(BenchAnswer {
answer: answer_text.trim().to_string(),
retrieved_facts: facts,
retrieved_episodes: episodes,
model,
provider: provider_label,
tokens_in: estimate_tokens(&system_prompt) + estimate_tokens(&user_message),
tokens_out: estimate_tokens(&answer_text),
latency_ms,
})
}
async fn retrieve_facts(
memory_dir: &Path,
question: &str,
opts: &AnswerOpts,
) -> Result<Vec<RetrievedFact>, RecallError> {
let graph_dir = memory_dir.join("graph");
if !graph_dir.exists() {
return Ok(Vec::new());
}
let gm = GraphMemory::open(&graph_dir).await?;
let query_opts = QueryOptions {
limit: opts.graph_limit,
entity_type: None,
keyword: None,
graph_depth: opts.graph_depth as u32,
include_episodes: opts.include_episodes,
};
let QueryResult { entities, .. } = gm.query(question, &query_opts).await?;
let mut facts = Vec::with_capacity(entities.len());
let mut seen = std::collections::HashSet::new();
for scored in entities {
let key = scored.entity.name.to_lowercase();
if !seen.insert(key) {
continue;
}
let source = match &scored.source {
MatchSource::Semantic => "semantic".to_string(),
MatchSource::Keyword => "keyword".to_string(),
MatchSource::Graph { parent, rel_type } => {
format!("graph:{parent}/{rel_type}")
}
};
facts.push(RetrievedFact {
name: scored.entity.name,
entity_type: scored.entity.entity_type.to_string(),
abstract_text: scored.entity.abstract_text,
overview: scored.entity.overview,
score: scored.score,
source,
});
}
Ok(facts)
}
async fn retrieve_episodes(
memory_dir: &Path,
question: &str,
opts: &AnswerOpts,
) -> Result<Vec<RetrievedEpisode>, RecallError> {
let archive_budget = opts.episode_char_budget / ARCHIVE_BUDGET_DIVISOR;
let graph_budget = opts.episode_char_budget - archive_budget;
let mut episodes = fit_within_budget(
search_graph_episodes(memory_dir, question, opts).await?,
graph_budget,
);
episodes.extend(fit_within_budget(
search_archive(memory_dir, question, opts)?,
archive_budget,
));
Ok(episodes)
}
async fn search_graph_episodes(
memory_dir: &Path,
question: &str,
opts: &AnswerOpts,
) -> Result<Vec<RetrievedEpisode>, RecallError> {
let graph_dir = memory_dir.join("graph");
if !opts.include_episodes || !graph_dir.exists() {
return Ok(Vec::new());
}
let gm = GraphMemory::open(&graph_dir).await?;
let found = gm
.search_episodes(question, opts.episode_top_k.max(1))
.await?;
Ok(found
.into_iter()
.map(|ep| RetrievedEpisode {
source: "graph-episode".to_string(),
abstract_text: ep.episode.abstract_text,
session_id: Some(ep.episode.session_id),
log_number: ep.episode.log_number,
score: ep.score,
})
.collect())
}
fn search_archive(
memory_dir: &Path,
question: &str,
opts: &AnswerOpts,
) -> Result<Vec<RetrievedEpisode>, RecallError> {
if !memory_dir.join("conversations").exists() {
return Ok(Vec::new());
}
let ranked = match search::ranked_search(question, memory_dir, opts.archive_top_k) {
Ok(ranked) => ranked,
Err(RecallError::NotInitialized(_)) => return Ok(Vec::new()),
Err(e) => return Err(e),
};
Ok(ranked
.into_iter()
.map(|file| RetrievedEpisode {
source: format!("archive:{}", file.file),
abstract_text: file.preview_lines.join(" / "),
session_id: None,
log_number: None,
score: file.score,
})
.collect())
}
pub(super) fn fit_within_budget(
episodes: Vec<RetrievedEpisode>,
budget: usize,
) -> Vec<RetrievedEpisode> {
let mut spent = 0usize;
episodes
.into_iter()
.enumerate()
.take_while(|(index, ep)| {
spent += ep.abstract_text.len();
*index == 0 || spent <= budget
})
.map(|(_, ep)| ep)
.collect()
}
fn read_memory_md(memory_dir: &Path) -> String {
fs::read_to_string(memory_dir.join("MEMORY.md")).unwrap_or_default()
}
fn build_system_prompt() -> String {
"You are answering a question based on your memory of past conversations. \
Use only the facts and episodes provided. If the memory does not contain \
the answer, reply exactly: \"I don't have enough information to answer.\""
.to_string()
}
fn build_user_message(
memory_md: &str,
facts: &[RetrievedFact],
episodes: &[RetrievedEpisode],
question: &str,
) -> String {
let mut buf = String::new();
if !memory_md.trim().is_empty() {
buf.push_str("## Curated memory\n\n");
buf.push_str(memory_md.trim());
buf.push_str("\n\n");
}
buf.push_str("## Memory facts\n\n");
if facts.is_empty() {
buf.push_str("(none)\n\n");
} else {
for fact in facts {
buf.push_str(&format!(
"- **{}** ({}, score {:.2}): {}\n",
fact.name,
fact.entity_type,
fact.score,
fact.abstract_text.trim()
));
if !fact.overview.trim().is_empty() {
buf.push_str(&format!(" {}\n", fact.overview.trim()));
}
}
buf.push('\n');
}
buf.push_str("## Recent episodes\n\n");
if episodes.is_empty() {
buf.push_str("(none)\n\n");
} else {
for ep in episodes {
let session = ep.session_id.as_deref().unwrap_or("-");
buf.push_str(&format!(
"- [{}] session={} score={:.2}: {}\n",
ep.source,
session,
ep.score,
ep.abstract_text.trim()
));
}
buf.push('\n');
}
buf.push_str("## Question\n\n");
buf.push_str(question);
buf.push_str("\n\nAnswer concisely. State only the answer; do not narrate your reasoning.\n");
buf
}
fn estimate_tokens(s: &str) -> u32 {
s.len().div_ceil(4) as u32
}