use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use crate::error::{KernelError, Result};
use crate::registry::StateRegistry;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decision {
pub id: Uuid,
pub timestamp: DateTime<Utc>,
pub actor: String,
pub action: String,
pub rationale: String,
pub inputs: serde_json::Value,
pub output: serde_json::Value,
pub confidence: f32,
}
impl Decision {
pub fn new(
actor: impl Into<String>,
action: impl Into<String>,
rationale: impl Into<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
timestamp: Utc::now(),
actor: actor.into(),
action: action.into(),
rationale: rationale.into(),
inputs: serde_json::Value::Null,
output: serde_json::Value::Null,
confidence: 1.0,
}
}
#[must_use]
pub fn with_inputs(mut self, v: serde_json::Value) -> Self {
self.inputs = v;
self
}
#[must_use]
pub fn with_output(mut self, v: serde_json::Value) -> Self {
self.output = v;
self
}
#[must_use]
pub fn with_confidence(mut self, c: f32) -> Self {
self.confidence = c.clamp(0.0, 1.0);
self
}
}
#[derive(Clone)]
pub struct DecisionLog<'a> {
registry: &'a StateRegistry,
}
impl<'a> DecisionLog<'a> {
pub async fn open(registry: &'a StateRegistry) -> Result<Self> {
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS xai_decisions (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
rationale TEXT NOT NULL,
inputs TEXT NOT NULL,
output TEXT NOT NULL,
confidence REAL NOT NULL
)"#,
)
.execute(registry.pool())
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_xai_actor_time ON xai_decisions(actor, timestamp DESC)",
)
.execute(registry.pool())
.await?;
Ok(Self { registry })
}
pub async fn log(&self, decision: &Decision) -> Result<Uuid> {
let inputs_json = serde_json::to_string(&decision.inputs)?;
let output_json = serde_json::to_string(&decision.output)?;
sqlx::query(
r#"INSERT INTO xai_decisions
(id, timestamp, actor, action, rationale, inputs, output, confidence)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
)
.bind(decision.id.to_string())
.bind(decision.timestamp.to_rfc3339())
.bind(&decision.actor)
.bind(&decision.action)
.bind(&decision.rationale)
.bind(inputs_json)
.bind(output_json)
.bind(decision.confidence as f64)
.execute(self.registry.pool())
.await?;
Ok(decision.id)
}
pub async fn recent(&self, limit: i64) -> Result<Vec<Decision>> {
let rows = sqlx::query(
r#"SELECT id, timestamp, actor, action, rationale, inputs, output, confidence
FROM xai_decisions ORDER BY timestamp DESC LIMIT ?1"#,
)
.bind(limit)
.fetch_all(self.registry.pool())
.await?;
rows.iter().map(row_to_decision).collect()
}
pub async fn by_actor(&self, actor: &str) -> Result<Vec<Decision>> {
let rows = sqlx::query(
r#"SELECT id, timestamp, actor, action, rationale, inputs, output, confidence
FROM xai_decisions WHERE actor = ?1 ORDER BY timestamp DESC"#,
)
.bind(actor)
.fetch_all(self.registry.pool())
.await?;
rows.iter().map(row_to_decision).collect()
}
pub async fn count(&self) -> Result<i64> {
let row = sqlx::query("SELECT COUNT(*) as n FROM xai_decisions")
.fetch_one(self.registry.pool())
.await?;
Ok(row.get::<i64, _>("n"))
}
}
fn row_to_decision(row: &sqlx::sqlite::SqliteRow) -> Result<Decision> {
let id: String = row.try_get("id")?;
let ts: String = row.try_get("timestamp")?;
let timestamp = DateTime::parse_from_rfc3339(&ts)
.map_err(|e| KernelError::Other(anyhow::anyhow!("bad timestamp: {e}")))?
.with_timezone(&Utc);
let inputs_str: String = row.try_get("inputs")?;
let output_str: String = row.try_get("output")?;
let confidence: f64 = row.try_get("confidence")?;
Ok(Decision {
id: Uuid::parse_str(&id)
.map_err(|e| KernelError::Other(anyhow::anyhow!("bad uuid: {e}")))?,
timestamp,
actor: row.try_get("actor")?,
action: row.try_get("action")?,
rationale: row.try_get("rationale")?,
inputs: serde_json::from_str(&inputs_str)?,
output: serde_json::from_str(&output_str)?,
confidence: confidence as f32,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn log_and_retrieve_decision() {
let reg = StateRegistry::in_memory().await.unwrap();
let log = DecisionLog::open(®).await.unwrap();
let d = Decision::new("healer", "rewrite-selector", "AX role match found")
.with_inputs(json!({"css": ".primary"}))
.with_output(json!({"role": "button"}))
.with_confidence(0.85);
let id = log.log(&d).await.unwrap();
assert_eq!(id, d.id);
assert_eq!(log.count().await.unwrap(), 1);
let recent = log.recent(10).await.unwrap();
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].actor, "healer");
assert_eq!(recent[0].confidence, 0.85);
assert_eq!(recent[0].inputs["css"], json!(".primary"));
}
#[tokio::test]
async fn by_actor_filters() {
let reg = StateRegistry::in_memory().await.unwrap();
let log = DecisionLog::open(®).await.unwrap();
log.log(&Decision::new("a", "do", "r1")).await.unwrap();
log.log(&Decision::new("b", "do", "r2")).await.unwrap();
log.log(&Decision::new("a", "do", "r3")).await.unwrap();
assert_eq!(log.by_actor("a").await.unwrap().len(), 2);
assert_eq!(log.by_actor("b").await.unwrap().len(), 1);
assert_eq!(log.by_actor("c").await.unwrap().len(), 0);
}
#[tokio::test]
async fn confidence_is_clamped() {
let d = Decision::new("x", "y", "z").with_confidence(2.5);
assert_eq!(d.confidence, 1.0);
let d = Decision::new("x", "y", "z").with_confidence(-1.0);
assert_eq!(d.confidence, 0.0);
}
}