use crate::core::language_models::BaseChatModel;
use crate::schema::Message;
use crate::vector_stores::Document;
#[derive(Debug, Clone)]
pub struct GradeResult {
pub score: f64,
pub reasoning: Option<String>,
pub is_ambiguous: bool,
}
pub struct DocumentGrader<'a, M: BaseChatModel> {
llm: &'a M,
}
impl<'a, M: BaseChatModel> DocumentGrader<'a, M> {
pub fn new(llm: &'a M) -> Self {
Self { llm }
}
pub async fn grade(
&self,
query: &str,
document: &Document,
) -> Result<GradeResult, GraderError> {
let prompt = build_grade_prompt(query, &document.content);
let messages = vec![Message::human(&prompt)];
let result = self
.llm
.chat(messages, None)
.await
.map_err(|e| GraderError::LLMError(e.to_string()))?;
parse_grade_response(&result.content)
}
pub async fn grade_all(
&self,
query: &str,
documents: &[Document],
) -> Result<Vec<GradeResult>, GraderError> {
use futures_util::future::join_all;
let futures: Vec<_> = documents.iter().map(|doc| self.grade(query, doc)).collect();
let results = join_all(futures).await;
results.into_iter().collect()
}
}
fn build_grade_prompt(query: &str, document_content: &str) -> String {
use crate::PromptTemplate;
use std::collections::HashMap;
let template = PromptTemplate::new(GRADE_PROMPT);
let mut vars = HashMap::new();
vars.insert("query", query);
vars.insert("document_content", document_content);
template.format(&vars).unwrap_or_else(|_| GRADE_PROMPT.to_string())
}
fn parse_grade_response(response: &str) -> Result<GradeResult, GraderError> {
let lower = response.to_lowercase();
let explicit_score = extract_numeric_score(&lower);
let (score, reasoning, is_ambiguous) = if let Some(s) = explicit_score {
(s.clamp(0.0, 1.0), Some(response.to_string()), false)
} else if lower.contains("relevant") && !lower.contains("irrelevant") {
(0.8, Some(response.to_string()), false)
} else if lower.contains("irrelevant") {
(0.2, Some(response.to_string()), false)
} else {
(0.4, Some(response.to_string()), true)
};
Ok(GradeResult {
score,
reasoning,
is_ambiguous,
})
}
fn extract_numeric_score(text: &str) -> Option<f64> {
for part in text.split(|c: char| c.is_whitespace() || c == ',' || c == ';') {
let trimmed = part.trim();
if let Some(slash_pos) = trimmed.find('/') {
let numerator_str = trimmed[..slash_pos].trim();
let denominator_str = trimmed[slash_pos + 1..].trim();
if let (Ok(num), Ok(den)) =
(numerator_str.parse::<f64>(), denominator_str.parse::<f64>())
{
if den > 0.0 {
let ratio = num / den;
if (0.0..=1.0).contains(&ratio) {
return Some(ratio);
}
}
}
}
}
for part in text.split(|c: char| c.is_whitespace() || c == ',' || c == ';') {
let trimmed = part.trim().to_lowercase();
if let Some(rest) = trimmed.strip_prefix("score") {
let candidate = rest.trim_start_matches([':', '=', ' ']);
if let Ok(val) = candidate.parse::<f64>() {
if (0.0..=1.0).contains(&val) {
return Some(val);
}
if (1.0..=10.0).contains(&val) {
return Some(val / 10.0);
}
}
}
}
for part in text.split(|c: char| !c.is_ascii_digit() && c != '.') {
if part.is_empty() {
continue;
}
if part.matches('.').count() > 1 {
continue;
}
if let Ok(val) = part.parse::<f64>() {
if (0.0..=1.0).contains(&val) {
return Some(val);
}
if (1.0..=10.0).contains(&val) {
return Some(val / 10.0);
}
}
}
None
}
#[derive(Debug, thiserror::Error)]
pub enum GraderError {
#[error("LLM error during grading: {0}")]
LLMError(String),
#[error("Failed to parse grading response: {0}")]
ParseError(String),
}
const GRADE_PROMPT: &str = r#"You are a document relevance grader. Given a user query and a document, determine if the document is relevant to answering the query.
Query: {query}
Document: {document_content}
Instructions:
1. Read the query and document carefully.
2. Determine if the document contains information that directly helps answer the query.
3. Respond with your assessment in this exact format:
Relevance: [relevant/irrelevant]
Score: [0.0 to 1.0]
Reasoning: [brief explanation]
A score of 1.0 means the document is perfectly relevant, 0.0 means completely irrelevant.
A document is "relevant" if it contains information that directly addresses the query.
A document is "irrelevant" if it does not contain useful information for answering the query.
Example:
Query: What is Rust programming language?
Document: Rust is a systems programming language focused on safety and performance.
Relevance: relevant
Score: 0.95
Reasoning: The document directly describes what Rust is."#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_relevant_response() {
let result = parse_grade_response(
"Relevance: relevant\nScore: 0.9\nReasoning: Document directly addresses the query.",
)
.unwrap();
assert!(result.score >= 0.8);
}
#[test]
fn test_parse_irrelevant_response() {
let result = parse_grade_response(
"Relevance: irrelevant\nScore: 0.1\nReasoning: Document is about a different topic.",
)
.unwrap();
assert!(result.score <= 0.3);
}
#[test]
fn test_parse_explicit_score() {
let result =
parse_grade_response("Score: 0.75, the document is somewhat relevant.").unwrap();
assert!((result.score - 0.75).abs() < 0.01);
}
#[test]
fn test_parse_ambiguous_response() {
let result = parse_grade_response("The document mentions the topic briefly.").unwrap();
assert!((result.score - 0.4).abs() < 0.01);
assert!(result.is_ambiguous);
}
#[test]
fn test_reasoning_field_populated_on_relevant() {
let result = parse_grade_response(
"Relevance: relevant\nScore: 0.9\nReasoning: Document directly addresses the query.",
)
.unwrap();
assert!(result.reasoning.is_some());
let reasoning = result.reasoning.unwrap();
assert!(
reasoning.contains("Reasoning"),
"reasoning should contain the original response text"
);
}
#[test]
fn test_reasoning_field_populated_on_irrelevant() {
let result = parse_grade_response(
"Relevance: irrelevant\nScore: 0.1\nReasoning: Off-topic.",
)
.unwrap();
assert!(result.reasoning.is_some());
assert!(result.reasoning.unwrap().contains("Off-topic"));
}
#[test]
fn test_reasoning_field_populated_on_ambiguous() {
let result = parse_grade_response("The document mentions the topic briefly.").unwrap();
assert!(result.reasoning.is_some());
assert!(result.reasoning.unwrap().contains("briefly"));
}
#[test]
fn test_extract_numeric_score_decimal() {
assert_eq!(extract_numeric_score("score: 0.85"), Some(0.85));
}
#[test]
fn test_extract_numeric_score_out_of_ten() {
assert_eq!(extract_numeric_score("7/10"), Some(0.7));
}
#[test]
fn test_extract_numeric_score_none() {
assert_eq!(extract_numeric_score("no score here"), None);
}
#[test]
fn test_build_grade_prompt() {
let prompt = build_grade_prompt("What is Rust?", "Rust is a systems language.");
assert!(prompt.contains("What is Rust?"));
assert!(prompt.contains("Rust is a systems language."));
assert!(prompt.contains("Relevance:"));
}
#[test]
fn test_grade_prompt_contains_few_shot_example() {
assert!(
GRADE_PROMPT.contains("Example:"),
"grade prompt should contain few-shot example"
);
assert!(
GRADE_PROMPT.contains("Rust"),
"grade prompt example should contain the Rust example"
);
assert!(
GRADE_PROMPT.contains("0.95"),
"grade prompt example should contain an example score"
);
}
}