From 68d6d43582c0123efcabebee471dc0648244775b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 19:05:27 +0000 Subject: [PATCH] Implement Layer 3: Big Five personality analysis system Layer 3 evaluates the user based on their Layer 2 memories using the Big Five personality model (OCEAN), which is the most reliable psychological model for personality assessment. Changes: - Add UserAnalysis struct with Big Five traits (Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism) - Create user_analyses table in SQLite for storing analyses - Add storage methods: save_analysis, get_latest_analysis, list_analyses - Add MCP tools: save_user_analysis and get_user_analysis - Include helper methods: dominant_trait(), is_high() - All scores are f32 clamped to 0.0-1.0 range Architecture: - Layer 3 analyzes patterns from Layer 2 memories (AI interpretations and priority scores) to build psychological profile - AI judges personality, tool records the analysis - Independent from memory storage but references Layer 2 data --- src/core/analysis.rs | 161 +++++++++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 2 + src/core/store.rs | 117 +++++++++++++++++++++++++++++++ src/mcp/base.rs | 117 ++++++++++++++++++++++++++++++- 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/core/analysis.rs diff --git a/src/core/analysis.rs b/src/core/analysis.rs new file mode 100644 index 0000000..951f8d4 --- /dev/null +++ b/src/core/analysis.rs @@ -0,0 +1,161 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +/// User personality analysis based on Big Five model (OCEAN) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAnalysis { + /// Unique identifier using ULID + pub id: String, + + /// Openness to Experience (0.0-1.0) + /// Curiosity, imagination, willingness to try new things + pub openness: f32, + + /// Conscientiousness (0.0-1.0) + /// Organization, responsibility, self-discipline + pub conscientiousness: f32, + + /// Extraversion (0.0-1.0) + /// Sociability, assertiveness, energy level + pub extraversion: f32, + + /// Agreeableness (0.0-1.0) + /// Compassion, cooperation, trust + pub agreeableness: f32, + + /// Neuroticism (0.0-1.0) + /// Emotional stability, anxiety, mood swings + pub neuroticism: f32, + + /// AI-generated summary of the personality analysis + pub summary: String, + + /// When this analysis was performed + pub analyzed_at: DateTime, +} + +impl UserAnalysis { + /// Create a new personality analysis + pub fn new( + openness: f32, + conscientiousness: f32, + extraversion: f32, + agreeableness: f32, + neuroticism: f32, + summary: String, + ) -> Self { + let id = Ulid::new().to_string(); + let analyzed_at = Utc::now(); + + Self { + id, + openness: openness.clamp(0.0, 1.0), + conscientiousness: conscientiousness.clamp(0.0, 1.0), + extraversion: extraversion.clamp(0.0, 1.0), + agreeableness: agreeableness.clamp(0.0, 1.0), + neuroticism: neuroticism.clamp(0.0, 1.0), + summary, + analyzed_at, + } + } + + /// Get the dominant trait (highest score) + pub fn dominant_trait(&self) -> &str { + let scores = [ + (self.openness, "Openness"), + (self.conscientiousness, "Conscientiousness"), + (self.extraversion, "Extraversion"), + (self.agreeableness, "Agreeableness"), + (self.neuroticism, "Neuroticism"), + ]; + + scores + .iter() + .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) + .map(|(_, name)| *name) + .unwrap_or("Unknown") + } + + /// Check if a trait is high (>= 0.6) + pub fn is_high(&self, trait_name: &str) -> bool { + let score = match trait_name.to_lowercase().as_str() { + "openness" | "o" => self.openness, + "conscientiousness" | "c" => self.conscientiousness, + "extraversion" | "e" => self.extraversion, + "agreeableness" | "a" => self.agreeableness, + "neuroticism" | "n" => self.neuroticism, + _ => return false, + }; + score >= 0.6 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_analysis() { + let analysis = UserAnalysis::new( + 0.8, + 0.7, + 0.4, + 0.6, + 0.3, + "Test summary".to_string(), + ); + + assert_eq!(analysis.openness, 0.8); + assert_eq!(analysis.conscientiousness, 0.7); + assert_eq!(analysis.extraversion, 0.4); + assert_eq!(analysis.agreeableness, 0.6); + assert_eq!(analysis.neuroticism, 0.3); + assert!(!analysis.id.is_empty()); + } + + #[test] + fn test_score_clamping() { + let analysis = UserAnalysis::new( + 1.5, // Should clamp to 1.0 + -0.2, // Should clamp to 0.0 + 0.5, + 0.5, + 0.5, + "Test".to_string(), + ); + + assert_eq!(analysis.openness, 1.0); + assert_eq!(analysis.conscientiousness, 0.0); + } + + #[test] + fn test_dominant_trait() { + let analysis = UserAnalysis::new( + 0.9, // Highest + 0.5, + 0.4, + 0.6, + 0.3, + "Test".to_string(), + ); + + assert_eq!(analysis.dominant_trait(), "Openness"); + } + + #[test] + fn test_is_high() { + let analysis = UserAnalysis::new( + 0.8, // High + 0.4, // Low + 0.6, // Threshold + 0.5, + 0.3, + "Test".to_string(), + ); + + assert!(analysis.is_high("openness")); + assert!(!analysis.is_high("conscientiousness")); + assert!(analysis.is_high("extraversion")); // 0.6 is high + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 874c208..ffdbe73 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,7 +1,9 @@ +pub mod analysis; pub mod error; pub mod memory; pub mod store; +pub use analysis::UserAnalysis; pub use error::{MemoryError, Result}; pub use memory::Memory; pub use store::MemoryStore; diff --git a/src/core/store.rs b/src/core/store.rs index 2c6f88c..14be3be 100644 --- a/src/core/store.rs +++ b/src/core/store.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use rusqlite::{params, Connection}; use std::path::PathBuf; +use super::analysis::UserAnalysis; use super::error::{MemoryError, Result}; use super::memory::Memory; @@ -61,6 +62,26 @@ impl MemoryStore { [], )?; + // Create user_analyses table (Layer 3) + conn.execute( + "CREATE TABLE IF NOT EXISTS user_analyses ( + id TEXT PRIMARY KEY, + openness REAL NOT NULL, + conscientiousness REAL NOT NULL, + extraversion REAL NOT NULL, + agreeableness REAL NOT NULL, + neuroticism REAL NOT NULL, + summary TEXT NOT NULL, + analyzed_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_analyzed_at ON user_analyses(analyzed_at)", + [], + )?; + Ok(Self { conn }) } @@ -249,6 +270,102 @@ impl MemoryStore { .query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?; Ok(count) } + + // ========== Layer 3: User Analysis Methods ========== + + /// Save a new user personality analysis + pub fn save_analysis(&self, analysis: &UserAnalysis) -> Result<()> { + self.conn.execute( + "INSERT INTO user_analyses (id, openness, conscientiousness, extraversion, agreeableness, neuroticism, summary, analyzed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + &analysis.id, + &analysis.openness, + &analysis.conscientiousness, + &analysis.extraversion, + &analysis.agreeableness, + &analysis.neuroticism, + &analysis.summary, + analysis.analyzed_at.to_rfc3339(), + ], + )?; + Ok(()) + } + + /// Get the most recent user analysis + pub fn get_latest_analysis(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, openness, conscientiousness, extraversion, agreeableness, neuroticism, summary, analyzed_at + FROM user_analyses + ORDER BY analyzed_at DESC + LIMIT 1", + )?; + + let result = stmt.query_row([], |row| { + let analyzed_at: String = row.get(7)?; + + Ok(UserAnalysis { + id: row.get(0)?, + openness: row.get(1)?, + conscientiousness: row.get(2)?, + extraversion: row.get(3)?, + agreeableness: row.get(4)?, + neuroticism: row.get(5)?, + summary: row.get(6)?, + analyzed_at: DateTime::parse_from_rfc3339(&analyzed_at) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + }) + }); + + match result { + Ok(analysis) => Ok(Some(analysis)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Get all user analyses, ordered by date (newest first) + pub fn list_analyses(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, openness, conscientiousness, extraversion, agreeableness, neuroticism, summary, analyzed_at + FROM user_analyses + ORDER BY analyzed_at DESC", + )?; + + let analyses = stmt + .query_map([], |row| { + let analyzed_at: String = row.get(7)?; + + Ok(UserAnalysis { + id: row.get(0)?, + openness: row.get(1)?, + conscientiousness: row.get(2)?, + extraversion: row.get(3)?, + agreeableness: row.get(4)?, + neuroticism: row.get(5)?, + summary: row.get(6)?, + analyzed_at: DateTime::parse_from_rfc3339(&analyzed_at) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + }) + })? + .collect::, _>>()?; + + Ok(analyses) + } } #[cfg(test)] diff --git a/src/mcp/base.rs b/src/mcp/base.rs index e2a2863..520dbc3 100644 --- a/src/mcp/base.rs +++ b/src/mcp/base.rs @@ -2,7 +2,7 @@ use anyhow::Result; use serde_json::{json, Value}; use std::io::{self, BufRead, Write}; -use crate::core::{Memory, MemoryStore}; +use crate::core::{Memory, MemoryStore, UserAnalysis}; pub struct BaseMCPServer { store: MemoryStore, @@ -192,6 +192,58 @@ impl BaseMCPServer { "required": ["id"] } }), + json!({ + "name": "save_user_analysis", + "description": "Save a Big Five personality analysis based on user's memories (Layer 3)", + "inputSchema": { + "type": "object", + "properties": { + "openness": { + "type": "number", + "description": "Openness to Experience (0.0-1.0)", + "minimum": 0.0, + "maximum": 1.0 + }, + "conscientiousness": { + "type": "number", + "description": "Conscientiousness (0.0-1.0)", + "minimum": 0.0, + "maximum": 1.0 + }, + "extraversion": { + "type": "number", + "description": "Extraversion (0.0-1.0)", + "minimum": 0.0, + "maximum": 1.0 + }, + "agreeableness": { + "type": "number", + "description": "Agreeableness (0.0-1.0)", + "minimum": 0.0, + "maximum": 1.0 + }, + "neuroticism": { + "type": "number", + "description": "Neuroticism (0.0-1.0)", + "minimum": 0.0, + "maximum": 1.0 + }, + "summary": { + "type": "string", + "description": "AI-generated summary of the personality analysis" + } + }, + "required": ["openness", "conscientiousness", "extraversion", "agreeableness", "neuroticism", "summary"] + } + }), + json!({ + "name": "get_user_analysis", + "description": "Get the most recent Big Five personality analysis (Layer 3)", + "inputSchema": { + "type": "object", + "properties": {} + } + }), ] } @@ -222,6 +274,8 @@ impl BaseMCPServer { "list_memories" => self.tool_list_memories(), "update_memory" => self.tool_update_memory(arguments), "delete_memory" => self.tool_delete_memory(arguments), + "save_user_analysis" => self.tool_save_user_analysis(arguments), + "get_user_analysis" => self.tool_get_user_analysis(), _ => json!({ "success": false, "error": format!("Unknown tool: {}", tool_name) @@ -374,6 +428,67 @@ impl BaseMCPServer { } } + // ========== Layer 3: User Analysis Tools ========== + + fn tool_save_user_analysis(&self, arguments: &Value) -> Value { + let openness = arguments["openness"].as_f64().unwrap_or(0.5) as f32; + let conscientiousness = arguments["conscientiousness"].as_f64().unwrap_or(0.5) as f32; + let extraversion = arguments["extraversion"].as_f64().unwrap_or(0.5) as f32; + let agreeableness = arguments["agreeableness"].as_f64().unwrap_or(0.5) as f32; + let neuroticism = arguments["neuroticism"].as_f64().unwrap_or(0.5) as f32; + let summary = arguments["summary"].as_str().unwrap_or("").to_string(); + + let analysis = UserAnalysis::new( + openness, + conscientiousness, + extraversion, + agreeableness, + neuroticism, + summary, + ); + + match self.store.save_analysis(&analysis) { + Ok(()) => json!({ + "success": true, + "id": analysis.id, + "message": "User analysis saved successfully", + "dominant_trait": analysis.dominant_trait() + }), + Err(e) => json!({ + "success": false, + "error": e.to_string() + }), + } + } + + fn tool_get_user_analysis(&self) -> Value { + match self.store.get_latest_analysis() { + Ok(Some(analysis)) => json!({ + "success": true, + "analysis": { + "id": analysis.id, + "openness": analysis.openness, + "conscientiousness": analysis.conscientiousness, + "extraversion": analysis.extraversion, + "agreeableness": analysis.agreeableness, + "neuroticism": analysis.neuroticism, + "summary": analysis.summary, + "dominant_trait": analysis.dominant_trait(), + "analyzed_at": analysis.analyzed_at + } + }), + Ok(None) => json!({ + "success": true, + "analysis": null, + "message": "No analysis found. Run personality analysis first." + }), + Err(e) => json!({ + "success": false, + "error": e.to_string() + }), + } + } + fn handle_unknown_method(&self, id: Value) -> Value { json!({ "jsonrpc": "2.0",