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 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 {
GRADE_PROMPT
.replace("{query}", query)
.replace("{document_content}", document_content)
}
fn parse_grade_response(response: &str) -> Result<GradeResult, GraderError> {
let lower = response.to_lowercase();
let explicit_score = extract_numeric_score(&lower);
let (score, reasoning) = if let Some(s) = explicit_score {
(s.clamp(0.0, 1.0), Some(response.to_string()))
} else if lower.contains("relevant") && !lower.contains("irrelevant") {
(0.8, Some(response.to_string()))
} else if lower.contains("irrelevant") {
(0.2, Some(response.to_string()))
} else {
(0.5, Some(response.to_string()))
};
Ok(GradeResult { score, reasoning })
}
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."#;
#[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.5).abs() < 0.01);
}
#[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:"));
}
}