use ainl_memory::{AinlMemoryNode, AinlNodeType, GraphMemory, MemoryCategory};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LedgerKind {
Facts,
Guesses,
Plan,
}
impl LedgerKind {
pub fn tag_suffix(self) -> &'static str {
match self {
Self::Facts => "ledger:facts",
Self::Guesses => "ledger:guesses",
Self::Plan => "ledger:plan",
}
}
}
fn mission_tag(mission_id: &str) -> String {
format!("mission:{mission_id}")
}
fn ledger_tags(mission_id: &str, kind: LedgerKind) -> Vec<String> {
vec![mission_tag(mission_id), kind.tag_suffix().to_string()]
}
#[derive(Debug, Clone, PartialEq)]
pub struct LedgerEntry {
pub node_id: Uuid,
pub kind: LedgerKind,
pub text: String,
pub confidence: f32,
}
#[derive(Debug, Error)]
pub enum TaskLedgerError {
#[error("memory: {0}")]
Memory(String),
}
pub fn write_fact(
memory: &GraphMemory,
agent_id: &str,
mission_id: &str,
fact: &str,
confidence: f32,
source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
write_ledger(
memory,
agent_id,
mission_id,
LedgerKind::Facts,
fact,
confidence,
source_turn_id,
)
}
pub fn write_guess(
memory: &GraphMemory,
agent_id: &str,
mission_id: &str,
guess: &str,
confidence: f32,
source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
write_ledger(
memory,
agent_id,
mission_id,
LedgerKind::Guesses,
guess,
confidence,
source_turn_id,
)
}
pub fn write_plan(
memory: &GraphMemory,
agent_id: &str,
mission_id: &str,
plan_md: &str,
source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
write_ledger(
memory,
agent_id,
mission_id,
LedgerKind::Plan,
plan_md,
1.0,
source_turn_id,
)
}
fn write_ledger(
memory: &GraphMemory,
agent_id: &str,
mission_id: &str,
kind: LedgerKind,
text: &str,
confidence: f32,
source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
let tags = ledger_tags(mission_id, kind);
let mut node = AinlMemoryNode::new_fact(text.to_string(), confidence.clamp(0.0, 1.0), source_turn_id);
node.agent_id = agent_id.to_string();
node.memory_category = MemoryCategory::Semantic;
if let AinlNodeType::Semantic { ref mut semantic } = node.node_type {
semantic.tags = tags;
semantic.topic_cluster = Some(format!("mission-ledger-{}", kind.tag_suffix()));
}
let id = node.id;
memory
.write_node(&node)
.map_err(TaskLedgerError::Memory)?;
Ok(id)
}
pub fn read_ledger(
memory: &GraphMemory,
agent_id: &str,
mission_id: &str,
kind: Option<LedgerKind>,
) -> Result<Vec<LedgerEntry>, TaskLedgerError> {
let mission_prefix = mission_tag(mission_id);
let nodes = memory
.recall_by_type(ainl_memory::AinlNodeKind::Semantic, 60 * 60 * 24 * 365 * 5)
.map_err(TaskLedgerError::Memory)?;
let mut out = Vec::new();
for n in nodes {
if n.agent_id != agent_id {
continue;
}
let Some(semantic) = n.semantic() else {
continue;
};
if !semantic.tags.iter().any(|t| t == &mission_prefix) {
continue;
}
let entry_kind = semantic
.tags
.iter()
.find_map(|t| match t.as_str() {
"ledger:facts" => Some(LedgerKind::Facts),
"ledger:guesses" => Some(LedgerKind::Guesses),
"ledger:plan" => Some(LedgerKind::Plan),
_ => None,
});
let Some(entry_kind) = entry_kind else {
continue;
};
if let Some(filter) = kind {
if filter != entry_kind {
continue;
}
}
out.push(LedgerEntry {
node_id: n.id,
kind: entry_kind,
text: semantic.fact.clone(),
confidence: semantic.confidence,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_and_read_fact_plan() {
let dir = tempfile::tempdir().unwrap();
let memory = GraphMemory::new(&dir.path().join("ledger.db")).unwrap();
let turn = Uuid::new_v4();
write_fact(
&memory,
"agent-1",
"mission-a",
"API returns 401 without token",
0.9,
turn,
)
.unwrap();
write_plan(
&memory,
"agent-1",
"mission-a",
"1. Auth middleware\n2. Tests",
turn,
)
.unwrap();
let facts = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Facts)).unwrap();
assert_eq!(facts.len(), 1);
assert!(facts[0].text.contains("401"));
let plan = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Plan)).unwrap();
assert_eq!(plan.len(), 1);
assert!(plan[0].text.contains("Auth middleware"));
}
}