Skip to main content

lc_core/
judge.rs

1//! Shared LLM judge infrastructure: lets an LLM return a verdict via structured arguments.
2//!
3//! Reused by `lc-evaluation` (scoring / pairwise / faithfulness judges) and `lc-guardrails`
4//! (LLM validators): prefer `bind_tools` for structured `tool_calls` arguments; when the model
5//! does not support tool binding or still returns plain text, fall back to the caller-provided
6//! text parsing. Each crate builds its own prompts and parse rules; what is shared is the common
7//! execution path "bind tool → take structured arguments → fall back to text".
8
9use lc_schema::Message;
10use serde::de::DeserializeOwned;
11
12use crate::language_models::{BaseChatModel, TokenUsage};
13use crate::tools::ToolDefinition;
14
15/// Structured judge-call errors: distinguishes "LLM call failure" from "structured parse failure",
16/// mapped by the caller into its own error domain (e.g. `EvalError::PredictorError` /
17/// `EvalError::ParseError`).
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum StructuredJudgeError {
21    /// Underlying LLM call failure (network / rate limit / abnormal reply).
22    #[error("LLM call failed: {0}")]
23    Call(String),
24    /// Tool-call arguments cannot be deserialized into the target type, or tool_calls is empty.
25    #[error("structured parse failed: {0}")]
26    Parse(String),
27}
28
29/// One LLM call, letting the judge return a verdict as structured arguments (T).
30///
31/// Flow:
32/// 1. If the model supports `bind_tools`, bind the verdict tool; parse and return the arguments when the reply has `tool_calls`.
33/// 2. Tool bound but still plain text → run the same reply's text through `text_fallback`.
34/// 3. Model without `bind_tools` → fall back to text parsing (`text_fallback`) and `log::warn!`.
35/// 4. Tool bound but `tool_calls` arguments cannot be parsed → explicit `StructuredJudgeError::Parse`,
36///    never a silent default.
37///
38/// Guarantees at most one LLM round trip per verdict: the bound path does not re-issue a tool-less call.
39pub async fn structured_call<M, T, F>(
40    judge: &M,
41    tool: ToolDefinition,
42    messages: Vec<Message>,
43    text_fallback: F,
44) -> Result<T, StructuredJudgeError>
45where
46    M: BaseChatModel,
47    T: DeserializeOwned,
48    F: FnOnce(&str) -> Result<T, StructuredJudgeError>,
49{
50    structured_call_with_usage(judge, tool, messages, text_fallback)
51        .await
52        .map(|(t, _usage)| t)
53}
54
55/// Like [`structured_call`], but also reports the LLM token usage of the single
56/// judge round trip (I3: so eval runners can credit judge/tool LLM cost).
57///
58/// Same guarantees as [`structured_call`]: at most one LLM call per verdict, tool
59/// bound → structured arguments; plain-text fallback when the model cannot bind or
60/// returns text. The usage is returned alongside the parsed verdict; `None` when
61/// the model does not report usage.
62pub async fn structured_call_with_usage<M, T, F>(
63    judge: &M,
64    tool: ToolDefinition,
65    messages: Vec<Message>,
66    text_fallback: F,
67) -> Result<(T, Option<TokenUsage>), StructuredJudgeError>
68where
69    M: BaseChatModel,
70    T: DeserializeOwned,
71    F: FnOnce(&str) -> Result<T, StructuredJudgeError>,
72{
73    if let Some(bound) = judge.bind_tools(vec![tool]) {
74        let result = bound
75            .chat(messages, None)
76            .await
77            .map_err(|e| StructuredJudgeError::Call(e.to_string()))?;
78        let usage = result.token_usage.clone();
79        match result.tool_calls {
80            Some(calls) => {
81                let call = calls.first().ok_or_else(|| {
82                    StructuredJudgeError::Parse("judge returned empty tool_calls".to_string())
83                })?;
84                let parsed = call.parse_arguments::<T>().map_err(|e| {
85                    StructuredJudgeError::Parse(format!(
86                        "failed to parse judge structured arguments: {}",
87                        e
88                    ))
89                })?;
90                Ok((parsed, usage))
91            }
92            None => {
93                log::warn!(
94                    "judge model bound tools but returned plain text; falling back to text parsing"
95                );
96                text_fallback(&result.content).map(|t| (t, usage))
97            }
98        }
99    } else {
100        log::warn!("judge model does not support bind_tools; falling back to text parsing");
101        let result = judge
102            .chat(messages, None)
103            .await
104            .map_err(|e| StructuredJudgeError::Call(e.to_string()))?;
105        let usage = result.token_usage.clone();
106        text_fallback(&result.content).map(|t| (t, usage))
107    }
108}
109
110/// Truncates long text for error messages, avoiding stuffing a whole LLM reply into an error.
111pub fn truncate(s: &str, max: usize) -> String {
112    if s.chars().count() <= max {
113        s.to_string()
114    } else {
115        let truncated: String = s.chars().take(max).collect();
116        format!("{}...", truncated)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use async_trait::async_trait;
124    use futures_util::Stream;
125    use lc_schema::Message;
126    use std::pin::Pin;
127    use std::sync::atomic::{AtomicUsize, Ordering};
128    use std::sync::Arc;
129
130    use crate::language_models::{LLMResult, StreamChunk};
131    use crate::{BaseLanguageModel, Runnable, RunnableConfig};
132
133    #[derive(Debug)]
134    struct JudgeError(String);
135    impl std::fmt::Display for JudgeError {
136        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137            write!(f, "{}", self.0)
138        }
139    }
140    impl std::error::Error for JudgeError {}
141
142    /// Mock judge returning preset replies in order, recording received messages for assertions
143    struct SeqMockJudge {
144        replies: Vec<String>,
145        call: Arc<AtomicUsize>,
146    }
147    impl SeqMockJudge {
148        fn new(replies: Vec<String>) -> Self {
149            Self {
150                replies,
151                call: Arc::new(AtomicUsize::new(0)),
152            }
153        }
154    }
155
156    #[async_trait]
157    impl Runnable<Vec<Message>, LLMResult> for SeqMockJudge {
158        type Error = JudgeError;
159        async fn invoke(
160            &self,
161            _input: Vec<Message>,
162            _config: Option<RunnableConfig>,
163        ) -> Result<LLMResult, Self::Error> {
164            Err(JudgeError("use chat".into()))
165        }
166    }
167
168    #[async_trait]
169    impl BaseLanguageModel<Vec<Message>, LLMResult> for SeqMockJudge {
170        fn model_name(&self) -> &str {
171            "seq-mock"
172        }
173        fn get_num_tokens(&self, t: &str) -> usize {
174            t.len()
175        }
176        fn with_temperature(self, _: f32) -> Self {
177            self
178        }
179        fn with_max_tokens(self, _: usize) -> Self {
180            self
181        }
182    }
183
184    #[async_trait]
185    impl BaseChatModel for SeqMockJudge {
186        async fn chat(
187            &self,
188            _messages: Vec<Message>,
189            _config: Option<RunnableConfig>,
190        ) -> Result<LLMResult, Self::Error> {
191            let idx = self.call.fetch_add(1, Ordering::SeqCst);
192            let reply = self.replies.get(idx).cloned().unwrap_or_default();
193            Ok(LLMResult {
194                content: reply,
195                model: "seq-mock".to_string(),
196                token_usage: None,
197                tool_calls: None,
198                thinking_content: None,
199            })
200        }
201        async fn stream_chat(
202            &self,
203            _messages: Vec<Message>,
204            _config: Option<RunnableConfig>,
205        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
206        {
207            Err(JudgeError("not supported".into()))
208        }
209    }
210
211    #[derive(serde::Deserialize, Debug)]
212    struct MockArgs {
213        verdict: String,
214    }
215
216    fn mock_tool() -> ToolDefinition {
217        ToolDefinition::new("mock_judge", "返回判定。")
218    }
219
220    #[tokio::test]
221    async fn test_fallback_on_text_only_model() {
222        // SeqMockJudge does not implement bind_tools → text fallback; the closure parses plain text.
223        let judge = SeqMockJudge::new(vec!["yes".into()]);
224        let messages = vec![Message::human("判断")];
225        let out = structured_call(&judge, mock_tool(), messages, |raw| {
226            Ok(MockArgs {
227                verdict: raw.trim().to_string(),
228            })
229        })
230        .await
231        .unwrap();
232        assert_eq!(out.verdict, "yes");
233    }
234
235    #[tokio::test]
236    async fn test_parse_error_raised_not_silently_defaulted() {
237        // text-fallback parse failure → explicit Parse, no silent default.
238        let judge = SeqMockJudge::new(vec!["没法判断".into()]);
239        let messages = vec![Message::human("判断")];
240        let err = structured_call(
241            &judge,
242            mock_tool(),
243            messages,
244            |_raw: &str| -> Result<MockArgs, StructuredJudgeError> {
245                Err(StructuredJudgeError::Parse("parse failed".into()))
246            },
247        )
248        .await
249        .unwrap_err();
250        assert!(matches!(err, StructuredJudgeError::Parse(_)));
251    }
252}