1use std::collections::HashMap;
18
19use chrono::Utc;
20use rusqlite::{params, Connection};
21use serde::{Deserialize, Serialize};
22
23use crate::error::Result;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SentimentLabel {
33 Positive,
35 Negative,
37 Neutral,
39 Mixed,
41}
42
43impl SentimentLabel {
44 pub fn as_str(&self) -> &'static str {
45 match self {
46 SentimentLabel::Positive => "positive",
47 SentimentLabel::Negative => "negative",
48 SentimentLabel::Neutral => "neutral",
49 SentimentLabel::Mixed => "mixed",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Sentiment {
57 pub score: f32,
59 pub label: SentimentLabel,
61 pub confidence: f32,
63 pub keywords: Vec<String>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ReflectionDepth {
71 Surface,
73 Analytical,
75 Meta,
77}
78
79impl ReflectionDepth {
80 pub fn as_str(&self) -> &'static str {
81 match self {
82 ReflectionDepth::Surface => "surface",
83 ReflectionDepth::Analytical => "analytical",
84 ReflectionDepth::Meta => "meta",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Reflection {
92 pub id: i64,
94 pub content: String,
96 pub source_ids: Vec<i64>,
98 pub depth: ReflectionDepth,
100 pub insights: Vec<String>,
102 pub created_at: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SentimentPoint {
109 pub timestamp: String,
111 pub score: f32,
113 pub memory_id: i64,
115}
116
117pub const CREATE_REFLECTIONS_TABLE: &str = r#"
123 CREATE TABLE IF NOT EXISTS reflections (
124 id INTEGER PRIMARY KEY AUTOINCREMENT,
125 content TEXT NOT NULL,
126 source_ids TEXT NOT NULL DEFAULT '[]',
127 depth TEXT NOT NULL DEFAULT 'surface',
128 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
129 );
130 CREATE INDEX IF NOT EXISTS idx_reflections_depth ON reflections(depth);
131 CREATE INDEX IF NOT EXISTS idx_reflections_created_at ON reflections(created_at);
132"#;
133
134static POSITIVE_WORDS: &[&str] = &[
140 "good",
141 "great",
142 "excellent",
143 "happy",
144 "love",
145 "amazing",
146 "wonderful",
147 "fantastic",
148 "brilliant",
149 "awesome",
150 "perfect",
151 "beautiful",
152 "outstanding",
153 "superb",
154 "delightful",
155 "pleased",
156 "grateful",
157 "thrilled",
158 "excited",
159 "proud",
160 "successful",
161 "efficient",
162 "impressive",
163 "remarkable",
164 "enjoyable",
165 "positive",
166 "beneficial",
167 "valuable",
168 "productive",
169 "innovative",
170 "elegant",
171 "smooth",
172 "clean",
173 "fast",
174 "reliable",
175 "stable",
176 "robust",
177 "secure",
178 "scalable",
179 "optimal",
180];
181
182static NEGATIVE_WORDS: &[&str] = &[
184 "bad",
185 "terrible",
186 "awful",
187 "hate",
188 "horrible",
189 "poor",
190 "worst",
191 "ugly",
192 "broken",
193 "failed",
194 "error",
195 "bug",
196 "crash",
197 "slow",
198 "wrong",
199 "missing",
200 "confusing",
201 "frustrating",
202 "annoying",
203 "difficult",
204 "complicated",
205 "messy",
206 "unstable",
207 "insecure",
208 "vulnerable",
209 "deprecated",
210 "outdated",
211 "bloated",
212 "fragile",
213 "flaky",
214 "painful",
215 "tedious",
216 "cumbersome",
217 "clunky",
218 "hacky",
219 "legacy",
220 "technical-debt",
221 "regression",
222 "leak",
223 "bottleneck",
224];
225
226static NEGATION_WORDS: &[&str] = &[
228 "not", "no", "never", "don't", "doesn't", "isn't", "aren't", "wasn't", "can't", "won't",
229];
230
231static INTENSIFIERS: &[&str] = &["very", "extremely", "really", "absolutely", "incredibly"];
233
234const INTENSIFIER_MULTIPLIER: f32 = 1.5;
236
237pub struct SentimentAnalyzer;
245
246impl SentimentAnalyzer {
247 pub fn new() -> Self {
248 Self
249 }
250
251 pub fn analyze(&self, text: &str) -> Sentiment {
259 if text.trim().is_empty() {
260 return Sentiment {
261 score: 0.0,
262 label: SentimentLabel::Neutral,
263 confidence: 1.0,
264 keywords: Vec::new(),
265 };
266 }
267
268 let tokens: Vec<String> = text
269 .split_whitespace()
270 .map(|t| t.to_lowercase())
271 .map(|t| {
272 t.trim_matches(|c: char| !c.is_alphanumeric() && c != '-')
273 .to_string()
274 })
275 .filter(|t| !t.is_empty())
276 .collect();
277
278 let mut raw_score: f32 = 0.0;
279 let mut keywords: Vec<String> = Vec::new();
280 let mut negated = false;
281 let mut intensify = false;
282 let mut pos_hits: u32 = 0;
283 let mut neg_hits: u32 = 0;
284
285 for token in &tokens {
286 if NEGATION_WORDS.contains(&token.as_str()) {
287 negated = true;
288 intensify = false;
289 continue;
290 }
291
292 if INTENSIFIERS.contains(&token.as_str()) {
293 intensify = true;
294 continue;
295 }
296
297 let base_delta = if POSITIVE_WORDS.contains(&token.as_str()) {
298 keywords.push(token.clone());
299 pos_hits += 1;
300 1.0_f32
301 } else if NEGATIVE_WORDS.contains(&token.as_str()) {
302 keywords.push(token.clone());
303 neg_hits += 1;
304 -1.0_f32
305 } else {
306 negated = false;
308 intensify = false;
309 continue;
310 };
311
312 let mut delta = base_delta;
313 if intensify {
314 delta *= INTENSIFIER_MULTIPLIER;
315 }
316 if negated {
317 delta = -delta;
318 }
319
320 raw_score += delta;
321
322 negated = false;
324 intensify = false;
325 }
326
327 let total_hits = pos_hits + neg_hits;
328
329 let score = if total_hits == 0 {
331 0.0
332 } else {
333 (raw_score / (total_hits as f32)).clamp(-1.0, 1.0)
334 };
335
336 let confidence = if total_hits == 0 {
338 0.5 } else {
340 (0.5 + (total_hits as f32 * 0.1)).min(1.0)
341 };
342
343 let label = if total_hits == 0 {
345 SentimentLabel::Neutral
346 } else if pos_hits > 0 && neg_hits > 0 {
347 let ratio = pos_hits.min(neg_hits) as f32 / pos_hits.max(neg_hits) as f32;
349 if ratio > 0.3 {
350 SentimentLabel::Mixed
351 } else if score > 0.0 {
352 SentimentLabel::Positive
353 } else {
354 SentimentLabel::Negative
355 }
356 } else if score > 0.0 {
357 SentimentLabel::Positive
358 } else {
359 SentimentLabel::Negative
360 };
361
362 Sentiment {
363 score,
364 label,
365 confidence,
366 keywords,
367 }
368 }
369}
370
371impl Default for SentimentAnalyzer {
372 fn default() -> Self {
373 Self::new()
374 }
375}
376
377pub struct ReflectionEngine {
383 analyzer: SentimentAnalyzer,
384}
385
386impl ReflectionEngine {
387 pub fn new() -> Self {
388 Self {
389 analyzer: SentimentAnalyzer::new(),
390 }
391 }
392
393 pub fn create_reflection(
398 &self,
399 conn: &Connection,
400 memory_contents: &[(i64, &str)],
401 depth: ReflectionDepth,
402 ) -> Result<Reflection> {
403 let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
404 let source_ids: Vec<i64> = memory_contents.iter().map(|(id, _)| *id).collect();
405
406 let (content, insights) = match depth {
407 ReflectionDepth::Surface => self.surface_reflect(memory_contents),
408 ReflectionDepth::Analytical => self.analytical_reflect(memory_contents),
409 ReflectionDepth::Meta => self.meta_reflect(conn, memory_contents)?,
410 };
411
412 Ok(Reflection {
413 id: 0,
414 content,
415 source_ids,
416 depth,
417 insights,
418 created_at: now,
419 })
420 }
421
422 fn surface_reflect(&self, memory_contents: &[(i64, &str)]) -> (String, Vec<String>) {
428 if memory_contents.is_empty() {
429 return (
430 "No memories provided for reflection.".to_string(),
431 Vec::new(),
432 );
433 }
434
435 let stopwords = &[
437 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
438 "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
439 "shall", "can", "to", "of", "in", "for", "on", "with", "at", "by", "from", "and", "or",
440 "but", "if", "then", "that", "this", "it", "its", "i", "you", "we", "they", "he",
441 "she", "my", "your", "our", "their", "not", "no", "so",
442 ];
443
444 let mut freq: HashMap<String, usize> = HashMap::new();
445 for (_, content) in memory_contents {
446 for token in content.split_whitespace() {
447 let t = token
448 .to_lowercase()
449 .trim_matches(|c: char| !c.is_alphanumeric())
450 .to_string();
451 if t.len() > 3 && !stopwords.contains(&t.as_str()) {
452 *freq.entry(t).or_insert(0) += 1;
453 }
454 }
455 }
456
457 let mut sorted: Vec<(String, usize)> = freq.into_iter().collect();
458 sorted.sort_by(|a, b| b.1.cmp(&a.1));
459 let top_themes: Vec<String> = sorted.into_iter().take(5).map(|(w, _)| w).collect();
460
461 let insights: Vec<String> = top_themes
462 .iter()
463 .map(|t| format!("Key theme: {}", t))
464 .collect();
465
466 let content = if top_themes.is_empty() {
467 format!(
468 "Reflection over {} memories. No dominant themes detected.",
469 memory_contents.len()
470 )
471 } else {
472 format!(
473 "Reflection over {} memories. Key themes: {}.",
474 memory_contents.len(),
475 top_themes.join(", ")
476 )
477 };
478
479 (content, insights)
480 }
481
482 fn analytical_reflect(&self, memory_contents: &[(i64, &str)]) -> (String, Vec<String>) {
488 if memory_contents.is_empty() {
489 return (
490 "No memories provided for analytical reflection.".to_string(),
491 Vec::new(),
492 );
493 }
494
495 let mut pos_count = 0usize;
496 let mut neg_count = 0usize;
497 let mut mixed_count = 0usize;
498 let mut neutral_count = 0usize;
499 let mut total_score: f32 = 0.0;
500
501 let sentiments: Vec<Sentiment> = memory_contents
502 .iter()
503 .map(|(_, c)| self.analyzer.analyze(c))
504 .collect();
505
506 for s in &sentiments {
507 total_score += s.score;
508 match s.label {
509 SentimentLabel::Positive => pos_count += 1,
510 SentimentLabel::Negative => neg_count += 1,
511 SentimentLabel::Mixed => mixed_count += 1,
512 SentimentLabel::Neutral => neutral_count += 1,
513 }
514 }
515
516 let n = memory_contents.len();
517 let avg_score = total_score / n as f32;
518
519 let mut insights = Vec::new();
520
521 let trend = if avg_score > 0.3 {
523 insights.push(format!(
524 "Overall sentiment is positive (avg score: {:.2})",
525 avg_score
526 ));
527 "positive"
528 } else if avg_score < -0.3 {
529 insights.push(format!(
530 "Overall sentiment is negative (avg score: {:.2})",
531 avg_score
532 ));
533 "negative"
534 } else {
535 insights.push(format!(
536 "Overall sentiment is neutral (avg score: {:.2})",
537 avg_score
538 ));
539 "neutral"
540 };
541
542 if pos_count > 0 || neg_count > 0 {
544 insights.push(format!(
545 "Distribution: {} positive, {} negative, {} mixed, {} neutral",
546 pos_count, neg_count, mixed_count, neutral_count
547 ));
548 }
549
550 if pos_count > 0 && neg_count > 0 {
552 let ratio = pos_count.min(neg_count) as f32 / pos_count.max(neg_count) as f32;
553 if ratio > 0.4 {
554 insights.push(format!(
555 "Contradictory signals detected: {} positive vs {} negative memories",
556 pos_count, neg_count
557 ));
558 }
559 }
560
561 let mut kw_freq: HashMap<String, usize> = HashMap::new();
563 for s in &sentiments {
564 for kw in &s.keywords {
565 *kw_freq.entry(kw.clone()).or_insert(0) += 1;
566 }
567 }
568 let mut top_kw: Vec<(String, usize)> = kw_freq.into_iter().collect();
569 top_kw.sort_by(|a, b| b.1.cmp(&a.1));
570 let top_keywords: Vec<String> = top_kw.into_iter().take(3).map(|(k, _)| k).collect();
571 if !top_keywords.is_empty() {
572 insights.push(format!(
573 "Frequent sentiment keywords: {}",
574 top_keywords.join(", ")
575 ));
576 }
577
578 let content = format!(
579 "Analytical reflection over {} memories. Overall {trend} tone (avg score: {:.2}). \
580 Positive: {pos_count}, Negative: {neg_count}, Mixed: {mixed_count}, Neutral: {neutral_count}.",
581 n,
582 avg_score,
583 );
584
585 (content, insights)
586 }
587
588 fn meta_reflect(
594 &self,
595 conn: &Connection,
596 memory_contents: &[(i64, &str)],
597 ) -> Result<(String, Vec<String>)> {
598 let prior = list_reflections(conn, None, 10)?;
600
601 let prior_count = prior.len();
602
603 let (analytical_content, mut insights) = self.analytical_reflect(memory_contents);
605
606 if prior_count == 0 {
608 insights
609 .push("No prior reflections found; this is a first-order reflection.".to_string());
610 } else {
611 insights.push(format!(
612 "Built on {} prior reflections for meta-level synthesis.",
613 prior_count
614 ));
615
616 let surface_count = prior
618 .iter()
619 .filter(|r| r.depth == ReflectionDepth::Surface)
620 .count();
621 let analytical_count = prior
622 .iter()
623 .filter(|r| r.depth == ReflectionDepth::Analytical)
624 .count();
625
626 if surface_count > 0 || analytical_count > 0 {
627 insights.push(format!(
628 "Prior reflection depth breakdown: {} surface, {} analytical.",
629 surface_count, analytical_count
630 ));
631 }
632
633 let prior_text: String = prior
635 .iter()
636 .map(|r| r.content.as_str())
637 .collect::<Vec<_>>()
638 .join(" ");
639 let prior_sentiment = self.analyzer.analyze(&prior_text);
640 insights.push(format!(
641 "Aggregate prior reflection sentiment: {} (score: {:.2}).",
642 prior_sentiment.label.as_str(),
643 prior_sentiment.score
644 ));
645 }
646
647 let content = format!(
648 "Meta-reflection synthesising {} current memories with {} prior reflections. {}",
649 memory_contents.len(),
650 prior_count,
651 analytical_content,
652 );
653
654 Ok((content, insights))
655 }
656}
657
658impl Default for ReflectionEngine {
659 fn default() -> Self {
660 Self::new()
661 }
662}
663
664pub fn save_reflection(conn: &Connection, reflection: &Reflection) -> Result<i64> {
670 let source_ids_json = serde_json::to_string(&reflection.source_ids)?;
671 let now = if reflection.created_at.is_empty() {
672 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
673 } else {
674 reflection.created_at.clone()
675 };
676
677 conn.execute(
678 "INSERT INTO reflections (content, source_ids, depth, created_at) VALUES (?1, ?2, ?3, ?4)",
679 params![
680 reflection.content,
681 source_ids_json,
682 reflection.depth.as_str(),
683 now,
684 ],
685 )?;
686
687 Ok(conn.last_insert_rowid())
688}
689
690pub fn list_reflections(
694 conn: &Connection,
695 depth: Option<ReflectionDepth>,
696 limit: usize,
697) -> Result<Vec<Reflection>> {
698 let effective_limit = if limit == 0 { i64::MAX } else { limit as i64 };
699
700 let rows: Vec<Reflection> = match depth {
701 Some(d) => {
702 let mut stmt = conn.prepare(
703 "SELECT id, content, source_ids, depth, created_at
704 FROM reflections
705 WHERE depth = ?1
706 ORDER BY id DESC
707 LIMIT ?2",
708 )?;
709 let collected = stmt
710 .query_map(params![d.as_str(), effective_limit], map_reflection_row)?
711 .collect::<std::result::Result<Vec<_>, _>>()?;
712 collected
713 }
714 None => {
715 let mut stmt = conn.prepare(
716 "SELECT id, content, source_ids, depth, created_at
717 FROM reflections
718 ORDER BY id DESC
719 LIMIT ?1",
720 )?;
721 let collected = stmt
722 .query_map(params![effective_limit], map_reflection_row)?
723 .collect::<std::result::Result<Vec<_>, _>>()?;
724 collected
725 }
726 };
727
728 Ok(rows)
729}
730
731pub fn sentiment_timeline(
738 conn: &Connection,
739 workspace: &str,
740 from: &str,
741 to: &str,
742) -> Result<Vec<SentimentPoint>> {
743 let mut stmt = conn.prepare(
744 "SELECT id, content, created_at
745 FROM memories
746 WHERE workspace = ?1
747 AND created_at BETWEEN ?2 AND ?3
748 ORDER BY created_at ASC",
749 )?;
750
751 let analyzer = SentimentAnalyzer::new();
752
753 let points: Vec<SentimentPoint> = stmt
754 .query_map(params![workspace, from, to], |row| {
755 let id: i64 = row.get(0)?;
756 let content: String = row.get(1)?;
757 let timestamp: String = row.get(2)?;
758 Ok((id, content, timestamp))
759 })?
760 .filter_map(|r| r.ok())
761 .map(|(id, content, timestamp)| {
762 let sentiment = analyzer.analyze(&content);
763 SentimentPoint {
764 timestamp,
765 score: sentiment.score,
766 memory_id: id,
767 }
768 })
769 .collect();
770
771 Ok(points)
772}
773
774fn map_reflection_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reflection> {
776 let id: i64 = row.get(0)?;
777 let content: String = row.get(1)?;
778 let source_ids_json: String = row.get(2)?;
779 let depth_str: String = row.get(3)?;
780 let created_at: String = row.get(4)?;
781
782 let source_ids: Vec<i64> = serde_json::from_str(&source_ids_json).unwrap_or_default();
783
784 let depth = match depth_str.as_str() {
785 "surface" => ReflectionDepth::Surface,
786 "analytical" => ReflectionDepth::Analytical,
787 "meta" => ReflectionDepth::Meta,
788 _ => ReflectionDepth::Surface,
789 };
790
791 Ok(Reflection {
792 id,
793 content,
794 source_ids,
795 depth,
796 insights: Vec::new(), created_at,
798 })
799}
800
801#[cfg(test)]
806mod tests {
807 use super::*;
808 use rusqlite::Connection;
809
810 fn in_memory_conn() -> Connection {
811 let conn = Connection::open_in_memory().expect("in-memory db");
812 conn.execute_batch(CREATE_REFLECTIONS_TABLE)
813 .expect("create reflections table");
814 conn
815 }
816
817 fn memories_table(conn: &Connection) {
818 conn.execute_batch(
819 "CREATE TABLE IF NOT EXISTS memories (
820 id INTEGER PRIMARY KEY AUTOINCREMENT,
821 content TEXT NOT NULL,
822 workspace TEXT NOT NULL DEFAULT 'default',
823 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
824 );",
825 )
826 .expect("create memories table");
827 }
828
829 fn analyzer() -> SentimentAnalyzer {
830 SentimentAnalyzer::new()
831 }
832
833 #[test]
837 fn test_positive_sentiment() {
838 let s = analyzer().analyze("This is a great and amazing product");
839 assert_eq!(s.label, SentimentLabel::Positive);
840 assert!(s.score > 0.0, "score should be positive, got {}", s.score);
841 assert!(
842 s.keywords.contains(&"great".to_string())
843 || s.keywords.contains(&"amazing".to_string())
844 );
845 }
846
847 #[test]
851 fn test_negative_sentiment() {
852 let s = analyzer().analyze("The software is broken and has terrible bugs");
853 assert_eq!(s.label, SentimentLabel::Negative);
854 assert!(s.score < 0.0, "score should be negative, got {}", s.score);
855 assert!(
856 s.keywords.contains(&"broken".to_string())
857 || s.keywords.contains(&"terrible".to_string())
858 || s.keywords.contains(&"bugs".to_string())
859 );
860 }
861
862 #[test]
866 fn test_negation_flips_positive() {
867 let positive = analyzer().analyze("great work here");
868 let negated = analyzer().analyze("not great work here");
869 assert!(
871 positive.score > negated.score,
872 "negation should reduce score: positive={}, negated={}",
873 positive.score,
874 negated.score
875 );
876 }
877
878 #[test]
879 fn test_negation_flips_negative() {
880 let negative = analyzer().analyze("this is terrible");
881 let negated = analyzer().analyze("this is not terrible");
882 assert!(
884 negated.score > negative.score,
885 "negation of negative word should increase score: negative={}, negated={}",
886 negative.score,
887 negated.score
888 );
889 }
890
891 #[test]
895 fn test_intensifiers_boost_magnitude() {
896 let base = analyzer().analyze("good result");
897 let intensified = analyzer().analyze("very good result");
898 assert_eq!(base.label, SentimentLabel::Positive);
901 assert_eq!(intensified.label, SentimentLabel::Positive);
902 assert!(
903 intensified.score >= base.score,
904 "intensifier should not decrease score: base={}, intensified={}",
905 base.score,
906 intensified.score
907 );
908 }
909
910 #[test]
914 fn test_mixed_sentiment() {
915 let s = analyzer().analyze("great performance but terrible stability and broken error");
917 assert!(
919 matches!(
920 s.label,
921 SentimentLabel::Mixed | SentimentLabel::Positive | SentimentLabel::Negative
922 ),
923 "unexpected label: {:?}",
924 s.label
925 );
926 let has_positive = s
928 .keywords
929 .iter()
930 .any(|k| POSITIVE_WORDS.contains(&k.as_str()));
931 let has_negative = s
932 .keywords
933 .iter()
934 .any(|k| NEGATIVE_WORDS.contains(&k.as_str()));
935 assert!(has_positive, "expected positive keywords in mixed text");
936 assert!(has_negative, "expected negative keywords in mixed text");
937 }
938
939 #[test]
943 fn test_empty_text() {
944 let s = analyzer().analyze("");
945 assert_eq!(s.label, SentimentLabel::Neutral);
946 assert_eq!(s.score, 0.0);
947 assert!(s.keywords.is_empty());
948 }
949
950 #[test]
951 fn test_whitespace_only_text() {
952 let s = analyzer().analyze(" \t\n ");
953 assert_eq!(s.label, SentimentLabel::Neutral);
954 assert_eq!(s.score, 0.0);
955 }
956
957 #[test]
961 fn test_reflection_surface() {
962 let conn = in_memory_conn();
963 let engine = ReflectionEngine::new();
964 let memories = vec![
965 (1i64, "memory performance is really fast and scalable"),
966 (2i64, "memory performance tests look good"),
967 ];
968 let reflection = engine
969 .create_reflection(&conn, &memories, ReflectionDepth::Surface)
970 .expect("surface reflection should succeed");
971
972 assert!(!reflection.content.is_empty());
973 assert_eq!(reflection.depth, ReflectionDepth::Surface);
974 assert_eq!(reflection.source_ids, vec![1, 2]);
975 assert!(
976 !reflection.insights.is_empty(),
977 "surface reflection should produce insights"
978 );
979 let content_lower = reflection.content.to_lowercase();
981 assert!(
982 content_lower.contains("theme") || content_lower.contains("memories"),
983 "unexpected surface content: {}",
984 reflection.content
985 );
986 }
987
988 #[test]
992 fn test_reflection_analytical() {
993 let conn = in_memory_conn();
994 let engine = ReflectionEngine::new();
995 let memories = vec![
996 (1i64, "the new feature is excellent and robust"),
997 (2i64, "there is a terrible bug and regression in production"),
998 (3i64, "deployment went smooth and stable"),
999 ];
1000 let reflection = engine
1001 .create_reflection(&conn, &memories, ReflectionDepth::Analytical)
1002 .expect("analytical reflection should succeed");
1003
1004 assert!(!reflection.content.is_empty());
1005 assert_eq!(reflection.depth, ReflectionDepth::Analytical);
1006 let has_contradiction = reflection
1008 .insights
1009 .iter()
1010 .any(|i| i.contains("Contradict") || i.contains("positive") || i.contains("negative"));
1011 assert!(
1012 has_contradiction,
1013 "analytical reflection should detect sentiment signals"
1014 );
1015 }
1016
1017 #[test]
1021 fn test_sentiment_timeline() {
1022 let conn = in_memory_conn();
1023 memories_table(&conn);
1024
1025 conn.execute_batch(
1026 "INSERT INTO memories (id, content, workspace, created_at) VALUES
1027 (1, 'great day today excellent work', 'test', '2025-01-01T10:00:00Z'),
1028 (2, 'terrible bug crash broken', 'test', '2025-01-02T10:00:00Z'),
1029 (3, 'stable and reliable release', 'test', '2025-01-03T10:00:00Z');",
1030 )
1031 .expect("insert memories");
1032
1033 let timeline = sentiment_timeline(
1034 &conn,
1035 "test",
1036 "2025-01-01T00:00:00Z",
1037 "2025-01-03T23:59:59Z",
1038 )
1039 .expect("timeline should succeed");
1040
1041 assert_eq!(timeline.len(), 3, "expected 3 sentiment points");
1042 assert!(timeline[0].score > 0.0, "first memory should be positive");
1043 assert!(timeline[1].score < 0.0, "second memory should be negative");
1044 assert!(timeline[2].score > 0.0, "third memory should be positive");
1045
1046 assert_eq!(timeline[0].memory_id, 1);
1048 assert_eq!(timeline[1].memory_id, 2);
1049 assert_eq!(timeline[2].memory_id, 3);
1050 }
1051
1052 #[test]
1056 fn test_save_and_list_reflections() {
1057 let conn = in_memory_conn();
1058 let engine = ReflectionEngine::new();
1059
1060 let memories = vec![(10i64, "smooth and fast deployment was successful")];
1061 let mut reflection = engine
1062 .create_reflection(&conn, &memories, ReflectionDepth::Surface)
1063 .expect("create reflection");
1064
1065 let id = save_reflection(&conn, &reflection).expect("save reflection");
1066 assert!(id > 0, "saved id should be positive");
1067 reflection.id = id;
1068
1069 let all = list_reflections(&conn, None, 10).expect("list reflections");
1070 assert_eq!(all.len(), 1);
1071 assert_eq!(all[0].id, id);
1072 assert_eq!(all[0].depth, ReflectionDepth::Surface);
1073
1074 let surface_only =
1075 list_reflections(&conn, Some(ReflectionDepth::Surface), 10).expect("list surface");
1076 assert_eq!(surface_only.len(), 1);
1077
1078 let analytical_only = list_reflections(&conn, Some(ReflectionDepth::Analytical), 10)
1079 .expect("list analytical");
1080 assert!(analytical_only.is_empty());
1081 }
1082}