use lc_core::language_models::BaseChatModel;
use lc_core::tools::ToolDefinition;
use lc_schema::Message;
use lc_vector_stores::Document;
use serde_json::{json, Value};
#[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 structured = crate::structured::chat_structured(
self.llm,
Some(grade_tool()),
messages,
None,
&crate::retry::RetryConfig::default(),
)
.await
.map_err(|e| GraderError::LLMError(e.to_string()))?;
if let Some(args) = &structured.tool_args {
if let Some(result) = grade_from_tool_args(args) {
return Ok(result);
}
}
parse_grade_response(&structured.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 lc_prompts::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 grade_tool() -> ToolDefinition {
ToolDefinition::new(
"grade_document",
"评估文档与查询的相关性,返回是否相关、0.0-1.0 分数与简要理由",
)
.with_parameters(json!({
"type": "object",
"properties": {
"relevant": {
"type": "boolean",
"description": "文档是否包含直接回答查询的信息"
},
"score": {
"type": "number",
"description": "相关性分数,1.0 完全相关,0.0 完全无关"
},
"reasoning": {
"type": "string",
"description": "简要理由"
}
},
"required": ["relevant", "score"]
}))
}
fn grade_from_tool_args(args: &Value) -> Option<GradeResult> {
let score = args.get("score").and_then(|v| v.as_f64())?.clamp(0.0, 1.0);
let reasoning = args
.get("reasoning")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(GradeResult {
score,
reasoning,
is_ambiguous: false,
})
}
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)]
#[non_exhaustive]
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_grade_from_tool_args() {
let args = json!({"relevant": true, "score": 0.9, "reasoning": "direct match"});
let result = grade_from_tool_args(&args).unwrap();
assert!((result.score - 0.9).abs() < 1e-9);
assert_eq!(result.reasoning.as_deref(), Some("direct match"));
assert!(!result.is_ambiguous);
}
#[test]
fn test_grade_from_tool_args_score_out_of_range() {
let args = json!({"relevant": true, "score": 5.0});
let result = grade_from_tool_args(&args).unwrap();
assert!((result.score - 1.0).abs() < 1e-9, "分数应被 clamp 到 1.0");
}
#[test]
fn test_grade_from_tool_args_missing_score() {
let args = json!({"relevant": true});
assert!(
grade_from_tool_args(&args).is_none(),
"缺 score 应回落文本解析"
);
}
#[test]
fn test_grade_tool_schema() {
let tool = grade_tool();
assert_eq!(tool.function.name, "grade_document");
assert!(tool.function.parameters.is_some());
}
#[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"
);
}
}