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:
161
src/core/analysis.rs
Normal file
161
src/core/analysis.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
pub mod analysis;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
pub use analysis::UserAnalysis;
|
||||||
pub use error::{MemoryError, Result};
|
pub use error::{MemoryError, Result};
|
||||||
pub use memory::Memory;
|
pub use memory::Memory;
|
||||||
pub use store::MemoryStore;
|
pub use store::MemoryStore;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::analysis::UserAnalysis;
|
||||||
use super::error::{MemoryError, Result};
|
use super::error::{MemoryError, Result};
|
||||||
use super::memory::Memory;
|
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 })
|
Ok(Self { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +270,102 @@ impl MemoryStore {
|
|||||||
.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
|
.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
|
||||||
Ok(count)
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
117
src/mcp/base.rs
117
src/mcp/base.rs
@@ -2,7 +2,7 @@ use anyhow::Result;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
use crate::core::{Memory, MemoryStore};
|
use crate::core::{Memory, MemoryStore, UserAnalysis};
|
||||||
|
|
||||||
pub struct BaseMCPServer {
|
pub struct BaseMCPServer {
|
||||||
store: MemoryStore,
|
store: MemoryStore,
|
||||||
@@ -192,6 +192,58 @@ impl BaseMCPServer {
|
|||||||
"required": ["id"]
|
"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(),
|
"list_memories" => self.tool_list_memories(),
|
||||||
"update_memory" => self.tool_update_memory(arguments),
|
"update_memory" => self.tool_update_memory(arguments),
|
||||||
"delete_memory" => self.tool_delete_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!({
|
_ => json!({
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": format!("Unknown tool: {}", tool_name)
|
"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 {
|
fn handle_unknown_method(&self, id: Value) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user