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
This commit is contained in:
Claude
2025-11-05 19:05:27 +00:00
parent a558a0ba6f
commit 68d6d43582
4 changed files with 396 additions and 1 deletions

161
src/core/analysis.rs Normal file
View File

@@ -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<Utc>,
}
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
}
}

View File

@@ -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;

View File

@@ -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<Option<UserAnalysis>> {
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<Vec<UserAnalysis>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(analyses)
}
}
#[cfg(test)]

View File

@@ -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",