1use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::{mpsc, oneshot};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CriticResult {
15 pub approved: bool,
17 pub score: f64,
19 pub dimension_scores: std::collections::HashMap<String, f64>,
21 pub feedback: String,
23 pub reviewer: ReviewerIdentity,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type")]
30pub enum ReviewerIdentity {
31 #[serde(rename = "llm")]
33 Llm { model_id: String },
34 #[serde(rename = "human")]
36 Human { user_id: String, name: String },
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ReviewRequest {
42 pub review_id: String,
44 pub content: String,
46 pub context: String,
48 pub rubric_dimensions: Vec<String>,
50 pub requested_at: chrono::DateTime<chrono::Utc>,
52 pub deadline: chrono::DateTime<chrono::Utc>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ReviewResponse {
59 pub review_id: String,
61 pub result: CriticResult,
63}
64
65#[async_trait::async_trait]
67pub trait Critic: Send + Sync {
68 async fn evaluate(&self, content: &str, context: &str) -> Result<CriticResult, CriticError>;
70
71 fn critic_type(&self) -> &str;
73}
74
75pub struct HumanCritic {
77 review_sender: mpsc::Sender<(ReviewRequest, oneshot::Sender<ReviewResponse>)>,
79 timeout: Duration,
81 default_reviewer: String,
83}
84
85impl HumanCritic {
86 pub fn new(
91 timeout: Duration,
92 ) -> (
93 Self,
94 mpsc::Receiver<(ReviewRequest, oneshot::Sender<ReviewResponse>)>,
95 ) {
96 let (tx, rx) = mpsc::channel(32);
97 let critic = Self {
98 review_sender: tx,
99 timeout,
100 default_reviewer: "human".into(),
101 };
102 (critic, rx)
103 }
104
105 pub fn with_reviewer_name(mut self, name: impl Into<String>) -> Self {
107 self.default_reviewer = name.into();
108 self
109 }
110}
111
112#[async_trait::async_trait]
113impl Critic for HumanCritic {
114 async fn evaluate(&self, content: &str, context: &str) -> Result<CriticResult, CriticError> {
115 let review_id = uuid::Uuid::new_v4().to_string();
116 let now = chrono::Utc::now();
117
118 let request = ReviewRequest {
119 review_id: review_id.clone(),
120 content: content.to_string(),
121 context: context.to_string(),
122 rubric_dimensions: Vec::new(),
123 requested_at: now,
124 deadline: now + chrono::Duration::from_std(self.timeout).unwrap_or_default(),
125 };
126
127 let (response_tx, response_rx) = oneshot::channel();
128
129 self.review_sender
131 .send((request, response_tx))
132 .await
133 .map_err(|_| CriticError::ChannelClosed)?;
134
135 match tokio::time::timeout(self.timeout, response_rx).await {
137 Ok(Ok(response)) => Ok(response.result),
138 Ok(Err(_)) => Err(CriticError::ChannelClosed),
139 Err(_) => Err(CriticError::Timeout {
140 review_id,
141 timeout: self.timeout,
142 }),
143 }
144 }
145
146 fn critic_type(&self) -> &str {
147 "human"
148 }
149}
150
151pub struct LlmCritic {
153 provider: Arc<dyn crate::reasoning::inference::InferenceProvider>,
155 system_prompt: String,
157 model_id: String,
159}
160
161impl LlmCritic {
162 pub fn new(
164 provider: Arc<dyn crate::reasoning::inference::InferenceProvider>,
165 system_prompt: impl Into<String>,
166 ) -> Self {
167 let model_id = provider.default_model().to_string();
168 Self {
169 provider,
170 system_prompt: system_prompt.into(),
171 model_id,
172 }
173 }
174}
175
176#[async_trait::async_trait]
177impl Critic for LlmCritic {
178 async fn evaluate(&self, content: &str, context: &str) -> Result<CriticResult, CriticError> {
179 use crate::reasoning::conversation::{Conversation, ConversationMessage};
180 use crate::reasoning::inference::{InferenceOptions, ResponseFormat};
181
182 let mut conv = Conversation::with_system(&self.system_prompt);
183 conv.push(ConversationMessage::user(format!(
184 "Context: {}\n\nContent to review:\n{}",
185 context, content
186 )));
187
188 let schema = serde_json::json!({
189 "type": "object",
190 "properties": {
191 "approved": {"type": "boolean"},
192 "score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
193 "feedback": {"type": "string"}
194 },
195 "required": ["approved", "score", "feedback"]
196 });
197
198 let options = InferenceOptions {
199 response_format: ResponseFormat::JsonSchema {
200 schema,
201 name: Some("critic_evaluation".into()),
202 },
203 ..InferenceOptions::default()
204 };
205
206 let response = self.provider.complete(&conv, &options).await.map_err(|e| {
207 CriticError::InferenceError {
208 message: e.to_string(),
209 }
210 })?;
211
212 let parsed: serde_json::Value =
214 serde_json::from_str(&response.content).map_err(|e| CriticError::ParseError {
215 message: e.to_string(),
216 })?;
217
218 Ok(CriticResult {
219 approved: parsed["approved"].as_bool().unwrap_or(false),
220 score: parsed["score"].as_f64().unwrap_or(0.0),
221 dimension_scores: std::collections::HashMap::new(),
222 feedback: parsed["feedback"].as_str().unwrap_or("").to_string(),
223 reviewer: ReviewerIdentity::Llm {
224 model_id: self.model_id.clone(),
225 },
226 })
227 }
228
229 fn critic_type(&self) -> &str {
230 "llm"
231 }
232}
233
234#[derive(Debug, thiserror::Error)]
236pub enum CriticError {
237 #[error("Review timed out (review_id={review_id}, timeout={timeout:?})")]
238 Timeout {
239 review_id: String,
240 timeout: Duration,
241 },
242
243 #[error("Review channel closed")]
244 ChannelClosed,
245
246 #[error("Inference error: {message}")]
247 InferenceError { message: String },
248
249 #[error("Failed to parse critic response: {message}")]
250 ParseError { message: String },
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_critic_result_serde() {
259 let result = CriticResult {
260 approved: true,
261 score: 0.85,
262 dimension_scores: {
263 let mut m = std::collections::HashMap::new();
264 m.insert("accuracy".into(), 0.9);
265 m.insert("clarity".into(), 0.8);
266 m
267 },
268 feedback: "Good analysis.".into(),
269 reviewer: ReviewerIdentity::Llm {
270 model_id: "claude-sonnet".into(),
271 },
272 };
273
274 let json = serde_json::to_string(&result).unwrap();
275 let restored: CriticResult = serde_json::from_str(&json).unwrap();
276 assert!(restored.approved);
277 assert!((restored.score - 0.85).abs() < f64::EPSILON);
278 assert_eq!(restored.dimension_scores.len(), 2);
279 }
280
281 #[test]
282 fn test_review_request_serde() {
283 let request = ReviewRequest {
284 review_id: "test-123".into(),
285 content: "Content to review".into(),
286 context: "Generated by agent X".into(),
287 rubric_dimensions: vec!["accuracy".into(), "completeness".into()],
288 requested_at: chrono::Utc::now(),
289 deadline: chrono::Utc::now() + chrono::Duration::minutes(5),
290 };
291
292 let json = serde_json::to_string(&request).unwrap();
293 let restored: ReviewRequest = serde_json::from_str(&json).unwrap();
294 assert_eq!(restored.review_id, "test-123");
295 assert_eq!(restored.rubric_dimensions.len(), 2);
296 }
297
298 #[test]
299 fn test_reviewer_identity_serde() {
300 let llm = ReviewerIdentity::Llm {
301 model_id: "gpt-4".into(),
302 };
303 let json = serde_json::to_string(&llm).unwrap();
304 assert!(json.contains("\"type\":\"llm\""));
305
306 let human = ReviewerIdentity::Human {
307 user_id: "user-1".into(),
308 name: "Alice".into(),
309 };
310 let json = serde_json::to_string(&human).unwrap();
311 assert!(json.contains("\"type\":\"human\""));
312 }
313
314 #[tokio::test]
315 async fn test_human_critic_timeout() {
316 let (critic, _rx) = HumanCritic::new(Duration::from_millis(50));
317 let result = critic.evaluate("test content", "test context").await;
319 assert!(result.is_err());
320 match result.unwrap_err() {
321 CriticError::Timeout { .. } => {}
322 other => panic!("Expected Timeout, got {:?}", other),
323 }
324 }
325
326 #[tokio::test]
327 async fn test_human_critic_response() {
328 let (critic, mut rx) = HumanCritic::new(Duration::from_secs(5));
329
330 tokio::spawn(async move {
332 if let Some((request, response_tx)) = rx.recv().await {
333 let _ = response_tx.send(ReviewResponse {
334 review_id: request.review_id,
335 result: CriticResult {
336 approved: true,
337 score: 0.9,
338 dimension_scores: std::collections::HashMap::new(),
339 feedback: "Looks good!".into(),
340 reviewer: ReviewerIdentity::Human {
341 user_id: "tester".into(),
342 name: "Test User".into(),
343 },
344 },
345 });
346 }
347 });
348
349 let result = critic
350 .evaluate("test content", "test context")
351 .await
352 .unwrap();
353 assert!(result.approved);
354 assert!((result.score - 0.9).abs() < f64::EPSILON);
355 assert_eq!(result.feedback, "Looks good!");
356 }
357
358 #[tokio::test]
359 async fn test_human_critic_channel_closed() {
360 let (critic, rx) = HumanCritic::new(Duration::from_secs(5));
361 drop(rx); let result = critic.evaluate("test", "context").await;
364 assert!(matches!(result.unwrap_err(), CriticError::ChannelClosed));
365 }
366}