1use anyhow::{Context, Result};
2use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
3
4use super::promote::slugify_for_topic;
5use super::types::{map_memory_row_pub, Memory, MEMORY_COLS};
6
7const MIN_CONFIDENCE_FOR_CONTEXT: f64 = 0.5;
8
9#[derive(Debug, Clone)]
10pub struct LessonMetadata {
11 pub memory_id: i64,
12 pub confidence: f64,
13 pub reinforcement_count: i64,
14 pub source_evidence: Option<String>,
15 pub last_reinforced_at_epoch: i64,
16 pub stale_after_epoch: Option<i64>,
17 pub outcome_kind: String,
18 pub success_count: i64,
19 pub failure_count: i64,
20 pub recovery_count: i64,
21 pub correction_count: i64,
22 pub revert_count: i64,
23}
24
25#[derive(Debug, Clone)]
26pub struct LessonMemory {
27 pub memory: Memory,
28 pub metadata: LessonMetadata,
29}
30
31#[derive(Debug, Clone)]
32pub struct SaveLessonRequest<'a> {
33 pub session_id: Option<&'a str>,
34 pub project: &'a str,
35 pub topic_key: Option<&'a str>,
36 pub title: &'a str,
37 pub content: &'a str,
38 pub confidence: f64,
39 pub source_evidence: Option<&'a str>,
40 pub files: Option<&'a str>,
41 pub branch: Option<&'a str>,
42 pub scope: &'a str,
43 pub created_at_epoch: Option<i64>,
44 pub stale_after_epoch: Option<i64>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum LessonOutcomeKind {
49 Unknown,
50 Success,
51 Failure,
52 Recovery,
53 Correction,
54 Revert,
55}
56
57impl LessonOutcomeKind {
58 fn as_str(self) -> &'static str {
59 match self {
60 Self::Unknown => "unknown",
61 Self::Success => "success",
62 Self::Failure => "failure",
63 Self::Recovery => "recovery",
64 Self::Correction => "correction",
65 Self::Revert => "revert",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct LessonOutcomeUpdate {
72 kind: LessonOutcomeKind,
73 success_delta: i64,
74 failure_delta: i64,
75 recovery_delta: i64,
76 correction_delta: i64,
77 revert_delta: i64,
78}
79
80impl LessonOutcomeUpdate {
81 pub fn unknown() -> Self {
82 Self {
83 kind: LessonOutcomeKind::Unknown,
84 success_delta: 0,
85 failure_delta: 0,
86 recovery_delta: 0,
87 correction_delta: 0,
88 revert_delta: 0,
89 }
90 }
91
92 pub fn success() -> Self {
93 Self {
94 kind: LessonOutcomeKind::Success,
95 success_delta: 1,
96 ..Self::unknown()
97 }
98 }
99
100 pub fn failure() -> Self {
101 Self {
102 kind: LessonOutcomeKind::Failure,
103 failure_delta: 1,
104 ..Self::unknown()
105 }
106 }
107
108 pub fn recovery() -> Self {
109 Self {
110 kind: LessonOutcomeKind::Recovery,
111 recovery_delta: 1,
112 ..Self::unknown()
113 }
114 }
115
116 pub fn correction() -> Self {
117 Self {
118 kind: LessonOutcomeKind::Correction,
119 correction_delta: 1,
120 ..Self::unknown()
121 }
122 }
123
124 pub fn revert() -> Self {
125 Self {
126 kind: LessonOutcomeKind::Revert,
127 revert_delta: 1,
128 ..Self::unknown()
129 }
130 }
131}
132
133pub fn save_lesson(conn: &Connection, req: &SaveLessonRequest<'_>) -> Result<i64> {
134 save_lesson_with_reference_time(conn, req, req.created_at_epoch)
135}
136
137pub fn save_lesson_with_outcome(
138 conn: &Connection,
139 req: &SaveLessonRequest<'_>,
140 outcome: LessonOutcomeUpdate,
141) -> Result<i64> {
142 save_lesson_with_reference_time_and_outcome(conn, req, req.created_at_epoch, outcome)
143}
144
145pub fn save_lesson_with_reference_time(
146 conn: &Connection,
147 req: &SaveLessonRequest<'_>,
148 reference_time_epoch: Option<i64>,
149) -> Result<i64> {
150 save_lesson_with_reference_time_and_outcome(
151 conn,
152 req,
153 reference_time_epoch,
154 LessonOutcomeUpdate::unknown(),
155 )
156}
157
158fn save_lesson_with_reference_time_and_outcome(
159 conn: &Connection,
160 req: &SaveLessonRequest<'_>,
161 reference_time_epoch: Option<i64>,
162 outcome: LessonOutcomeUpdate,
163) -> Result<i64> {
164 if !conn.is_autocommit() {
165 conn.execute(
166 "UPDATE memory_activation_requests
167 SET activation_id = activation_id WHERE 0",
168 [],
169 )
170 .context("serialize lesson save inside caller transaction")?;
171 return crate::memory::operation::with_operation_savepoint(conn, || {
172 save_lesson_with_reference_time_and_outcome_inner(
173 conn,
174 None,
175 req,
176 reference_time_epoch,
177 outcome,
178 )
179 });
180 }
181
182 let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
183 .context("begin serialized lesson save")?;
184 match save_lesson_with_reference_time_and_outcome_inner(
185 &tx,
186 None,
187 req,
188 reference_time_epoch,
189 outcome,
190 ) {
191 Ok(id) => {
192 tx.commit().context("commit serialized lesson save")?;
193 Ok(id)
194 }
195 Err(error) => match tx.rollback() {
196 Ok(()) => Err(error),
197 Err(rollback_error) => Err(error.context(format!(
198 "lesson save transaction rollback also failed: {rollback_error}"
199 ))),
200 },
201 }
202}
203
204pub(crate) fn save_lesson_with_reference_time_activated(
205 conn: &Connection,
206 permit: &crate::memory::activation::ActiveMemoryWritePermit,
207 req: &SaveLessonRequest<'_>,
208 reference_time_epoch: Option<i64>,
209) -> Result<i64> {
210 save_lesson_with_reference_time_and_outcome_inner(
211 conn,
212 Some(permit),
213 req,
214 reference_time_epoch,
215 LessonOutcomeUpdate::unknown(),
216 )
217}
218
219fn save_lesson_with_reference_time_and_outcome_inner(
220 conn: &Connection,
221 permit: Option<&crate::memory::activation::ActiveMemoryWritePermit>,
222 req: &SaveLessonRequest<'_>,
223 reference_time_epoch: Option<i64>,
224 outcome: LessonOutcomeUpdate,
225) -> Result<i64> {
226 validate_outcome_update(outcome)?;
227 let topic_key = req
228 .topic_key
229 .map(str::to_string)
230 .unwrap_or_else(|| format!("lesson-{}", slugify_for_topic(req.content, 64)));
231 let scope = if req.scope.trim().is_empty() {
232 "project"
233 } else {
234 req.scope
235 };
236 let existing_id = existing_lesson_id(conn, req.project, &topic_key, scope)?;
237 let id = if let Some(permit) = permit {
238 crate::memory::store::insert_memory_full_activated(
239 conn,
240 permit,
241 req.session_id,
242 req.project,
243 Some(&topic_key),
244 req.title,
245 req.content,
246 "lesson",
247 req.files,
248 req.branch,
249 scope,
250 req.created_at_epoch,
251 reference_time_epoch,
252 )?
253 } else {
254 crate::memory::insert_memory_full_with_reference_time(
255 conn,
256 req.session_id,
257 req.project,
258 Some(&topic_key),
259 req.title,
260 req.content,
261 "lesson",
262 req.files,
263 req.branch,
264 scope,
265 req.created_at_epoch,
266 reference_time_epoch,
267 )?
268 };
269 let metadata_exists = get_lesson_metadata(conn, id)?.is_some();
270 upsert_lesson_metadata(
271 conn,
272 id,
273 req,
274 existing_id.is_some() || metadata_exists,
275 outcome,
276 )?;
277 Ok(id)
278}
279
280fn validate_outcome_update(outcome: LessonOutcomeUpdate) -> Result<()> {
281 for (name, value) in [
282 ("success_delta", outcome.success_delta),
283 ("failure_delta", outcome.failure_delta),
284 ("recovery_delta", outcome.recovery_delta),
285 ("correction_delta", outcome.correction_delta),
286 ("revert_delta", outcome.revert_delta),
287 ] {
288 if value < 0 {
289 anyhow::bail!("lesson outcome {name} must be non-negative");
290 }
291 }
292 Ok(())
293}
294
295fn existing_lesson_id(
296 conn: &Connection,
297 project: &str,
298 topic_key: &str,
299 scope: &str,
300) -> Result<Option<i64>> {
301 let id = conn
302 .query_row(
303 "SELECT id FROM memories
304 WHERE project = ?1
305 AND topic_key = ?2
306 AND COALESCE(scope, 'project') = ?3
307 AND memory_type = 'lesson'
308 LIMIT 1",
309 params![project, topic_key, scope],
310 |row| row.get(0),
311 )
312 .optional()?;
313 Ok(id)
314}
315
316fn upsert_lesson_metadata(
317 conn: &Connection,
318 memory_id: i64,
319 req: &SaveLessonRequest<'_>,
320 existed: bool,
321 outcome: LessonOutcomeUpdate,
322) -> Result<()> {
323 let now = chrono::Utc::now().timestamp();
324 let confidence = req.confidence.clamp(0.0, 1.0);
325 let reinforcement_delta = if existed { 1 } else { 0 };
326 conn.execute(
327 "INSERT INTO memory_lessons
328 (memory_id, confidence, reinforcement_count, source_evidence,
329 last_reinforced_at_epoch, stale_after_epoch, outcome_kind,
330 success_count, failure_count, recovery_count, correction_count, revert_count)
331 VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
332 ON CONFLICT(memory_id) DO UPDATE SET
333 confidence = MAX(memory_lessons.confidence, excluded.confidence),
334 reinforcement_count = memory_lessons.reinforcement_count + ?12,
335 source_evidence = COALESCE(excluded.source_evidence, memory_lessons.source_evidence),
336 last_reinforced_at_epoch = excluded.last_reinforced_at_epoch,
337 stale_after_epoch = excluded.stale_after_epoch,
338 outcome_kind = CASE
339 WHEN excluded.outcome_kind != 'unknown' THEN excluded.outcome_kind
340 ELSE memory_lessons.outcome_kind
341 END,
342 success_count = memory_lessons.success_count + excluded.success_count,
343 failure_count = memory_lessons.failure_count + excluded.failure_count,
344 recovery_count = memory_lessons.recovery_count + excluded.recovery_count,
345 correction_count = memory_lessons.correction_count + excluded.correction_count,
346 revert_count = memory_lessons.revert_count + excluded.revert_count",
347 params![
348 memory_id,
349 confidence,
350 req.source_evidence,
351 now,
352 req.stale_after_epoch,
353 outcome.kind.as_str(),
354 outcome.success_delta,
355 outcome.failure_delta,
356 outcome.recovery_delta,
357 outcome.correction_delta,
358 outcome.revert_delta,
359 reinforcement_delta
360 ],
361 )?;
362 Ok(())
363}
364
365pub fn get_lesson_metadata(conn: &Connection, memory_id: i64) -> Result<Option<LessonMetadata>> {
366 conn.query_row(
367 "SELECT memory_id, confidence, reinforcement_count, source_evidence,
368 last_reinforced_at_epoch, stale_after_epoch, outcome_kind,
369 success_count, failure_count, recovery_count, correction_count, revert_count
370 FROM memory_lessons WHERE memory_id = ?1",
371 [memory_id],
372 map_lesson_metadata_row,
373 )
374 .optional()
375 .map_err(Into::into)
376}
377
378pub fn list_lessons_for_context(
379 conn: &Connection,
380 project: &str,
381 current_branch: Option<&str>,
382 limit: i64,
383) -> Result<Vec<LessonMemory>> {
384 if limit <= 0 {
385 return Ok(vec![]);
386 }
387 let now = chrono::Utc::now().timestamp();
388 let mut stmt = conn.prepare(&format!(
389 "SELECT {cols},
390 l.memory_id, l.confidence, l.reinforcement_count, l.source_evidence,
391 l.last_reinforced_at_epoch, l.stale_after_epoch, l.outcome_kind,
392 l.success_count, l.failure_count, l.recovery_count, l.correction_count,
393 l.revert_count
394 FROM memories m
395 JOIN memory_lessons l ON l.memory_id = m.id
396 WHERE m.memory_type = 'lesson'
397 AND {current_filter}
398 AND {policy_filter}
399 AND ((m.owner_scope = 'repo' AND m.owner_key = ?1)
400 OR (m.owner_scope = 'repo' AND m.target_project = ?1)
401 OR (m.owner_scope = 'user' AND m.owner_key = 'user:default')
402 OR (m.owner_scope IS NULL AND (m.project = ?1 OR m.scope = 'global')))
403 AND l.confidence >= ?2
404 AND (l.stale_after_epoch IS NULL OR l.stale_after_epoch > ?3)
405 AND (?4 IS NULL OR m.branch = ?4 OR m.branch IS NULL)
406 ORDER BY
407 CASE WHEN m.project = ?1 THEN 0 ELSE 1 END,
408 l.confidence DESC,
409 l.reinforcement_count DESC,
410 l.last_reinforced_at_epoch DESC,
411 m.id ASC
412 LIMIT ?5",
413 cols = prefixed_memory_cols("m"),
414 current_filter =
415 crate::memory::memory_current_filter_sql("m.status", "m.expires_at_epoch", false),
416 policy_filter = crate::memory::suppression::memory_policy_filter_sql("m"),
417 ))?;
418 let rows = stmt.query_map(
419 params![
420 project,
421 MIN_CONFIDENCE_FOR_CONTEXT,
422 now,
423 current_branch,
424 limit
425 ],
426 |row| {
427 let memory = map_memory_row_pub(row)?;
428 let metadata = map_lesson_metadata_from_offset(row, 13)?;
429 Ok(LessonMemory { memory, metadata })
430 },
431 )?;
432 crate::db::query::collect_rows(rows)
433}
434
435pub fn is_lesson_candidate(text: &str) -> bool {
436 let normalized = text.trim().to_lowercase();
437 if normalized.len() < 30 {
438 return false;
439 }
440 const SIGNALS: &[&str] = &[
441 "lesson:",
442 "root cause",
443 "avoid ",
444 "do not ",
445 "don't ",
446 "never ",
447 "must ",
448 "should ",
449 "proven ",
450 "workflow",
451 "pitfall",
452 "prevent",
453 "regression",
454 ];
455 SIGNALS.iter().any(|signal| normalized.contains(signal))
456}
457
458fn prefixed_memory_cols(alias: &str) -> String {
459 MEMORY_COLS
460 .split(',')
461 .map(|col| format!("{alias}.{}", col.trim()))
462 .collect::<Vec<_>>()
463 .join(", ")
464}
465
466fn map_lesson_metadata_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<LessonMetadata> {
467 map_lesson_metadata_from_offset(row, 0)
468}
469
470fn map_lesson_metadata_from_offset(
471 row: &rusqlite::Row<'_>,
472 offset: usize,
473) -> rusqlite::Result<LessonMetadata> {
474 Ok(LessonMetadata {
475 memory_id: row.get(offset)?,
476 confidence: row.get(offset + 1)?,
477 reinforcement_count: row.get(offset + 2)?,
478 source_evidence: row.get(offset + 3)?,
479 last_reinforced_at_epoch: row.get(offset + 4)?,
480 stale_after_epoch: row.get(offset + 5)?,
481 outcome_kind: row.get(offset + 6)?,
482 success_count: row.get(offset + 7)?,
483 failure_count: row.get(offset + 8)?,
484 recovery_count: row.get(offset + 9)?,
485 correction_count: row.get(offset + 10)?,
486 revert_count: row.get(offset + 11)?,
487 })
488}
489
490#[cfg(test)]
491mod tests;