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)]