1use std::collections::HashMap;
13
14use chrono::Utc;
15use serde::{Deserialize, Serialize};
16
17use crate::store::db::Database;
18use crate::store::memory::{CreateMemoryInput, Importance, Memory, MemoryType, Scope};
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(rename_all = "snake_case")]
23pub enum SessionOutcome {
24 Success,
25 PartialSuccess,
26 Failure { reasons: Vec<String> },
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct FailureReport {
32 pub project: String,
33 pub sessions_analyzed: u32,
34 pub failed_sessions: u32,
35 pub not_useful_memories: u32,
36 pub patterns_found: u32,
37 pub corrective_memories_generated: u32,
38 pub patterns: Vec<FailurePattern>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FailurePattern {
44 pub id: Option<i64>,
45 pub pattern_key: String,
46 pub description: String,
47 pub frequency: u32,
48 pub confidence: f32,
49 pub corrective_memory_title: Option<String>,
50 pub corrective_memory_id: Option<String>,
51}
52
53pub struct FailureMiner {
55 db: std::sync::Arc<Database>,
56}
57
58impl FailureMiner {
59 pub fn new(db: std::sync::Arc<Database>) -> Self {
60 Self { db }
61 }
62
63 pub fn record_session_outcome(
65 &self,
66 session_id: uuid::Uuid,
67 outcome: SessionOutcome,
68 affected_files: u32,
69 bugs_introduced: u32,
70 user_corrections: Option<&str>,
71 ) -> crate::error::Result<()> {
72 let conn = self.db.get_conn();
73 let conn = conn
74 .lock()
75 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
76
77 let outcome_str = match &outcome {
78 SessionOutcome::Success => "success",
79 SessionOutcome::PartialSuccess => "partial",
80 SessionOutcome::Failure { .. } => "failure",
81 };
82 let reasons_json = match &outcome {
83 SessionOutcome::Failure { reasons } => Some(serde_json::to_string(reasons)?),
84 _ => None,
85 };
86
87 conn.execute(
88 "UPDATE sessions SET outcome = ?1, failure_reasons = ?2,
89 affected_files = ?3, bugs_introduced = ?4,
90 user_corrections = ?5
91 WHERE id = ?6",
92 rusqlite::params![
93 outcome_str,
94 reasons_json,
95 affected_files as i64,
96 bugs_introduced as i64,
97 user_corrections,
98 session_id.to_string()
99 ],
100 )?;
101 Ok(())
102 }
103
104 pub fn mine(&self, project: &str) -> crate::error::Result<FailureReport> {
107 tracing::info!(project = %project, "Starting failure mining");
108 let mut report = FailureReport {
109 project: project.to_string(),
110 ..Default::default()
111 };
112
113 let failed_sessions = self.find_failed_sessions(project)?;
115 report.sessions_analyzed = self.count_sessions(project)?;
116 report.failed_sessions = failed_sessions.len() as u32;
117
118 let not_useful = self.find_not_useful_memories(project)?;
120 report.not_useful_memories = not_useful.len() as u32;
121
122 let mut pattern_signals: HashMap<String, PatternSignal> = HashMap::new();
124
125 for session in &failed_sessions {
126 if let Some(ref reasons_json) = session.failure_reasons {
128 if let Ok(reasons) = serde_json::from_str::<Vec<String>>(reasons_json) {
129 for r in reasons {
130 pattern_signals
131 .entry(r.clone())
132 .or_insert_with(|| PatternSignal::new(&r))
133 .sessions
134 .push(session.id);
135 }
136 }
137 }
138 if session.bugs_introduced > 0 {
140 pattern_signals
141 .entry("introduces_bugs".to_string())
142 .or_insert_with(|| PatternSignal::new("introduces_bugs"))
143 .sessions
144 .push(session.id);
145 }
146 if session.affected_files > 5 && session.bugs_introduced == 0 {
148 pattern_signals
149 .entry("scope_creep".to_string())
150 .or_insert_with(|| PatternSignal::new("scope_creep"))
151 .sessions
152 .push(session.id);
153 }
154 }
155
156 for mem in ¬_useful {
158 let key = match mem.memory_type {
159 MemoryType::Decision => "outdated_decision",
160 MemoryType::Pattern => "outdated_pattern",
161 MemoryType::Convention => "outdated_convention",
162 MemoryType::Architecture => "outdated_architecture",
163 _ => "low_quality_memory",
164 };
165 pattern_signals
166 .entry(key.to_string())
167 .or_insert_with(|| PatternSignal::new(key))
168 .memories
169 .push(mem.id);
170 }
171
172 for (key, signal) in pattern_signals {
174 let total = signal.sessions.len() + signal.memories.len();
175 if total < 1 {
176 continue;
177 }
178
179 let description = signal.description();
180 let corrective_title = signal.corrective_title();
181
182 let pattern_id = self.upsert_pattern(project, &key, &description, total as u32)?;
184 report.patterns_found += 1;
185
186 let corrective_id = self.generate_corrective_memory(
188 project,
189 &key,
190 &description,
191 &corrective_title,
192 total as u32,
193 )?;
194 if corrective_id.is_some() {
195 report.corrective_memories_generated += 1;
196 }
197
198 if let (Some(pid), Some(cid)) = (pattern_id, corrective_id) {
200 let conn = self.db.get_conn();
201 let conn = conn
202 .lock()
203 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
204 let _ = conn.execute(
205 "UPDATE failure_patterns SET corrective_memory_id = ?1 WHERE id = ?2",
206 rusqlite::params![cid.to_string(), pid],
207 );
208 let _ = conn.execute(
209 "INSERT OR IGNORE INTO corrective_memories (project, failure_pattern_id, generated_memory_id, rationale, user_accepted)
210 VALUES (?1, ?2, ?3, ?4, 0)",
211 rusqlite::params![project, pid, cid.to_string(), description],
212 );
213 }
214
215 report.patterns.push(FailurePattern {
216 id: pattern_id,
217 pattern_key: key,
218 description,
219 frequency: total as u32,
220 confidence: signal.confidence(),
221 corrective_memory_title: corrective_id.map(|_| corrective_title),
222 corrective_memory_id: corrective_id.map(|u| u.to_string()),
223 });
224 }
225
226 Ok(report)
227 }
228
229 fn find_failed_sessions(&self, project: &str) -> crate::error::Result<Vec<SessionFailureInfo>> {
230 let conn = self.db.get_conn();
231 let conn = conn
232 .lock()
233 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
234 let mut stmt = conn.prepare(
235 "SELECT id, outcome, failure_reasons, affected_files, bugs_introduced
236 FROM sessions
237 WHERE project = ?1 AND outcome = 'failure' AND ended_at IS NOT NULL
238 ORDER BY started_at DESC LIMIT 100",
239 )?;
240 let rows = stmt.query_map(rusqlite::params![project], |row| {
241 Ok(SessionFailureInfo {
242 id: uuid::Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
243 rusqlite::Error::FromSqlConversionFailure(
244 0,
245 rusqlite::types::Type::Text,
246 Box::new(e),
247 )
248 })?,
249 failure_reasons: row.get(2)?,
250 affected_files: row.get::<_, i64>(3)? as u32,
251 bugs_introduced: row.get::<_, i64>(4)? as u32,
252 })
253 })?;
254 let mut sessions = Vec::new();
255 for r in rows {
256 sessions.push(r?);
257 }
258 Ok(sessions)
259 }
260
261 fn count_sessions(&self, project: &str) -> crate::error::Result<u32> {
262 let conn = self.db.get_conn();
263 let conn = conn
264 .lock()
265 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
266 let count: u32 = conn
267 .query_row(
268 "SELECT COUNT(*) FROM sessions WHERE project = ?1",
269 rusqlite::params![project],
270 |row| row.get(0),
271 )
272 .unwrap_or(0);
273 Ok(count)
274 }
275
276 fn find_not_useful_memories(&self, project: &str) -> crate::error::Result<Vec<Memory>> {
277 let conn = self.db.get_conn();
278 let conn = conn
279 .lock()
280 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
281 let _stmt = conn.prepare(
283 "SELECT m.id, m.project, m.scope, m.title, m.content, m.what, m.why, m.context, m.learned,
284 m.memory_type, m.importance, m.tags, m.topic_key, m.access_count, m.revision_count,
285 m.duplicate_count, m.normalized_hash, m.created_at, m.updated_at, m.last_accessed_at, m.last_seen_at, m.deleted_at,
286 m.deprecated_at, m.deprecated_reason, m.supersedes_id, m.context_inject_count, m.origin_peer,
287 m.is_encrypted, m.encrypted_for
288 FROM memories m
289 JOIN memory_feedback f ON m.id = f.memory_id
290 WHERE m.project = ?1 AND m.deleted_at IS NULL AND f.is_useful = 0
291 GROUP BY m.id
292 HAVING COUNT(*) >= 2
293 LIMIT 100"
294 )?;
295 let mut stmt = conn.prepare(
297 "SELECT id, title, content, memory_type
298 FROM memories
299 WHERE project = ?1 AND deleted_at IS NULL
300 AND id IN (
301 SELECT memory_id FROM memory_feedback WHERE is_useful = 0 GROUP BY memory_id HAVING COUNT(*) >= 2
302 )
303 LIMIT 100"
304 )?;
305 let rows = stmt.query_map(rusqlite::params![project], |row| {
306 Ok(NotUsefulRef {
307 id: uuid::Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
308 rusqlite::Error::FromSqlConversionFailure(
309 0,
310 rusqlite::types::Type::Text,
311 Box::new(e),
312 )
313 })?,
314 title: row.get(1)?,
315 content: row.get(2)?,
316 memory_type_str: row.get(3)?,
317 })
318 })?;
319 let mut result = Vec::new();
320 for r in rows {
321 let r = r?;
322 result.push(Memory {
323 id: r.id,
324 project: project.to_string(),
325 scope: Scope::Project,
326 title: r.title,
327 content: r.content,
328 what: None,
329 why: None,
330 context: None,
331 learned: None,
332 memory_type: std::str::FromStr::from_str(&r.memory_type_str)
333 .unwrap_or(MemoryType::Note),
334 importance: Importance::Medium,
335 tags: Vec::new(),
336 topic_key: None,
337 access_count: 0,
338 revision_count: 0,
339 duplicate_count: 0,
340 normalized_hash: None,
341 created_at: chrono::DateTime::UNIX_EPOCH,
342 updated_at: chrono::DateTime::UNIX_EPOCH,
343 last_accessed_at: None,
344 last_seen_at: None,
345 deleted_at: None,
346 deprecated_at: None,
347 deprecated_reason: None,
348 supersedes_id: None,
349 context_inject_count: 0,
350 origin_peer: None,
351 is_encrypted: false,
352 encrypted_for: None,
353 valid_from: None,
354 valid_until: None,
355 provenance: None,
356 });
357 }
358 Ok(result)
359 }
360
361 fn upsert_pattern(
362 &self,
363 project: &str,
364 key: &str,
365 description: &str,
366 freq: u32,
367 ) -> crate::error::Result<Option<i64>> {
368 let conn = self.db.get_conn();
369 let conn = conn
370 .lock()
371 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
372
373 let now = Utc::now().to_rfc3339();
374
375 let existing: Option<i64> = conn
377 .query_row(
378 "SELECT id FROM failure_patterns WHERE project = ?1 AND pattern_key = ?2",
379 rusqlite::params![project, key],
380 |row| row.get(0),
381 )
382 .ok();
383
384 if let Some(id) = existing {
385 conn.execute(
386 "UPDATE failure_patterns SET frequency = frequency + ?1, last_seen = ?2 WHERE id = ?3",
387 rusqlite::params![freq as i64, now, id],
388 )?;
389 Ok(Some(id))
390 } else {
391 conn.execute(
392 "INSERT INTO failure_patterns (project, pattern_key, description, frequency, first_seen, last_seen, confidence)
393 VALUES (?1, ?2, ?3, ?4, ?5, ?5, 0.5)",
394 rusqlite::params![project, key, description, freq as i64, now],
395 )?;
396 Ok(conn.last_insert_rowid().into())
397 }
398 }
399
400 fn generate_corrective_memory(
401 &self,
402 project: &str,
403 pattern_key: &str,
404 description: &str,
405 corrective_title: &str,
406 _freq: u32,
407 ) -> crate::error::Result<Option<uuid::Uuid>> {
408 let already_exists: bool = {
411 let conn = self.db.get_conn();
412 let conn = conn
413 .lock()
414 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
415 conn.query_row(
416 "SELECT id FROM memories
417 WHERE project = ?1 AND deleted_at IS NULL
418 AND provenance LIKE ?2",
419 rusqlite::params![project, format!("learn/{}/%", pattern_key)],
420 |row| row.get::<_, String>(0),
421 )
422 .is_ok()
423 };
424
425 if already_exists {
426 return Ok(None);
427 }
428
429 let input = CreateMemoryInput {
431 project: project.to_string(),
432 scope: Some(Scope::Project),
433 title: corrective_title.to_string(),
434 content: format!(
435 "**Patrón detectado:** `{}`\n\n**Descripción:** {}\n\n**Recomendación:** Revisar memorias relacionadas con este patrón. Considerar marcar como obsoletas las que generen este tipo de error.",
436 pattern_key, description
437 ),
438 what: Some(format!("Pattern: {}", pattern_key)),
439 why: Some(description.to_string()),
440 context: None,
441 learned: Some("Auto-generado por FailureMiner".to_string()),
442 memory_type: MemoryType::Learning,
443 importance: Importance::High,
444 tags: vec!["learned".to_string(), "pattern".to_string(), pattern_key.to_string()],
445 topic_key: Some(format!("learn/{}", pattern_key)),
446 capture_prompt: None,
447 encrypt: false,
448 valid_from: None,
449 valid_until: None,
450 provenance: Some(format!("learn/{}/auto", pattern_key)),
451 };
452
453 let store = self.db.memories();
454 let memory = store.save(input, None, None)?;
455 Ok(Some(memory.id))
456 }
457}
458
459struct NotUsefulRef {
461 id: uuid::Uuid,
462 title: String,
463 content: String,
464 memory_type_str: String,
465}
466
467struct SessionFailureInfo {
469 id: uuid::Uuid,
470 failure_reasons: Option<String>,
471 affected_files: u32,
472 bugs_introduced: u32,
473}
474
475struct PatternSignal {
477 key: String,
478 sessions: Vec<uuid::Uuid>,
479 memories: Vec<uuid::Uuid>,
480}
481
482impl PatternSignal {
483 fn new(key: &str) -> Self {
484 Self {
485 key: key.to_string(),
486 sessions: Vec::new(),
487 memories: Vec::new(),
488 }
489 }
490
491 fn total(&self) -> u32 {
492 (self.sessions.len() + self.memories.len()) as u32
493 }
494
495 fn confidence(&self) -> f32 {
496 let total = self.total();
497 (total as f32 * 0.15).min(0.95)
499 }
500
501 fn description(&self) -> String {
502 match self.key.as_str() {
503 "missing_context" => {
504 "El agente no tuvo suficiente contexto al recuperar memorias".to_string()
505 }
506 "outdated_decision" => {
507 "Decisiones arquitectónicas marcadas como outdated por feedback".to_string()
508 }
509 "outdated_pattern" => {
510 "Patrones de código obsoletos según feedback de usuarios".to_string()
511 }
512 "outdated_convention" => "Convenciones del proyecto que ya no se aplican".to_string(),
513 "outdated_architecture" => "Decisiones arquitectónicas que cambiaron".to_string(),
514 "low_quality_memory" => {
515 "Memorias con feedback negativo recurrente — revisar calidad".to_string()
516 }
517 "introduces_bugs" => "Sesiones donde el agente introdujo bugs".to_string(),
518 "scope_creep" => "Sesiones que tocaron muchos archivos sin introducir bugs".to_string(),
519 _ => format!("Pattern custom: {}", self.key),
520 }
521 }
522
523 fn corrective_title(&self) -> String {
524 format!("[Learn] {}", self.key)
525 }
526}
527
528pub fn format_failure_report(report: &FailureReport) -> String {
530 let mut out = String::new();
531 out.push_str(&format!("# Failure Mining Report: {}\n\n", report.project));
532 out.push_str("## Resumen\n\n");
533 out.push_str(&format!(
534 "- Sesiones analizadas: {}\n",
535 report.sessions_analyzed
536 ));
537 out.push_str(&format!(
538 "- Sesiones con failure: {}\n",
539 report.failed_sessions
540 ));
541 out.push_str(&format!(
542 "- Memorias con feedback negativo: {}\n",
543 report.not_useful_memories
544 ));
545 out.push_str(&format!(
546 "- Patterns detectados: {}\n",
547 report.patterns_found
548 ));
549 out.push_str(&format!(
550 "- Memorias correctivas generadas: {}\n\n",
551 report.corrective_memories_generated
552 ));
553 if report.patterns.is_empty() {
554 out.push_str("No se detectaron patterns de failure. ¡Todo bien!\n");
555 } else {
556 out.push_str("## Patterns detectados\n\n");
557 out.push_str("| Key | Frecuencia | Confianza | Descripción | Correctiva |\n");
558 out.push_str("|-----|------------|-----------|-------------|------------|\n");
559 for p in &report.patterns {
560 let title = p.corrective_memory_title.as_deref().unwrap_or("-");
561 out.push_str(&format!(
562 "| `{}` | {} | {:.2} | {} | {} |\n",
563 p.pattern_key,
564 p.frequency,
565 p.confidence,
566 truncate_str(&p.description, 40),
567 title
568 ));
569 }
570 }
571 out
572}
573
574fn truncate_str(s: &str, max: usize) -> String {
575 if s.chars().count() <= max {
576 s.to_string()
577 } else {
578 let t: String = s.chars().take(max.saturating_sub(1)).collect();
579 format!("{}…", t)
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::store::memory::{CreateMemoryInput, Importance, MemoryType, Scope};
587
588 fn make_db() -> std::sync::Arc<Database> {
589 let path =
590 std::path::PathBuf::from(format!("/tmp/mneme_learn_test_{}.db", uuid::Uuid::new_v4()));
591 std::sync::Arc::new(Database::open(&path).unwrap())
592 }
593
594 #[test]
595 fn test_pattern_signal_confidence() {
596 let mut sig = PatternSignal::new("test");
597 assert!((sig.confidence() - 0.0).abs() < 0.001);
598 for _ in 0..5 {
599 sig.sessions.push(uuid::Uuid::new_v4());
600 }
601 assert!(sig.confidence() > 0.5);
602 }
603
604 #[test]
605 fn test_pattern_signal_description_known() {
606 let sig = PatternSignal::new("missing_context");
607 assert!(sig.description().contains("contexto"));
608 }
609
610 #[test]
611 fn test_pattern_signal_description_unknown() {
612 let sig = PatternSignal::new("weird_thing");
613 assert!(sig.description().contains("weird_thing"));
614 }
615
616 #[test]
617 fn test_format_empty_report() {
618 let report = FailureReport::default();
619 let s = format_failure_report(&report);
620 assert!(s.contains("Failure Mining Report"));
621 assert!(s.contains("No se detectaron"));
622 }
623
624 #[test]
625 fn test_format_populated_report() {
626 let report = FailureReport {
627 project: "test-proj".to_string(),
628 sessions_analyzed: 10,
629 failed_sessions: 3,
630 not_useful_memories: 2,
631 patterns_found: 1,
632 corrective_memories_generated: 1,
633 patterns: vec![FailurePattern {
634 id: Some(1),
635 pattern_key: "introduces_bugs".to_string(),
636 description: "Sesiones donde el agente introdujo bugs".to_string(),
637 frequency: 3,
638 confidence: 0.45,
639 corrective_memory_title: Some("[Learn] introduces_bugs".to_string()),
640 corrective_memory_id: Some("uuid".to_string()),
641 }],
642 };
643 let s = format_failure_report(&report);
644 assert!(s.contains("introduces_bugs"));
645 assert!(s.contains("[Learn]"));
646 }
647
648 #[test]
649 fn test_mine_on_empty_project() {
650 let db = make_db();
651 let miner = FailureMiner::new(db);
652 let report = miner.mine("empty-project").unwrap();
653 assert_eq!(report.sessions_analyzed, 0);
654 assert_eq!(report.patterns_found, 0);
655 }
656
657 #[test]
658 fn test_record_session_outcome_and_mine() {
659 let db = make_db();
660 let _memories = db.memories();
661 let sessions = db.sessions();
662 let session = sessions.start("test-proj", Some("/tmp")).unwrap();
663
664 let miner = FailureMiner::new(db.clone());
666 miner
667 .record_session_outcome(
668 session.id,
669 SessionOutcome::Failure {
670 reasons: vec![
671 "missing_context".to_string(),
672 "outdated_decision".to_string(),
673 ],
674 },
675 3,
676 1,
677 Some("Fixed typo in function signature"),
678 )
679 .unwrap();
680 sessions.end(session.id, Some("test summary")).unwrap();
681
682 let report = miner.mine("test-proj").unwrap();
684 assert_eq!(report.sessions_analyzed, 1);
685 assert_eq!(report.failed_sessions, 1);
686 assert!(report.patterns_found >= 2); }
688}