1use ainl_memory::{AinlMemoryNode, AinlNodeType, GraphMemory, MemoryCategory};
4use thiserror::Error;
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum LedgerKind {
10 Facts,
11 Guesses,
12 Plan,
13}
14
15impl LedgerKind {
16 pub fn tag_suffix(self) -> &'static str {
17 match self {
18 Self::Facts => "ledger:facts",
19 Self::Guesses => "ledger:guesses",
20 Self::Plan => "ledger:plan",
21 }
22 }
23}
24
25fn mission_tag(mission_id: &str) -> String {
26 format!("mission:{mission_id}")
27}
28
29fn ledger_tags(mission_id: &str, kind: LedgerKind) -> Vec<String> {
30 vec![mission_tag(mission_id), kind.tag_suffix().to_string()]
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct LedgerEntry {
36 pub node_id: Uuid,
37 pub kind: LedgerKind,
38 pub text: String,
39 pub confidence: f32,
40}
41
42#[derive(Debug, Error)]
44pub enum TaskLedgerError {
45 #[error("memory: {0}")]
46 Memory(String),
47}
48
49pub fn write_fact(
51 memory: &GraphMemory,
52 agent_id: &str,
53 mission_id: &str,
54 fact: &str,
55 confidence: f32,
56 source_turn_id: Uuid,
57) -> Result<Uuid, TaskLedgerError> {
58 write_ledger(
59 memory,
60 agent_id,
61 mission_id,
62 LedgerKind::Facts,
63 fact,
64 confidence,
65 source_turn_id,
66 )
67}
68
69pub fn write_guess(
71 memory: &GraphMemory,
72 agent_id: &str,
73 mission_id: &str,
74 guess: &str,
75 confidence: f32,
76 source_turn_id: Uuid,
77) -> Result<Uuid, TaskLedgerError> {
78 write_ledger(
79 memory,
80 agent_id,
81 mission_id,
82 LedgerKind::Guesses,
83 guess,
84 confidence,
85 source_turn_id,
86 )
87}
88
89pub fn write_plan(
91 memory: &GraphMemory,
92 agent_id: &str,
93 mission_id: &str,
94 plan_md: &str,
95 source_turn_id: Uuid,
96) -> Result<Uuid, TaskLedgerError> {
97 write_ledger(
98 memory,
99 agent_id,
100 mission_id,
101 LedgerKind::Plan,
102 plan_md,
103 1.0,
104 source_turn_id,
105 )
106}
107
108fn write_ledger(
109 memory: &GraphMemory,
110 agent_id: &str,
111 mission_id: &str,
112 kind: LedgerKind,
113 text: &str,
114 confidence: f32,
115 source_turn_id: Uuid,
116) -> Result<Uuid, TaskLedgerError> {
117 let tags = ledger_tags(mission_id, kind);
118 let mut node = AinlMemoryNode::new_fact(text.to_string(), confidence.clamp(0.0, 1.0), source_turn_id);
119 node.agent_id = agent_id.to_string();
120 node.memory_category = MemoryCategory::Semantic;
121 if let AinlNodeType::Semantic { ref mut semantic } = node.node_type {
122 semantic.tags = tags;
123 semantic.topic_cluster = Some(format!("mission-ledger-{}", kind.tag_suffix()));
124 }
125 let id = node.id;
126 memory
127 .write_node(&node)
128 .map_err(TaskLedgerError::Memory)?;
129 Ok(id)
130}
131
132pub fn read_ledger(
134 memory: &GraphMemory,
135 agent_id: &str,
136 mission_id: &str,
137 kind: Option<LedgerKind>,
138) -> Result<Vec<LedgerEntry>, TaskLedgerError> {
139 let mission_prefix = mission_tag(mission_id);
140 let nodes = memory
141 .recall_by_type(ainl_memory::AinlNodeKind::Semantic, 60 * 60 * 24 * 365 * 5)
142 .map_err(TaskLedgerError::Memory)?;
143
144 let mut out = Vec::new();
145 for n in nodes {
146 if n.agent_id != agent_id {
147 continue;
148 }
149 let Some(semantic) = n.semantic() else {
150 continue;
151 };
152 if !semantic.tags.iter().any(|t| t == &mission_prefix) {
153 continue;
154 }
155 let entry_kind = semantic
156 .tags
157 .iter()
158 .find_map(|t| match t.as_str() {
159 "ledger:facts" => Some(LedgerKind::Facts),
160 "ledger:guesses" => Some(LedgerKind::Guesses),
161 "ledger:plan" => Some(LedgerKind::Plan),
162 _ => None,
163 });
164 let Some(entry_kind) = entry_kind else {
165 continue;
166 };
167 if let Some(filter) = kind {
168 if filter != entry_kind {
169 continue;
170 }
171 }
172 out.push(LedgerEntry {
173 node_id: n.id,
174 kind: entry_kind,
175 text: semantic.fact.clone(),
176 confidence: semantic.confidence,
177 });
178 }
179 Ok(out)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn write_and_read_fact_plan() {
188 let dir = tempfile::tempdir().unwrap();
189 let memory = GraphMemory::new(&dir.path().join("ledger.db")).unwrap();
190 let turn = Uuid::new_v4();
191 write_fact(
192 &memory,
193 "agent-1",
194 "mission-a",
195 "API returns 401 without token",
196 0.9,
197 turn,
198 )
199 .unwrap();
200 write_plan(
201 &memory,
202 "agent-1",
203 "mission-a",
204 "1. Auth middleware\n2. Tests",
205 turn,
206 )
207 .unwrap();
208
209 let facts = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Facts)).unwrap();
210 assert_eq!(facts.len(), 1);
211 assert!(facts[0].text.contains("401"));
212
213 let plan = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Plan)).unwrap();
214 assert_eq!(plan.len(), 1);
215 assert!(plan[0].text.contains("Auth middleware"));
216 }
217}