use anyhow::Result;
use serde::Serialize;
use crate::embed::ollama::OllamaClient;
use crate::search::hybrid::SearchResult;
use crate::vector;
#[derive(Debug, Clone)]
pub struct AnswerOptions {
pub communities: usize,
pub evidence: usize,
pub summary_chars: usize,
pub chunk_chars: usize,
}
impl Default for AnswerOptions {
fn default() -> Self {
Self {
communities: 3,
evidence: 5,
summary_chars: 500,
chunk_chars: 300,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Source {
pub label: String,
pub path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AnswerResult {
pub answer: String,
pub sources: Vec<Source>,
}
impl AnswerResult {
pub fn sources_from_results(results: &[SearchResult]) -> Vec<Source> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut sources: Vec<Source> = Vec::new();
for r in results {
let key = if r.file.is_empty() {
r.label.clone()
} else {
r.file.clone()
};
if seen.insert(key) {
sources.push(Source {
label: r.label.clone(),
path: if r.file.is_empty() {
None
} else {
Some(r.file.clone())
},
});
}
}
sources
}
}
pub fn build_rag_prompt(
query: &str,
communities: &[(String, String)],
results: &[SearchResult],
opts: &AnswerOptions,
) -> String {
let truncated: Vec<(String, String)> = communities
.iter()
.take(opts.communities)
.map(|(label, summary)| (label.clone(), truncate_chars(summary, opts.summary_chars)))
.collect();
let community_refs: Vec<(&str, &str)> = truncated
.iter()
.map(|(label, summary)| (label.as_str(), summary.as_str()))
.collect();
let community_context = format_community_context(&community_refs);
let mut sorted_results: Vec<&SearchResult> = results.iter().collect();
sorted_results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let top_results: Vec<&SearchResult> = sorted_results.into_iter().take(opts.evidence).collect();
let chunk_evidence = format_chunk_evidence_limited(&top_results, opts.chunk_chars);
build_answer_prompt(&community_context, &chunk_evidence, query)
}
pub fn answer_query(
ollama: &OllamaClient,
query: &str,
results: &[SearchResult],
community_embeddings: &[(i64, Vec<f32>, String, String)],
summary_model: &str,
opts: &AnswerOptions,
) -> Result<AnswerResult> {
if results.is_empty() {
return Ok(AnswerResult {
answer: "No relevant information found.".into(),
sources: Vec::new(),
});
}
let sources = AnswerResult::sources_from_results(results);
let query_emb = ollama.embed(query)?;
let mut scored: Vec<(f32, &str, &str)> = community_embeddings
.iter()
.map(|(_id, emb, label, summary)| {
let sim = vector::cosine_similarity_raw(&query_emb, emb);
(sim, label.as_str(), summary.as_str())
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let top_communities: Vec<(String, String)> = scored
.into_iter()
.take(opts.communities)
.filter(|(sim, _, _)| *sim > 0.0)
.map(|(_, label, summary)| (label.to_string(), summary.to_string()))
.collect();
let prompt = build_rag_prompt(query, &top_communities, results, opts);
let answer = ollama.generate(&prompt, summary_model, None)?;
Ok(AnswerResult { answer, sources })
}
fn format_community_context(communities: &[(&str, &str)]) -> String {
if communities.is_empty() {
return String::new();
}
let mut out = String::from("## Related Knowledge Communities\n");
for (label, summary) in communities {
out.push_str("\n\nCommunity: ");
out.push_str(label);
out.push('\n');
out.push_str(summary);
}
out
}
#[allow(dead_code)] fn format_chunk_evidence(results: &[&SearchResult]) -> String {
format_chunk_evidence_limited(results, 300)
}
fn format_chunk_evidence_limited(results: &[&SearchResult], max_chars: usize) -> String {
let entries: Vec<String> = results
.iter()
.filter(|r| !r.chunk_text.is_empty())
.map(|r| {
let preview = truncate_chars(&r.chunk_text, max_chars);
format!("Note: {} > {}\n{}", r.label, r.chunk_header, preview)
})
.collect();
if entries.is_empty() {
return String::new();
}
let mut out = String::from("## Relevant Notes\n\n");
out.push_str(&entries.join("\n\n"));
out
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars).collect();
format!("{}...", truncated)
}
}
fn build_answer_prompt(community_context: &str, chunk_evidence: &str, query: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
parts.push(
"You are a knowledgeable assistant that answers questions based on provided context.",
);
if !community_context.is_empty() {
parts.push(community_context);
}
if !chunk_evidence.is_empty() {
parts.push(chunk_evidence);
}
let question = format!("\nQuestion: {query}");
parts.push(&question);
parts.push(
"\nAnswer the question based on the context above. If the context doesn't contain enough \
information, say so. Be concise but thorough.",
);
parts.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::expand::Neighbor;
fn make_result(label: &str, score: f64, chunk_header: &str, chunk_text: &str) -> SearchResult {
SearchResult {
id: 0,
label: label.to_string(),
r#type: "note".into(),
score,
content: String::new(),
file: String::new(),
neighbors: Vec::<Neighbor>::new(),
chunk_header: chunk_header.to_string(),
chunk_text: chunk_text.to_string(),
}
}
#[test]
fn test_format_community_context_empty() {
let result = format_community_context(&[]);
assert_eq!(result, "", "empty slice should produce empty string");
}
#[test]
fn test_format_community_context_single() {
let communities = &[("web-dev", "A community about web development tools.")];
let result = format_community_context(communities);
assert!(result.contains("## Related Knowledge Communities"));
assert!(result.contains("Community: web-dev"));
assert!(result.contains("A community about web development tools."));
}
#[test]
fn test_format_community_context_multiple() {
let communities = &[
("ai-ml", "Artificial intelligence and machine learning."),
("web-dev", "Web development frameworks."),
("databases", "Database technologies."),
];
let result = format_community_context(communities);
assert!(result.contains("Community: ai-ml"));
assert!(result.contains("Community: web-dev"));
assert!(result.contains("Community: databases"));
assert!(result.contains("Artificial intelligence and machine learning."));
assert!(result.contains("Web development frameworks."));
assert!(result.contains("Database technologies."));
}
#[test]
fn test_format_chunk_evidence_empty() {
let result = format_chunk_evidence(&[]);
assert_eq!(result, "", "empty results should produce empty string");
}
#[test]
fn test_format_chunk_evidence_skips_empty_chunk_text() {
let r = make_result("Python", 0.9, "Introduction", "");
let results = &[&r]; let result = format_chunk_evidence(results);
assert_eq!(
result, "",
"results with empty chunk_text should be skipped"
);
}
#[test]
fn test_format_chunk_evidence_single() {
let r = make_result(
"Python",
0.9,
"Introduction",
"Python is a high-level programming language.",
);
let results = &[&r];
let result = format_chunk_evidence(results);
assert!(result.contains("## Relevant Notes"));
assert!(result.contains("Note: Python > Introduction"));
assert!(result.contains("Python is a high-level programming language."));
}
#[test]
fn test_format_chunk_evidence_truncation() {
let long_text = "A".repeat(500);
let r = make_result("Test", 1.0, "Header", &long_text);
let results = &[&r];
let result = format_chunk_evidence(results);
assert!(result.contains("..."));
let preview_start = result.find("Note: Test > Header").unwrap();
let after_preview = &result[preview_start..];
let text_start = after_preview.find('\n').unwrap() + 1;
let preview_text = &after_preview[text_start..];
assert!(
preview_text.len() <= 303,
"preview should be <= 303 chars, got {}",
preview_text.len()
);
}
#[test]
fn test_format_chunk_evidence_multiple() {
let r1 = make_result("Rust", 0.95, "Ownership", "Rust's ownership system.");
let r2 = make_result("Python", 0.90, "Intro", "Python is dynamic.");
let results = &[&r1, &r2];
let result = format_chunk_evidence(results);
assert!(result.contains("Note: Rust > Ownership"));
assert!(result.contains("Note: Python > Intro"));
assert!(result.contains("Rust's ownership system."));
assert!(result.contains("Python is dynamic."));
}
#[test]
fn test_build_answer_prompt_structure() {
let community_context = "## Related Knowledge Communities\n\nCommunity: test\nA test.";
let chunk_evidence = "## Relevant Notes\n\nNote: Foo > Bar\nContent.";
let query = "What is this about?";
let prompt = build_answer_prompt(community_context, chunk_evidence, query);
assert!(
prompt.contains("You are a knowledgeable assistant"),
"should contain system instruction"
);
assert!(
prompt.contains("Related Knowledge Communities"),
"should contain community context"
);
assert!(
prompt.contains("Relevant Notes"),
"should contain chunk evidence"
);
assert!(prompt.contains("Question: What is this about?"));
assert!(
prompt.contains("If the context doesn't contain enough information"),
"should contain fallback instruction"
);
}
#[test]
fn test_build_answer_prompt_no_context() {
let prompt = build_answer_prompt("", "", "Hello");
assert!(prompt.contains("You are a knowledgeable assistant"));
assert!(prompt.contains("Question: Hello"));
assert!(!prompt.contains("Related Knowledge Communities"));
assert!(!prompt.contains("Relevant Notes"));
}
#[test]
fn test_answer_query_empty_results() {
let ollama = OllamaClient::new("http://localhost:11434", "nomic-embed-text");
let result = answer_query(
&ollama,
"test",
&[],
&[],
"llama3.2:3b",
&AnswerOptions::default(),
);
assert_eq!(
result.unwrap().answer,
"No relevant information found.",
"empty results should produce the 'no info' message"
);
}
fn make_note_result(
label: &str,
path: &str,
chunk_header: &str,
chunk_text: &str,
) -> SearchResult {
let mut r = make_result(label, 0.8, chunk_header, chunk_text);
r.file = path.to_string();
r
}
#[test]
fn test_answer_result_sources_deduplicated() {
let results = vec![
make_note_result("nginx", "/notes/nginx.md", "Setup", "server { listen 80; }"),
make_note_result(
"nginx",
"/notes/nginx.md",
"TLS",
"ssl_certificate /etc/ssl/cert.pem;",
),
make_note_result(
"docker",
"/notes/docker.md",
"Intro",
"Docker runs containers.",
),
make_note_result("rust", "/notes/rust.md", "Ownership", "Rust owns memory."),
];
let sources = AnswerResult::sources_from_results(&results);
assert_eq!(
sources.len(),
3,
"one source per distinct note, no duplicates even with 2 chunks of 'nginx'"
);
let unique: std::collections::HashSet<&str> =
sources.iter().map(|s| s.label.as_str()).collect();
assert_eq!(unique.len(), 3, "all source labels must be unique");
let nginx = sources
.iter()
.find(|s| s.label == "nginx")
.expect("nginx must appear once");
assert_eq!(
nginx.path.as_deref(),
Some("/notes/nginx.md"),
"source path must point at the underlying note"
);
}
#[test]
fn test_rag_prompt_respects_options() {
let opts = AnswerOptions {
communities: 1,
evidence: 3,
..AnswerOptions::default()
};
let communities = vec![
(
"ai-ml".to_string(),
"Artificial intelligence and machine learning.".to_string(),
),
(
"web-dev".to_string(),
"Web development frameworks.".to_string(),
),
(
"databases".to_string(),
"Database technologies.".to_string(),
),
];
let results = vec![
make_note_result("nginx", "/notes/nginx.md", "Setup", "server { listen 80; }"),
make_note_result(
"docker",
"/notes/docker.md",
"Intro",
"Docker runs containers.",
),
make_note_result("rust", "/notes/rust.md", "Ownership", "Rust owns memory."),
make_note_result("sqlite", "/notes/sqlite.md", "FTS", "SQLite FTS5 search."),
make_note_result("linux", "/notes/linux.md", "Systemd", "systemd unit files."),
];
let prompt = build_rag_prompt("nginx", &communities, &results, &opts);
assert_eq!(
prompt.matches("Community: ").count(),
1,
"opts.communities = 1 must include exactly one community summary"
);
assert_eq!(
prompt.matches("Note: ").count(),
3,
"opts.evidence = 3 must include exactly three chunk evidences"
);
}
#[test]
fn test_rag_prompt_without_communities() {
let results = vec![
make_note_result("nginx", "/notes/nginx.md", "Setup", "server { listen 80; }"),
make_note_result(
"docker",
"/notes/docker.md",
"Intro",
"Docker runs containers.",
),
];
let prompt = build_rag_prompt("nginx", &[], &results, &AnswerOptions::default());
assert!(
!prompt.contains("Related Knowledge Communities"),
"prompt must NOT contain a community section when there are no communities"
);
assert!(
prompt.contains("Relevant Notes"),
"prompt must still build chunk evidence"
);
assert_eq!(prompt.matches("Note: ").count(), 2);
assert!(prompt.contains("Question: nginx"));
}
#[test]
#[ignore = "requires Ollama with nomic-embed-text and llama3.2:3b"]
#[allow(clippy::len_zero)]
fn test_ask_end_to_end() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("demo.db");
let db = db_path.to_str().expect("utf-8 path");
crate::seed::demo_data::create_demo_db(db, "http://localhost:11434", "nomic-embed-text")
.expect("seed demo db");
let ollama = OllamaClient::new("http://localhost:11434", "nomic-embed-text");
let mut hs = crate::search::HybridSearch::new(db, ollama.clone()).expect("hybrid search");
let results = hs
.hybrid_search("nginx", 5, 2, 0.7, None, false, &[])
.expect("hybrid_search");
assert!(
!results.is_empty(),
"demo db should yield results for 'nginx'"
);
let conn = rusqlite::Connection::open(db).expect("open db");
let communities =
crate::db::communities::load_all_community_embeddings(&conn).unwrap_or_default();
let answer = answer_query(
&ollama,
"nginx",
&results,
&communities,
"llama3.2:3b",
&AnswerOptions::default(),
)
.expect("answer generation");
assert!(!answer.answer.trim().is_empty(), "answer must be non-empty");
assert!(
answer.sources.len() >= 1,
"answer must report at least one source"
);
}
}