1use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::eval::Scorer;
19use crate::typed_id::{AgentId, AgentVersionId, HarnessId, ObserverId, SessionId, TraceScoreId};
20
21#[cfg(feature = "openapi")]
22use utoipa::ToSchema;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[cfg_attr(feature = "openapi", derive(ToSchema))]
31#[serde(rename_all = "lowercase")]
32pub enum ObserverStatus {
33 Active,
34 Paused,
35 Archived,
36 Deleted,
37}
38
39impl std::fmt::Display for ObserverStatus {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 ObserverStatus::Active => write!(f, "active"),
43 ObserverStatus::Paused => write!(f, "paused"),
44 ObserverStatus::Archived => write!(f, "archived"),
45 ObserverStatus::Deleted => write!(f, "deleted"),
46 }
47 }
48}
49
50impl From<&str> for ObserverStatus {
51 fn from(s: &str) -> Self {
52 match s {
53 "paused" => ObserverStatus::Paused,
54 "archived" => ObserverStatus::Archived,
55 "deleted" => ObserverStatus::Deleted,
56 _ => ObserverStatus::Active,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
70#[cfg_attr(feature = "openapi", derive(ToSchema))]
71pub struct ObserverMatch {
72 #[serde(skip_serializing_if = "Option::is_none")]
74 #[cfg_attr(feature = "openapi", schema(value_type = Option<Vec<String>>))]
75 pub agent_ids: Option<Vec<AgentId>>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 #[cfg_attr(feature = "openapi", schema(value_type = Option<Vec<String>>))]
79 pub harness_ids: Option<Vec<HarnessId>>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub session_tags: Option<Vec<String>>,
83}
84
85impl ObserverMatch {
86 pub fn matches(
88 &self,
89 agent_id: Option<AgentId>,
90 harness_id: Option<HarnessId>,
91 session_tags: &[String],
92 ) -> bool {
93 if let Some(agent_ids) = &self.agent_ids {
94 let Some(agent_id) = agent_id else {
95 return false;
96 };
97 if !agent_ids.contains(&agent_id) {
98 return false;
99 }
100 }
101 if let Some(harness_ids) = &self.harness_ids {
102 let Some(harness_id) = harness_id else {
103 return false;
104 };
105 if !harness_ids.contains(&harness_id) {
106 return false;
107 }
108 }
109 if let Some(tags) = &self.session_tags
110 && !tags.iter().any(|t| session_tags.contains(t))
111 {
112 return false;
113 }
114 true
115 }
116}
117
118#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
125#[cfg_attr(feature = "openapi", derive(ToSchema))]
126#[serde(rename_all = "lowercase")]
127pub enum ObserverScope {
128 #[default]
129 Turn,
130}
131
132impl std::fmt::Display for ObserverScope {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 ObserverScope::Turn => write!(f, "turn"),
136 }
137 }
138}
139
140fn default_pass_threshold() -> f64 {
142 0.5
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "openapi", derive(ToSchema))]
150pub struct LlmJudgeConfig {
151 pub rubric: String,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
158 pub model_id: Option<crate::typed_id::ModelId>,
159 #[serde(default = "default_pass_threshold")]
161 pub pass_threshold: f64,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(ToSchema))]
168#[serde(tag = "method", rename_all = "snake_case")]
169pub enum ScorerMethod {
170 Rule { rule: Scorer },
173 LlmJudge(LlmJudgeConfig),
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182pub struct ObserverScorerConfig {
183 pub key: String,
185 #[serde(default)]
187 pub scope: ObserverScope,
188 #[serde(flatten)]
190 pub method: ScorerMethod,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "openapi", derive(ToSchema))]
200pub struct Observer {
201 #[serde(rename = "id")]
203 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "observer_01933b5a000070008000000000000001"))]
204 pub public_id: ObserverId,
205 #[serde(skip, default = "Uuid::nil")]
207 pub internal_id: Uuid,
208 #[serde(skip, default)]
210 pub org_id: i64,
211 pub name: String,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub description: Option<String>,
216 #[serde(rename = "match", default)]
218 pub match_config: ObserverMatch,
219 pub sampling_rate: f64,
221 pub scorers: Vec<ObserverScorerConfig>,
223 pub status: ObserverStatus,
225 pub created_at: DateTime<Utc>,
226 pub updated_at: DateTime<Utc>,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub archived_at: Option<DateTime<Utc>>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[cfg_attr(feature = "openapi", derive(ToSchema))]
238#[serde(rename_all = "lowercase")]
239pub enum TraceScoreStatus {
240 Pending,
241 Scoring,
242 Completed,
243 Errored,
244 Skipped,
245}
246
247impl std::fmt::Display for TraceScoreStatus {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 match self {
250 TraceScoreStatus::Pending => write!(f, "pending"),
251 TraceScoreStatus::Scoring => write!(f, "scoring"),
252 TraceScoreStatus::Completed => write!(f, "completed"),
253 TraceScoreStatus::Errored => write!(f, "errored"),
254 TraceScoreStatus::Skipped => write!(f, "skipped"),
255 }
256 }
257}
258
259impl From<&str> for TraceScoreStatus {
260 fn from(s: &str) -> Self {
261 match s {
262 "scoring" => TraceScoreStatus::Scoring,
263 "completed" => TraceScoreStatus::Completed,
264 "errored" => TraceScoreStatus::Errored,
265 "skipped" => TraceScoreStatus::Skipped,
266 _ => TraceScoreStatus::Pending,
267 }
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "openapi", derive(ToSchema))]
276pub struct TraceScore {
277 #[serde(rename = "id")]
279 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "score_01933b5a000070008000000000000001"))]
280 pub public_id: TraceScoreId,
281 #[serde(skip, default = "Uuid::nil")]
283 pub internal_id: Uuid,
284 #[serde(skip, default)]
286 pub org_id: i64,
287 #[cfg_attr(feature = "openapi", schema(value_type = String))]
289 pub observer_id: ObserverId,
290 pub scorer_key: String,
292 #[cfg_attr(feature = "openapi", schema(value_type = String))]
294 pub session_id: SessionId,
295 pub turn_id: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
300 pub agent_id: Option<AgentId>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
304 pub agent_version_id: Option<AgentVersionId>,
305 #[serde(skip_serializing_if = "Option::is_none")]
307 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
308 pub harness_id: Option<HarnessId>,
309 pub status: TraceScoreStatus,
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub pass: Option<bool>,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub value: Option<f64>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub label: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
323 pub reason: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub judge_input_tokens: Option<u64>,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub judge_output_tokens: Option<u64>,
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub judge_cost_usd: Option<f64>,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub error_message: Option<String>,
336 pub created_at: DateTime<Utc>,
337 pub updated_at: DateTime<Utc>,
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn observer_status_roundtrip() {
346 for (s, v) in [
347 ("active", ObserverStatus::Active),
348 ("paused", ObserverStatus::Paused),
349 ("archived", ObserverStatus::Archived),
350 ("deleted", ObserverStatus::Deleted),
351 ] {
352 assert_eq!(ObserverStatus::from(s), v);
353 assert_eq!(v.to_string(), s);
354 }
355 assert_eq!(ObserverStatus::from("unknown"), ObserverStatus::Active);
356 }
357
358 #[test]
359 fn trace_score_status_roundtrip() {
360 for (s, v) in [
361 ("pending", TraceScoreStatus::Pending),
362 ("scoring", TraceScoreStatus::Scoring),
363 ("completed", TraceScoreStatus::Completed),
364 ("errored", TraceScoreStatus::Errored),
365 ("skipped", TraceScoreStatus::Skipped),
366 ] {
367 assert_eq!(TraceScoreStatus::from(s), v);
368 assert_eq!(v.to_string(), s);
369 }
370 assert_eq!(TraceScoreStatus::from("unknown"), TraceScoreStatus::Pending);
371 }
372
373 #[test]
374 fn empty_match_matches_everything() {
375 let m = ObserverMatch::default();
376 assert!(m.matches(None, None, &[]));
377 assert!(m.matches(Some(AgentId::new()), Some(HarnessId::new()), &["x".into()]));
378 }
379
380 #[test]
381 fn match_agent_predicate() {
382 let agent = AgentId::new();
383 let m = ObserverMatch {
384 agent_ids: Some(vec![agent]),
385 ..Default::default()
386 };
387 assert!(m.matches(Some(agent), None, &[]));
388 assert!(!m.matches(Some(AgentId::new()), None, &[]));
389 assert!(!m.matches(None, None, &[]));
390 }
391
392 #[test]
393 fn match_harness_predicate() {
394 let harness = HarnessId::new();
395 let m = ObserverMatch {
396 harness_ids: Some(vec![harness]),
397 ..Default::default()
398 };
399 assert!(m.matches(None, Some(harness), &[]));
400 assert!(!m.matches(None, Some(HarnessId::new()), &[]));
401 assert!(!m.matches(None, None, &[]));
402 }
403
404 #[test]
405 fn match_tags_any_of() {
406 let m = ObserverMatch {
407 session_tags: Some(vec!["prod".into(), "beta".into()]),
408 ..Default::default()
409 };
410 assert!(m.matches(None, None, &["beta".into()]));
411 assert!(!m.matches(None, None, &["other".into()]));
412 assert!(!m.matches(None, None, &[]));
413 }
414
415 #[test]
416 fn match_predicates_are_anded() {
417 let agent = AgentId::new();
418 let m = ObserverMatch {
419 agent_ids: Some(vec![agent]),
420 session_tags: Some(vec!["prod".into()]),
421 ..Default::default()
422 };
423 assert!(m.matches(Some(agent), None, &["prod".into()]));
424 assert!(!m.matches(Some(agent), None, &[]));
425 assert!(!m.matches(None, None, &["prod".into()]));
426 }
427
428 #[test]
429 fn scorer_config_serde_defaults_scope() {
430 let json = serde_json::json!({
431 "key": "greeting",
432 "method": "rule",
433 "rule": { "type": "contains", "text": "hello" }
434 });
435 let config: ObserverScorerConfig = serde_json::from_value(json).unwrap();
436 assert_eq!(config.scope, ObserverScope::Turn);
437 assert_eq!(config.key, "greeting");
438 assert!(matches!(config.method, ScorerMethod::Rule { .. }));
439 }
440
441 #[test]
442 fn scorer_config_llm_judge_serde() {
443 let json = serde_json::json!({
444 "key": "completeness",
445 "scope": "turn",
446 "method": "llm_judge",
447 "rubric": "Score 1 if the answer fully addresses the question."
448 });
449 let config: ObserverScorerConfig = serde_json::from_value(json).unwrap();
450 assert_eq!(config.key, "completeness");
451 match config.method {
452 ScorerMethod::LlmJudge(j) => {
453 assert!(j.model_id.is_none());
454 assert_eq!(j.pass_threshold, 0.5);
455 assert!(j.rubric.contains("fully addresses"));
456 }
457 _ => panic!("expected llm_judge"),
458 }
459 }
460
461 #[test]
462 fn observer_serde_skips_internal_fields() {
463 let observer = Observer {
464 public_id: ObserverId::from_uuid(Uuid::nil()),
465 internal_id: Uuid::nil(),
466 org_id: 1,
467 name: "test".into(),
468 description: None,
469 match_config: ObserverMatch::default(),
470 sampling_rate: 0.1,
471 scorers: vec![],
472 status: ObserverStatus::Active,
473 created_at: Utc::now(),
474 updated_at: Utc::now(),
475 archived_at: None,
476 };
477 let json = serde_json::to_value(&observer).unwrap();
478 assert!(json.get("id").is_some());
479 assert!(json.get("match").is_some());
480 assert!(json.get("internal_id").is_none());
481 assert!(json.get("org_id").is_none());
482 assert_eq!(json["sampling_rate"], 0.1);
483 }
484}