use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::eval::Scorer;
use crate::typed_id::{AgentId, AgentVersionId, HarnessId, ObserverId, SessionId, TraceScoreId};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum ObserverStatus {
Active,
Paused,
Archived,
Deleted,
}
impl std::fmt::Display for ObserverStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ObserverStatus::Active => write!(f, "active"),
ObserverStatus::Paused => write!(f, "paused"),
ObserverStatus::Archived => write!(f, "archived"),
ObserverStatus::Deleted => write!(f, "deleted"),
}
}
}
impl From<&str> for ObserverStatus {
fn from(s: &str) -> Self {
match s {
"paused" => ObserverStatus::Paused,
"archived" => ObserverStatus::Archived,
"deleted" => ObserverStatus::Deleted,
_ => ObserverStatus::Active,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ObserverMatch {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Vec<String>>))]
pub agent_ids: Option<Vec<AgentId>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Vec<String>>))]
pub harness_ids: Option<Vec<HarnessId>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_tags: Option<Vec<String>>,
}
impl ObserverMatch {
pub fn matches(
&self,
agent_id: Option<AgentId>,
harness_id: Option<HarnessId>,
session_tags: &[String],
) -> bool {
if let Some(agent_ids) = &self.agent_ids {
let Some(agent_id) = agent_id else {
return false;
};
if !agent_ids.contains(&agent_id) {
return false;
}
}
if let Some(harness_ids) = &self.harness_ids {
let Some(harness_id) = harness_id else {
return false;
};
if !harness_ids.contains(&harness_id) {
return false;
}
}
if let Some(tags) = &self.session_tags
&& !tags.iter().any(|t| session_tags.contains(t))
{
return false;
}
true
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum ObserverScope {
#[default]
Turn,
}
impl std::fmt::Display for ObserverScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ObserverScope::Turn => write!(f, "turn"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ObserverScorerConfig {
pub key: String,
#[serde(default)]
pub scope: ObserverScope,
pub rule: Scorer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Observer {
#[serde(rename = "id")]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "observer_01933b5a000070008000000000000001"))]
pub public_id: ObserverId,
#[serde(skip, default = "Uuid::nil")]
pub internal_id: Uuid,
#[serde(skip, default)]
pub org_id: i64,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "match", default)]
pub match_config: ObserverMatch,
pub sampling_rate: f64,
pub scorers: Vec<ObserverScorerConfig>,
pub status: ObserverStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum TraceScoreStatus {
Pending,
Scoring,
Completed,
Errored,
Skipped,
}
impl std::fmt::Display for TraceScoreStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TraceScoreStatus::Pending => write!(f, "pending"),
TraceScoreStatus::Scoring => write!(f, "scoring"),
TraceScoreStatus::Completed => write!(f, "completed"),
TraceScoreStatus::Errored => write!(f, "errored"),
TraceScoreStatus::Skipped => write!(f, "skipped"),
}
}
}
impl From<&str> for TraceScoreStatus {
fn from(s: &str) -> Self {
match s {
"scoring" => TraceScoreStatus::Scoring,
"completed" => TraceScoreStatus::Completed,
"errored" => TraceScoreStatus::Errored,
"skipped" => TraceScoreStatus::Skipped,
_ => TraceScoreStatus::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TraceScore {
#[serde(rename = "id")]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "score_01933b5a000070008000000000000001"))]
pub public_id: TraceScoreId,
#[serde(skip, default = "Uuid::nil")]
pub internal_id: Uuid,
#[serde(skip, default)]
pub org_id: i64,
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub observer_id: ObserverId,
pub scorer_key: String,
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub session_id: SessionId,
pub turn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub agent_id: Option<AgentId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub agent_version_id: Option<AgentVersionId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub harness_id: Option<HarnessId>,
pub status: TraceScoreStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub pass: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observer_status_roundtrip() {
for (s, v) in [
("active", ObserverStatus::Active),
("paused", ObserverStatus::Paused),
("archived", ObserverStatus::Archived),
("deleted", ObserverStatus::Deleted),
] {
assert_eq!(ObserverStatus::from(s), v);
assert_eq!(v.to_string(), s);
}
assert_eq!(ObserverStatus::from("unknown"), ObserverStatus::Active);
}
#[test]
fn trace_score_status_roundtrip() {
for (s, v) in [
("pending", TraceScoreStatus::Pending),
("scoring", TraceScoreStatus::Scoring),
("completed", TraceScoreStatus::Completed),
("errored", TraceScoreStatus::Errored),
("skipped", TraceScoreStatus::Skipped),
] {
assert_eq!(TraceScoreStatus::from(s), v);
assert_eq!(v.to_string(), s);
}
assert_eq!(TraceScoreStatus::from("unknown"), TraceScoreStatus::Pending);
}
#[test]
fn empty_match_matches_everything() {
let m = ObserverMatch::default();
assert!(m.matches(None, None, &[]));
assert!(m.matches(Some(AgentId::new()), Some(HarnessId::new()), &["x".into()]));
}
#[test]
fn match_agent_predicate() {
let agent = AgentId::new();
let m = ObserverMatch {
agent_ids: Some(vec![agent]),
..Default::default()
};
assert!(m.matches(Some(agent), None, &[]));
assert!(!m.matches(Some(AgentId::new()), None, &[]));
assert!(!m.matches(None, None, &[]));
}
#[test]
fn match_harness_predicate() {
let harness = HarnessId::new();
let m = ObserverMatch {
harness_ids: Some(vec![harness]),
..Default::default()
};
assert!(m.matches(None, Some(harness), &[]));
assert!(!m.matches(None, Some(HarnessId::new()), &[]));
assert!(!m.matches(None, None, &[]));
}
#[test]
fn match_tags_any_of() {
let m = ObserverMatch {
session_tags: Some(vec!["prod".into(), "beta".into()]),
..Default::default()
};
assert!(m.matches(None, None, &["beta".into()]));
assert!(!m.matches(None, None, &["other".into()]));
assert!(!m.matches(None, None, &[]));
}
#[test]
fn match_predicates_are_anded() {
let agent = AgentId::new();
let m = ObserverMatch {
agent_ids: Some(vec![agent]),
session_tags: Some(vec!["prod".into()]),
..Default::default()
};
assert!(m.matches(Some(agent), None, &["prod".into()]));
assert!(!m.matches(Some(agent), None, &[]));
assert!(!m.matches(None, None, &["prod".into()]));
}
#[test]
fn scorer_config_serde_defaults_scope() {
let json = serde_json::json!({
"key": "greeting",
"rule": { "type": "contains", "text": "hello" }
});
let config: ObserverScorerConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.scope, ObserverScope::Turn);
assert_eq!(config.key, "greeting");
}
#[test]
fn observer_serde_skips_internal_fields() {
let observer = Observer {
public_id: ObserverId::from_uuid(Uuid::nil()),
internal_id: Uuid::nil(),
org_id: 1,
name: "test".into(),
description: None,
match_config: ObserverMatch::default(),
sampling_rate: 0.1,
scorers: vec![],
status: ObserverStatus::Active,
created_at: Utc::now(),
updated_at: Utc::now(),
archived_at: None,
};
let json = serde_json::to_value(&observer).unwrap();
assert!(json.get("id").is_some());
assert!(json.get("match").is_some());
assert!(json.get("internal_id").is_none());
assert!(json.get("org_id").is_none());
assert_eq!(json["sampling_rate"], 0.1);
}
}