1use lc_schema::Message;
10use serde::de::DeserializeOwned;
11
12use crate::language_models::{BaseChatModel, TokenUsage};
13use crate::tools::ToolDefinition;
14
15#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum StructuredJudgeError {
21 #[error("LLM call failed: {0}")]
23 Call(String),
24 #[error("structured parse failed: {0}")]
26 Parse(String),
27}
28
29pub 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
55pub 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
110pub 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 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 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 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}