mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat: port NPA psychographic profiling system into IronClaw
Port the complete psychographic profiling system from NPA into IronClaw,
including enriched profile schema, conversational onboarding, profile
evolution, and three-tier prompt augmentation.
Personal onboarding moved from wizard Step 9 to first assistant
interaction per maintainer feedback — the First Contact system prompt
block now instructs the LLM to conduct a natural onboarding conversation
that builds the psychographic profile via memory_write.
Changes:
- Enrich profile.rs with 5 new structs, 9-dimension analysis framework,
custom deserializers for backward compatibility, and rendering methods
- Add conversational onboarding engine with one-step-removed questioning
technique, personality framework, and confidence-scored profile generation
- Add profile evolution with confidence gating, analysis metadata tracking,
and weekly update routine
- Replace thin interaction style injection with three-tier system gated on
confidence > 0.6 and profile recency
- Replace wizard Step 9 with First Contact system prompt block that drives
conversational onboarding during the user's first interaction
- Add autonomy progression to SOUL.md seed and personality framework to
AGENTS.md seed
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* feat: replace chat-based onboarding with bootstrap greeting and workspace seeds
Remove the interactive onboarding_chat.rs engine in favor of a simpler
bootstrap flow: fresh workspaces get a proactive LLM greeting that
naturally profiles the user. Identity files are now seeded from
src/workspace/seeds/ instead of being hardcoded. Also removes the
identity-file write protection (seeds are now managed), adds routine
advisor integration, and includes an e2e trace for bootstrap greeting.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* feat(safety): sanitize identity file writes via Sanitizer to prevent prompt injection
Identity files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md) are injected into
every system prompt. Rather than hard-blocking writes (which broke onboarding),
scan content through the existing Sanitizer and reject writes with High/Critical
severity injection patterns. Medium/Low warnings are logged but allowed.
Also clarifies AGENTS.md identity file roles (USER.md = user info, IDENTITY.md =
agent identity) and adds IDENTITY.md setup as an explicit bootstrap step.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* docs: update profile_onboarding_completed comment to reflect current wiring
The field is now actively used by the agent loop to suppress BOOTSTRAP.md
injection — remove the stale "not yet wired" TODO.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(setup): use env_or_override for NEARAI_API_KEY in model fetch config
When the user authenticates via NEAR AI Cloud API key (option 4),
api_key_login() stores the key via set_runtime_env(). But
build_nearai_model_fetch_config() was using std::env::var() which
doesn't check the runtime overlay — so model listing fell back to
session-token auth and re-triggered the interactive NEAR AI
authentication menu.
Switch to env_or_override() which checks both real env vars and the
runtime overlay.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(agent): correct channel/user_id in bootstrap greeting persist call
persist_assistant_response was called with channel="default",
user_id="system" but the assistant thread was created via
get_or_create_assistant_conversation("default", "gateway") which owns
the conversation as user_id="default", channel="gateway". The mismatch
caused ensure_writable_conversation to reject the write with:
WARN Rejected write for unavailable thread id user=system channel=default
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(web): remove all inline event handlers for CSP compliance
The Content-Security-Policy header (added in f48fe95) blocks inline JS
via script-src 'self'. All onclick/onchange attributes in index.html
are replaced with getElementById().addEventListener() calls. Dynamic
inline handlers in app.js (jobs, routines, memory breadcrumb, code
blocks, TEE report) are replaced with data-action attributes and a
single delegated click handler on document.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(agent): align bootstrap message user/channel and update fixture schema field
- Bootstrap IncomingMessage now uses ("default", "gateway") consistently
with persist and session registration calls
- Update bootstrap_greeting.json fixture: schema_version → version to
match current PROFILE_JSON_SCHEMA
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: cargo fmt
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(safety): address PR review — expand injection scanning and harden profile sync
- BOOTSTRAP.md: fix target "profile" → "context/profile.json" so the
write hits the correct path and triggers profile sync
- IDENTITY_FILES: add context/assistant-directives.md to the scanned
set since it is also injected into the system prompt
- sync_profile_documents(): scan derived USER.md and assistant-directives
content through Sanitizer before writing, rejecting High/Critical
injection patterns
- profile_evolution_prompt(): wrap recent_messages_summary in <user_data>
delimiters with untrusted-data instruction to mitigate indirect
prompt injection
- routine-advisor skill: update cron examples from 6-field to standard
5-field format for consistency with routine_create tool docs
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: cargo fmt
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(setup): detect env-provided LLM keys during quick-mode onboarding
Quick-mode wizard now checks LLM_BACKEND, NEARAI_API_KEY,
ANTHROPIC_API_KEY, and OPENAI_API_KEY env vars to pre-populate
the provider setting, so users aren't re-prompted for credentials
they already supplied. Also teaches setup_nearai() to recognize
NEARAI_API_KEY from env (previously only checked session tokens).
Includes web UI cleanup (remove duplicate event listeners) and
e2e test response count adjustment.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(test): update routine_create_list to expect 7-field normalized cron
The cron normalizer now always expands to 7-field format, so the
stored schedule is "0 0 9 * * * *" not "0 0 9 * * *".
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat(setup): skip LLM provider prompts when NEARAI_API_KEY is present
In quick mode, if NEARAI_API_KEY is set in the environment and the
backend was auto-detected as nearai, skip the interactive inference
provider and model selection steps. The API key is persisted to the
secrets store and a default model is set automatically.
Also simplify the static fallback model list for nearai to a single
default entry.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: unify default model, static bootstrap greeting, and web UI cleanup
- Add DEFAULT_MODEL const and default_models() fallback list in
llm/nearai_chat.rs; use from config, wizard, and .env.example so the
default model is defined in one place
- Restore multi-model fallback list in setup wizard (was reduced to 1)
- Move BOOTSTRAP_GREETING to module-level const (out of run() body)
- Replace LLM-based bootstrap with static greeting (persist to DB before
channels start, then broadcast — eliminates startup LLM call and race)
- Fix double env::var read for NEARAI_API_KEY in quick setup path
- Move thread sidebar buttons into threads-section-header (web UI)
- Remove orphaned .thread-sidebar-header CSS and fix double blank line
- Update bootstrap e2e test for static greeting (no LLM trace needed)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(safety): move prompt injection scanning into Workspace write/append
Addresses PR #927 review comments (#1, #3) — identity file write
protection and unsanitized profile fields in system prompt.
Instead of scanning at the tool layer (memory.rs) or the sync layer
(sync_profile_documents), injection scanning now lives in
Workspace::write() and Workspace::append() for all files that are
injected into the system prompt. This ensures every code path that
writes to these files is protected, including future ones.
- Add SYSTEM_PROMPT_FILES const and reject_if_injected() in workspace
- Add WorkspaceError::InjectionRejected variant
- Add map_write_err() in memory.rs to convert InjectionRejected to
ToolError::NotAuthorized
- Remove redundant IDENTITY_FILES/Sanitizer from memory.rs
- Remove redundant sanitizer calls from sync_profile_documents()
- Move sanitization tests to workspace::tests
- Existing integration test (test_memory_write_rejects_injection)
continues to pass through the new path
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review — merge marker order, orphan thread, stale fixture
- merge_profile_section: search for END marker after BEGIN position to
avoid matching a stray END earlier in the file
- Bootstrap phase 2: use get_or_create_session + Thread::with_id instead
of resolve_thread(None) to avoid creating an orphan thread
- setup_nearai: use env_or_override for NEARAI_API_KEY consistency with
runtime overlay
- Delete orphaned bootstrap_greeting.json fixture (no test references it)
- Add test_merge_end_marker_must_follow_begin regression test
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: fmt agent_loop.rs (CI stable rustfmt)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: lazy-init sanitizer, check profile non-empty before skipping bootstrap
Address Copilot review:
- Use LazyLock<Sanitizer> to avoid rebuilding Aho-Corasick + regexes
on every workspace write
- has_profile check now requires non-empty content, not just file
existence, to prevent empty profile.json from suppressing onboarding
- Add seed_tests integration tests (libsql-backed) verifying:
- Empty profile.json does not suppress BOOTSTRAP.md seeding
- Non-empty profile.json correctly suppresses bootstrap for upgrades
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: duplicate language handler, empty LLM_BACKEND, test_rig style
Address Copilot review on PR #927:
- Remove duplicate language-option click listeners (delegated
data-action handler already covers them)
- Guard LLM_BACKEND env prefill against empty string to prevent
suppressing API-key-based auto-detection
- Use destructured local `keep_bootstrap` instead of `self.keep_bootstrap`
in test_rig for consistency after destructure
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: update stale BOOTSTRAP.md write-protection comment [skip-regression-check]
BOOTSTRAP.md is now in SYSTEM_PROMPT_FILES and gets injection scanning
on write. The old comment incorrectly stated it was not write-protected.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: replace debug_assert panics with graceful error returns [skip-regression-check]
debug_assert! in execute_tool_with_safety and JobContext::transition_to
panicked in test builds before the graceful error path could run.
Existing tests (test_cancel_job_completed, test_execute_empty_tool_name_returns_not_found)
already cover these paths — they were the ones failing.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review — schema label, env var check, path normalization, profile validation
1. Label ANALYSIS_FRAMEWORK and PROFILE_JSON_SCHEMA sections separately
in bootstrap prompt so the LLM knows which blob is the target structure.
2. Wizard quick-mode backend auto-detection now rejects empty env vars
(std::env::var().is_ok_and(|v| !v.is_empty())) to avoid selecting the
wrong backend when e.g. NEARAI_API_KEY="" is set.
3. Normalize the target path before comparing with paths::PROFILE in
memory_write so non-canonical variants like "context//profile.json"
still trigger profile sync.
4. seed_if_empty now requires valid JSON parse of context/profile.json
before treating it as a populated profile. Corrupted content no longer
permanently suppresses bootstrap seeding.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
* fix: address Copilot review — append scan, profile validation, env_or_override
1. Workspace::append() now scans the combined content (existing + new)
for prompt injection, not just the appended chunk. Prevents split-
injection evasion across multiple appends.
2. seed_if_empty() now deserializes into PsychographicProfile instead of
serde_json::Value for profile validation. Stray/legacy JSON that
doesn't match the expected schema no longer suppresses bootstrap.
3. Wizard quick-mode backend auto-detection now uses env_or_override()
to honor runtime overlays and injected secrets. LLM_BACKEND value
is trimmed before storage.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: add bootstrap_onboarding_clears_bootstrap E2E trace test
Exercises the full onboarding flow end-to-end:
1. Bootstrap greeting fires automatically on fresh workspace
2. User converses for 3 turns (name, tools, work style)
3. Agent writes psychographic profile to context/profile.json
4. Profile sync generates USER.md and assistant-directives.md
5. Agent writes IDENTITY.md (chosen persona)
6. Agent clears BOOTSTRAP.md via memory_write(target: "bootstrap")
Verifies:
- BOOTSTRAP.md is non-empty before onboarding, empty after
- bootstrap_completed flag is set
- Profile contains expected user data (name, profession, interests)
- USER.md contains profile-derived content (name, tone, profession)
- Assistant-directives.md references user and communication style
- IDENTITY.md contains agent's chosen persona name
- All memory_write calls succeed
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review — slash collapse, env_or_override, cron trim [skip-regression-check]
1. memory.rs path normalization now uses the same char-by-char loop as
Workspace::normalize_path() to fully collapse consecutive slashes
(e.g. "context///profile.json" → "context/profile.json").
2. Quick-mode NEARAI_API_KEY check (line 239) now uses env_or_override()
consistently with the backend auto-detection block above it.
3. normalize_cron_expression() trims input before field counting so the
passthrough branch (7+ fields) also strips whitespace.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Jay Zalowitz <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
1146 lines
43 KiB
Rust
1146 lines
43 KiB
Rust
//! Psychographic profile types for user onboarding.
|
|
//!
|
|
//! Adapted from NPA's psychographic profiling system. These types capture
|
|
//! personality traits, communication preferences, behavioral patterns, and
|
|
//! assistance preferences discovered during the "Getting to Know You"
|
|
//! onboarding conversation and refined through ongoing interactions.
|
|
//!
|
|
//! The profile is stored as JSON in `context/profile.json` and rendered
|
|
//! as markdown in `USER.md` for system prompt injection.
|
|
|
|
use serde::{Deserialize, Deserializer, Serialize};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9-dimension analysis framework (shared by onboarding + evolution prompts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Structured analysis framework used by both onboarding profile generation
|
|
/// and weekly profile evolution to guide the LLM in psychographic analysis.
|
|
pub const ANALYSIS_FRAMEWORK: &str = r#"Analyze across these 9 dimensions:
|
|
|
|
1. COMMUNICATION STYLE
|
|
- detail_level: detailed | concise | balanced | unknown
|
|
- formality: casual | balanced | formal | unknown
|
|
- tone: warm | neutral | professional
|
|
- response_speed: quick | thoughtful | depends | unknown
|
|
- learning_style: deep_dive | overview | hands_on | unknown
|
|
- pace: fast | measured | variable | unknown
|
|
Look for: message length, vocabulary complexity, emoji use, sentence structure,
|
|
how quickly they respond, whether they prefer bullet points or prose.
|
|
|
|
2. PERSONALITY TRAITS (0-100 scale, 50 = average)
|
|
- empathy, problem_solving, emotional_intelligence, adaptability, communication
|
|
Scoring guidance: 40-60 is average. Only score above 70 or below 30 with
|
|
strong evidence from multiple messages. A single empathetic statement is not
|
|
enough for empathy=90.
|
|
|
|
3. SOCIAL & RELATIONSHIP PATTERNS
|
|
- social_energy: extroverted | introverted | ambivert | unknown
|
|
- friendship.style: few_close | wide_circle | mixed | unknown
|
|
- friendship.support_style: listener | problem_solver | emotional_support | perspective_giver | adaptive | unknown
|
|
- relationship_values: primary values, secondary values, deal_breakers
|
|
Look for: how they talk about others, group vs solo preferences, how they
|
|
describe helping friends/family (the "one step removed" technique).
|
|
|
|
4. DECISION MAKING & INTERACTION
|
|
- communication.decision_making: intuitive | analytical | balanced | unknown
|
|
- interaction_preferences.proactivity_style: proactive | reactive | collaborative
|
|
- interaction_preferences.feedback_style: direct | gentle | detailed | minimal
|
|
- interaction_preferences.decision_making: autonomous | guided | collaborative
|
|
Look for: do they want options or recommendations? Do they analyze before
|
|
deciding or go with gut feel?
|
|
|
|
5. BEHAVIORAL PATTERNS
|
|
- frictions: things that frustrate or block them
|
|
- desired_outcomes: what they're trying to achieve
|
|
- time_wasters: activities they want to minimize
|
|
- pain_points: recurring challenges
|
|
- strengths: things they excel at
|
|
- suggested_support: concrete ways the assistant can help
|
|
Look for: complaints, wishes, repeated themes, "I always have to..." patterns.
|
|
|
|
6. CONTEXTUAL INFO
|
|
- profession, interests, life_stage, challenges
|
|
Only include what is directly stated or strongly implied.
|
|
|
|
7. ASSISTANCE PREFERENCES
|
|
- proactivity: high | medium | low | unknown
|
|
- formality: formal | casual | professional | unknown
|
|
- interaction_style: direct | conversational | minimal | unknown
|
|
- notification_preferences: frequent | moderate | minimal | unknown
|
|
- focus_areas, routines, goals (arrays of strings)
|
|
Look for: how they frame requests, whether they want hand-holding or autonomy.
|
|
|
|
8. USER COHORT
|
|
- cohort: busy_professional | new_parent | student | elder | other
|
|
- confidence: 0-100 (how sure you are of this classification)
|
|
- indicators: specific evidence strings supporting the classification
|
|
Only classify with confidence > 30 if there is direct evidence.
|
|
|
|
9. FRIENDSHIP QUALITIES (deep structure)
|
|
- qualities.user_values: what they value in friendships
|
|
- qualities.friends_appreciate: what friends like about them
|
|
- qualities.consistency_pattern: consistent | adaptive | situational | null
|
|
- qualities.primary_role: their main role in friendships (e.g., "the organizer")
|
|
- qualities.secondary_roles: other roles they play
|
|
- qualities.challenging_aspects: relationship difficulties they mention
|
|
|
|
GENERAL RULES:
|
|
- Be evidence-based: only include insights supported by message content.
|
|
- Use "unknown" or empty arrays when there is insufficient evidence.
|
|
- Prefer conservative scores over speculative ones.
|
|
- Look for patterns across multiple messages, not just individual statements.
|
|
"#;
|
|
|
|
/// JSON schema reference for the psychographic profile.
|
|
///
|
|
/// Shared by bootstrap onboarding and profile evolution (workspace/mod.rs)
|
|
/// prompt generation to ensure the LLM always targets the same structure.
|
|
pub const PROFILE_JSON_SCHEMA: &str = r#"{
|
|
"version": 2,
|
|
"preferred_name": "<string>",
|
|
"personality": {
|
|
"empathy": <0-100>,
|
|
"problem_solving": <0-100>,
|
|
"emotional_intelligence": <0-100>,
|
|
"adaptability": <0-100>,
|
|
"communication": <0-100>
|
|
},
|
|
"communication": {
|
|
"detail_level": "<detailed|concise|balanced|unknown>",
|
|
"formality": "<casual|balanced|formal|unknown>",
|
|
"tone": "<warm|neutral|professional>",
|
|
"learning_style": "<deep_dive|overview|hands_on|unknown>",
|
|
"social_energy": "<extroverted|introverted|ambivert|unknown>",
|
|
"decision_making": "<intuitive|analytical|balanced|unknown>",
|
|
"pace": "<fast|measured|variable|unknown>",
|
|
"response_speed": "<quick|thoughtful|depends|unknown>"
|
|
},
|
|
"cohort": {
|
|
"cohort": "<busy_professional|new_parent|student|elder|other>",
|
|
"confidence": <0-100>,
|
|
"indicators": ["<evidence string>"]
|
|
},
|
|
"behavior": {
|
|
"frictions": ["<string>"],
|
|
"desired_outcomes": ["<string>"],
|
|
"time_wasters": ["<string>"],
|
|
"pain_points": ["<string>"],
|
|
"strengths": ["<string>"],
|
|
"suggested_support": ["<string>"]
|
|
},
|
|
"friendship": {
|
|
"style": "<few_close|wide_circle|mixed|unknown>",
|
|
"values": ["<string>"],
|
|
"support_style": "<listener|problem_solver|emotional_support|perspective_giver|adaptive|unknown>",
|
|
"qualities": {
|
|
"user_values": ["<string>"],
|
|
"friends_appreciate": ["<string>"],
|
|
"consistency_pattern": "<consistent|adaptive|situational|null>",
|
|
"primary_role": "<string or null>",
|
|
"secondary_roles": ["<string>"],
|
|
"challenging_aspects": ["<string>"]
|
|
}
|
|
},
|
|
"assistance": {
|
|
"proactivity": "<high|medium|low|unknown>",
|
|
"formality": "<formal|casual|professional|unknown>",
|
|
"focus_areas": ["<string>"],
|
|
"routines": ["<string>"],
|
|
"goals": ["<string>"],
|
|
"interaction_style": "<direct|conversational|minimal|unknown>",
|
|
"notification_preferences": "<minimal|moderate|frequent|unknown>"
|
|
},
|
|
"context": {
|
|
"profession": "<string or null>",
|
|
"interests": ["<string>"],
|
|
"life_stage": "<string or null>",
|
|
"challenges": ["<string>"]
|
|
},
|
|
"relationship_values": {
|
|
"primary": ["<string>"],
|
|
"secondary": ["<string>"],
|
|
"deal_breakers": ["<string>"]
|
|
},
|
|
"interaction_preferences": {
|
|
"proactivity_style": "<proactive|reactive|collaborative>",
|
|
"feedback_style": "<direct|gentle|detailed|minimal>",
|
|
"decision_making": "<autonomous|guided|collaborative>"
|
|
},
|
|
"analysis_metadata": {
|
|
"message_count": <number>,
|
|
"confidence_score": <0.0-1.0>,
|
|
"analysis_method": "<onboarding|evolution>",
|
|
"update_type": "<initial|weekly>"
|
|
},
|
|
"confidence": <0.0-1.0>,
|
|
"created_at": "<ISO-8601>",
|
|
"updated_at": "<ISO-8601>"
|
|
}"#;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Personality traits
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Personality trait scores on a 0-100 scale.
|
|
///
|
|
/// Values are clamped to 0-100 during deserialization via [`deserialize_trait_score`].
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct PersonalityTraits {
|
|
#[serde(deserialize_with = "deserialize_trait_score")]
|
|
pub empathy: u8,
|
|
#[serde(deserialize_with = "deserialize_trait_score")]
|
|
pub problem_solving: u8,
|
|
#[serde(deserialize_with = "deserialize_trait_score")]
|
|
pub emotional_intelligence: u8,
|
|
#[serde(deserialize_with = "deserialize_trait_score")]
|
|
pub adaptability: u8,
|
|
#[serde(deserialize_with = "deserialize_trait_score")]
|
|
pub communication: u8,
|
|
}
|
|
|
|
/// Deserialize a trait score, clamping to the 0-100 range.
|
|
///
|
|
/// Accepts integer or floating-point JSON numbers. Values outside 0-100
|
|
/// are clamped. Non-finite or non-numeric values fall back to a default of 50.
|
|
fn deserialize_trait_score<'de, D>(deserializer: D) -> Result<u8, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let raw = f64::deserialize(deserializer).unwrap_or(50.0);
|
|
if !raw.is_finite() {
|
|
return Ok(50);
|
|
}
|
|
let clamped = raw.clamp(0.0, 100.0);
|
|
Ok(clamped.round() as u8)
|
|
}
|
|
|
|
impl Default for PersonalityTraits {
|
|
fn default() -> Self {
|
|
Self {
|
|
empathy: 50,
|
|
problem_solving: 50,
|
|
emotional_intelligence: 50,
|
|
adaptability: 50,
|
|
communication: 50,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Communication preferences
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// How the user prefers to communicate.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct CommunicationPreferences {
|
|
/// "detailed" | "concise" | "balanced" | "unknown"
|
|
pub detail_level: String,
|
|
/// "casual" | "balanced" | "formal" | "unknown"
|
|
pub formality: String,
|
|
/// "warm" | "neutral" | "professional"
|
|
pub tone: String,
|
|
/// "deep_dive" | "overview" | "hands_on" | "unknown"
|
|
pub learning_style: String,
|
|
/// "extroverted" | "introverted" | "ambivert" | "unknown"
|
|
pub social_energy: String,
|
|
/// "intuitive" | "analytical" | "balanced" | "unknown"
|
|
pub decision_making: String,
|
|
/// "fast" | "measured" | "variable" | "unknown"
|
|
pub pace: String,
|
|
/// "quick" | "thoughtful" | "depends" | "unknown"
|
|
#[serde(default = "default_unknown")]
|
|
pub response_speed: String,
|
|
}
|
|
|
|
fn default_unknown() -> String {
|
|
"unknown".into()
|
|
}
|
|
|
|
fn default_moderate() -> String {
|
|
"moderate".into()
|
|
}
|
|
|
|
impl Default for CommunicationPreferences {
|
|
fn default() -> Self {
|
|
Self {
|
|
detail_level: "balanced".into(),
|
|
formality: "balanced".into(),
|
|
tone: "neutral".into(),
|
|
learning_style: "unknown".into(),
|
|
social_energy: "unknown".into(),
|
|
decision_making: "unknown".into(),
|
|
pace: "unknown".into(),
|
|
response_speed: "unknown".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User cohort
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// User cohort classification.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum UserCohort {
|
|
BusyProfessional,
|
|
NewParent,
|
|
Student,
|
|
Elder,
|
|
#[default]
|
|
Other,
|
|
}
|
|
|
|
impl std::fmt::Display for UserCohort {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::BusyProfessional => write!(f, "busy professional"),
|
|
Self::NewParent => write!(f, "new parent"),
|
|
Self::Student => write!(f, "student"),
|
|
Self::Elder => write!(f, "elder"),
|
|
Self::Other => write!(f, "general"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cohort classification with confidence and evidence.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct CohortClassification {
|
|
#[serde(default)]
|
|
pub cohort: UserCohort,
|
|
/// 0-100 confidence in this classification.
|
|
#[serde(default)]
|
|
pub confidence: u8,
|
|
/// Evidence strings supporting the classification.
|
|
#[serde(default)]
|
|
pub indicators: Vec<String>,
|
|
}
|
|
|
|
/// Custom deserializer: accepts either a bare string (old format) or a struct (new format).
|
|
fn deserialize_cohort<'de, D>(deserializer: D) -> Result<CohortClassification, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum CohortOrString {
|
|
Classification(CohortClassification),
|
|
BareEnum(UserCohort),
|
|
}
|
|
|
|
match CohortOrString::deserialize(deserializer)? {
|
|
CohortOrString::Classification(c) => Ok(c),
|
|
CohortOrString::BareEnum(e) => Ok(CohortClassification {
|
|
cohort: e,
|
|
confidence: 0,
|
|
indicators: Vec::new(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Behavior patterns
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Behavioral observations.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
pub struct BehaviorPatterns {
|
|
pub frictions: Vec<String>,
|
|
pub desired_outcomes: Vec<String>,
|
|
pub time_wasters: Vec<String>,
|
|
pub pain_points: Vec<String>,
|
|
pub strengths: Vec<String>,
|
|
/// Concrete ways the assistant can help.
|
|
#[serde(default)]
|
|
pub suggested_support: Vec<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Friendship profile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Deep friendship qualities.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
pub struct FriendshipQualities {
|
|
#[serde(default)]
|
|
pub user_values: Vec<String>,
|
|
#[serde(default)]
|
|
pub friends_appreciate: Vec<String>,
|
|
/// "consistent" | "adaptive" | "situational" | "unknown"
|
|
#[serde(default)]
|
|
pub consistency_pattern: Option<String>,
|
|
/// Main role in friendships (e.g., "the organizer", "the listener").
|
|
#[serde(default)]
|
|
pub primary_role: Option<String>,
|
|
#[serde(default)]
|
|
pub secondary_roles: Vec<String>,
|
|
#[serde(default)]
|
|
pub challenging_aspects: Vec<String>,
|
|
}
|
|
|
|
/// Custom deserializer: accepts either a `Vec<String>` (old format) or `FriendshipQualities`.
|
|
fn deserialize_qualities<'de, D>(deserializer: D) -> Result<FriendshipQualities, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum QualitiesOrVec {
|
|
Struct(FriendshipQualities),
|
|
Vec(Vec<String>),
|
|
}
|
|
|
|
match QualitiesOrVec::deserialize(deserializer)? {
|
|
QualitiesOrVec::Struct(q) => Ok(q),
|
|
QualitiesOrVec::Vec(v) => Ok(FriendshipQualities {
|
|
user_values: v,
|
|
..Default::default()
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Friendship and support profile.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct FriendshipProfile {
|
|
/// "few_close" | "wide_circle" | "mixed" | "unknown"
|
|
pub style: String,
|
|
pub values: Vec<String>,
|
|
/// "listener" | "problem_solver" | "emotional_support" | "perspective_giver" | "adaptive" | "unknown"
|
|
pub support_style: String,
|
|
/// Deep friendship qualities structure.
|
|
#[serde(default, deserialize_with = "deserialize_qualities")]
|
|
pub qualities: FriendshipQualities,
|
|
}
|
|
|
|
impl Default for FriendshipProfile {
|
|
fn default() -> Self {
|
|
Self {
|
|
style: "unknown".into(),
|
|
values: Vec::new(),
|
|
support_style: "unknown".into(),
|
|
qualities: FriendshipQualities::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Assistance preferences
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// How the user wants the assistant to behave.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct AssistancePreferences {
|
|
/// "high" | "medium" | "low" | "unknown"
|
|
pub proactivity: String,
|
|
/// "formal" | "casual" | "professional" | "unknown"
|
|
pub formality: String,
|
|
pub focus_areas: Vec<String>,
|
|
pub routines: Vec<String>,
|
|
pub goals: Vec<String>,
|
|
/// "direct" | "conversational" | "minimal" | "unknown"
|
|
pub interaction_style: String,
|
|
/// "frequent" | "moderate" | "minimal" | "unknown"
|
|
#[serde(default = "default_moderate")]
|
|
pub notification_preferences: String,
|
|
}
|
|
|
|
impl Default for AssistancePreferences {
|
|
fn default() -> Self {
|
|
Self {
|
|
proactivity: "medium".into(),
|
|
formality: "unknown".into(),
|
|
focus_areas: Vec::new(),
|
|
routines: Vec::new(),
|
|
goals: Vec::new(),
|
|
interaction_style: "unknown".into(),
|
|
notification_preferences: "moderate".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Contextual info
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Contextual information about the user.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
pub struct ContextualInfo {
|
|
pub profession: Option<String>,
|
|
pub interests: Vec<String>,
|
|
pub life_stage: Option<String>,
|
|
pub challenges: Vec<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// New types: relationship values, interaction preferences, analysis metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Core relationship values and deal-breakers.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
pub struct RelationshipValues {
|
|
/// Most important values in relationships.
|
|
#[serde(default)]
|
|
pub primary: Vec<String>,
|
|
/// Additional important values.
|
|
#[serde(default)]
|
|
pub secondary: Vec<String>,
|
|
/// Unacceptable behaviors/traits.
|
|
#[serde(default)]
|
|
pub deal_breakers: Vec<String>,
|
|
}
|
|
|
|
/// How the user prefers to interact with the assistant.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct InteractionPreferences {
|
|
/// "proactive" | "reactive" | "collaborative"
|
|
pub proactivity_style: String,
|
|
/// "direct" | "gentle" | "detailed" | "minimal"
|
|
pub feedback_style: String,
|
|
/// "autonomous" | "guided" | "collaborative"
|
|
pub decision_making: String,
|
|
}
|
|
|
|
impl Default for InteractionPreferences {
|
|
fn default() -> Self {
|
|
Self {
|
|
proactivity_style: "reactive".into(),
|
|
feedback_style: "direct".into(),
|
|
decision_making: "guided".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Metadata about the most recent profile analysis.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
pub struct AnalysisMetadata {
|
|
/// Number of user messages analyzed.
|
|
#[serde(default)]
|
|
pub message_count: u32,
|
|
/// ISO-8601 timestamp of the analysis.
|
|
#[serde(default)]
|
|
pub analysis_date: Option<String>,
|
|
/// Time range of messages analyzed (e.g., "30 days").
|
|
#[serde(default)]
|
|
pub time_range: Option<String>,
|
|
/// LLM model used for analysis.
|
|
#[serde(default)]
|
|
pub model_used: Option<String>,
|
|
/// Overall confidence score (0.0-1.0).
|
|
#[serde(default)]
|
|
pub confidence_score: f64,
|
|
/// "onboarding" | "evolution" | "passive"
|
|
#[serde(default)]
|
|
pub analysis_method: Option<String>,
|
|
/// "initial" | "weekly" | "event_driven"
|
|
#[serde(default)]
|
|
pub update_type: Option<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The full psychographic profile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The full psychographic profile.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct PsychographicProfile {
|
|
/// Schema version (1 = original, 2 = enriched with NPA patterns).
|
|
pub version: u32,
|
|
/// What the user likes to be called.
|
|
pub preferred_name: String,
|
|
pub personality: PersonalityTraits,
|
|
pub communication: CommunicationPreferences,
|
|
/// Cohort classification with confidence and evidence.
|
|
#[serde(deserialize_with = "deserialize_cohort")]
|
|
pub cohort: CohortClassification,
|
|
pub behavior: BehaviorPatterns,
|
|
pub friendship: FriendshipProfile,
|
|
pub assistance: AssistancePreferences,
|
|
pub context: ContextualInfo,
|
|
/// Core relationship values.
|
|
#[serde(default)]
|
|
pub relationship_values: RelationshipValues,
|
|
/// How the user prefers to interact with the assistant.
|
|
#[serde(default)]
|
|
pub interaction_preferences: InteractionPreferences,
|
|
/// Metadata about the most recent analysis.
|
|
#[serde(default)]
|
|
pub analysis_metadata: AnalysisMetadata,
|
|
/// Top-level confidence (0.0-1.0), convenience mirror of analysis_metadata.confidence_score.
|
|
#[serde(default)]
|
|
pub confidence: f64,
|
|
/// ISO-8601 creation timestamp.
|
|
pub created_at: String,
|
|
/// ISO-8601 last update timestamp.
|
|
pub updated_at: String,
|
|
}
|
|
|
|
impl Default for PsychographicProfile {
|
|
fn default() -> Self {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
Self {
|
|
version: 2,
|
|
preferred_name: String::new(),
|
|
personality: PersonalityTraits::default(),
|
|
communication: CommunicationPreferences::default(),
|
|
cohort: CohortClassification::default(),
|
|
behavior: BehaviorPatterns::default(),
|
|
friendship: FriendshipProfile::default(),
|
|
assistance: AssistancePreferences::default(),
|
|
context: ContextualInfo::default(),
|
|
relationship_values: RelationshipValues::default(),
|
|
interaction_preferences: InteractionPreferences::default(),
|
|
analysis_metadata: AnalysisMetadata::default(),
|
|
confidence: 0.0,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PsychographicProfile {
|
|
/// Whether this profile contains meaningful user data beyond defaults.
|
|
///
|
|
/// Used to decide whether to inject bootstrap onboarding instructions
|
|
/// or profile-based personalization into the system prompt.
|
|
pub fn is_populated(&self) -> bool {
|
|
!self.preferred_name.is_empty()
|
|
|| self.context.profession.is_some()
|
|
|| !self.assistance.goals.is_empty()
|
|
}
|
|
|
|
/// Render a concise markdown summary suitable for `USER.md`.
|
|
pub fn to_user_md(&self) -> String {
|
|
let mut sections = Vec::new();
|
|
|
|
sections.push("# User Profile\n".to_string());
|
|
|
|
if !self.preferred_name.is_empty() {
|
|
sections.push(format!("**Name**: {}\n", self.preferred_name));
|
|
}
|
|
|
|
// Communication style
|
|
let mut comm = format!(
|
|
"**Communication**: {} tone, {} detail, {} formality, {} pace",
|
|
self.communication.tone,
|
|
self.communication.detail_level,
|
|
self.communication.formality,
|
|
self.communication.pace,
|
|
);
|
|
if self.communication.response_speed != "unknown" {
|
|
comm.push_str(&format!(
|
|
", {} response speed",
|
|
self.communication.response_speed
|
|
));
|
|
}
|
|
sections.push(comm);
|
|
|
|
// Decision making
|
|
if self.communication.decision_making != "unknown" {
|
|
sections.push(format!(
|
|
"**Decision style**: {}",
|
|
self.communication.decision_making
|
|
));
|
|
}
|
|
|
|
// Social energy
|
|
if self.communication.social_energy != "unknown" {
|
|
sections.push(format!(
|
|
"**Social energy**: {}",
|
|
self.communication.social_energy
|
|
));
|
|
}
|
|
|
|
// Cohort
|
|
if self.cohort.cohort != UserCohort::Other {
|
|
let mut cohort_line = format!("**User type**: {}", self.cohort.cohort);
|
|
if self.cohort.confidence > 0 {
|
|
cohort_line.push_str(&format!(" ({}% confidence)", self.cohort.confidence));
|
|
}
|
|
sections.push(cohort_line);
|
|
}
|
|
|
|
// Profession
|
|
if let Some(ref profession) = self.context.profession {
|
|
sections.push(format!("**Profession**: {}", profession));
|
|
}
|
|
|
|
// Life stage
|
|
if let Some(ref stage) = self.context.life_stage {
|
|
sections.push(format!("**Life stage**: {}", stage));
|
|
}
|
|
|
|
// Interests
|
|
if !self.context.interests.is_empty() {
|
|
sections.push(format!(
|
|
"**Interests**: {}",
|
|
self.context.interests.join(", ")
|
|
));
|
|
}
|
|
|
|
// Goals
|
|
if !self.assistance.goals.is_empty() {
|
|
sections.push(format!("**Goals**: {}", self.assistance.goals.join(", ")));
|
|
}
|
|
|
|
// Focus areas
|
|
if !self.assistance.focus_areas.is_empty() {
|
|
sections.push(format!(
|
|
"**Focus areas**: {}",
|
|
self.assistance.focus_areas.join(", ")
|
|
));
|
|
}
|
|
|
|
// Strengths
|
|
if !self.behavior.strengths.is_empty() {
|
|
sections.push(format!(
|
|
"**Strengths**: {}",
|
|
self.behavior.strengths.join(", ")
|
|
));
|
|
}
|
|
|
|
// Pain points
|
|
if !self.behavior.pain_points.is_empty() {
|
|
sections.push(format!(
|
|
"**Pain points**: {}",
|
|
self.behavior.pain_points.join(", ")
|
|
));
|
|
}
|
|
|
|
// Relationship values
|
|
if !self.relationship_values.primary.is_empty() {
|
|
sections.push(format!(
|
|
"**Core values**: {}",
|
|
self.relationship_values.primary.join(", ")
|
|
));
|
|
}
|
|
|
|
// Assistance preferences
|
|
let mut assist = format!(
|
|
"\n## Assistance Preferences\n\n\
|
|
- **Proactivity**: {}\n\
|
|
- **Interaction style**: {}",
|
|
self.assistance.proactivity, self.assistance.interaction_style,
|
|
);
|
|
if self.assistance.notification_preferences != "moderate" {
|
|
assist.push_str(&format!(
|
|
"\n- **Notifications**: {}",
|
|
self.assistance.notification_preferences
|
|
));
|
|
}
|
|
sections.push(assist);
|
|
|
|
// Interaction preferences
|
|
if self.interaction_preferences.feedback_style != "direct" {
|
|
sections.push(format!(
|
|
"- **Feedback style**: {}",
|
|
self.interaction_preferences.feedback_style
|
|
));
|
|
}
|
|
|
|
// Friendship/support style
|
|
if self.friendship.support_style != "unknown" {
|
|
sections.push(format!(
|
|
"- **Support style**: {}",
|
|
self.friendship.support_style
|
|
));
|
|
}
|
|
|
|
sections.join("\n")
|
|
}
|
|
|
|
/// Generate behavioral directives for `context/assistant-directives.md`.
|
|
pub fn to_assistant_directives(&self) -> String {
|
|
let proactivity_instruction = match self.assistance.proactivity.as_str() {
|
|
"high" => "Proactively suggest actions, check in regularly, and anticipate needs.",
|
|
"low" => "Wait for explicit requests. Minimize unsolicited suggestions.",
|
|
_ => "Offer suggestions when relevant but don't overwhelm.",
|
|
};
|
|
|
|
let name = if self.preferred_name.is_empty() {
|
|
"the user"
|
|
} else {
|
|
&self.preferred_name
|
|
};
|
|
|
|
let mut lines = vec![
|
|
"# Assistant Directives\n".to_string(),
|
|
format!("Based on {}'s profile:\n", name),
|
|
format!(
|
|
"- **Proactivity**: {} -- {}",
|
|
self.assistance.proactivity, proactivity_instruction
|
|
),
|
|
format!(
|
|
"- **Communication**: {} tone, {} detail level",
|
|
self.communication.tone, self.communication.detail_level
|
|
),
|
|
format!(
|
|
"- **Decision support**: {} style",
|
|
self.communication.decision_making
|
|
),
|
|
];
|
|
|
|
if self.communication.response_speed != "unknown" {
|
|
lines.push(format!(
|
|
"- **Response pacing**: {} (match this energy)",
|
|
self.communication.response_speed
|
|
));
|
|
}
|
|
|
|
if self.interaction_preferences.feedback_style != "direct" {
|
|
lines.push(format!(
|
|
"- **Feedback style**: {}",
|
|
self.interaction_preferences.feedback_style
|
|
));
|
|
}
|
|
|
|
if self.assistance.notification_preferences != "moderate"
|
|
&& self.assistance.notification_preferences != "unknown"
|
|
{
|
|
lines.push(format!(
|
|
"- **Notification frequency**: {}",
|
|
self.assistance.notification_preferences
|
|
));
|
|
}
|
|
|
|
if !self.assistance.focus_areas.is_empty() {
|
|
lines.push(format!(
|
|
"- **Focus areas**: {}",
|
|
self.assistance.focus_areas.join(", ")
|
|
));
|
|
}
|
|
|
|
if !self.assistance.goals.is_empty() {
|
|
lines.push(format!(
|
|
"- **Goals to support**: {}",
|
|
self.assistance.goals.join(", ")
|
|
));
|
|
}
|
|
|
|
if !self.behavior.pain_points.is_empty() {
|
|
lines.push(format!(
|
|
"- **Pain points to address**: {}",
|
|
self.behavior.pain_points.join(", ")
|
|
));
|
|
}
|
|
|
|
lines.push(String::new());
|
|
lines.push(
|
|
"Start conservative with autonomy — ask before taking actions that affect \
|
|
others or the outside world. Increase autonomy as trust grows."
|
|
.to_string(),
|
|
);
|
|
|
|
lines.join("\n")
|
|
}
|
|
|
|
/// Generate a personalized `HEARTBEAT.md` checklist.
|
|
pub fn to_heartbeat_md(&self) -> String {
|
|
let name = if self.preferred_name.is_empty() {
|
|
"the user".to_string()
|
|
} else {
|
|
self.preferred_name.clone()
|
|
};
|
|
|
|
let mut items = vec![
|
|
format!("- [ ] Check if {} has any pending tasks or reminders", name),
|
|
"- [ ] Review today's schedule and flag conflicts".to_string(),
|
|
"- [ ] Check for messages that need follow-up".to_string(),
|
|
];
|
|
|
|
for area in &self.assistance.focus_areas {
|
|
items.push(format!("- [ ] Check on progress in: {}", area));
|
|
}
|
|
|
|
format!(
|
|
"# Heartbeat Checklist\n\n\
|
|
{}\n\n\
|
|
Stay quiet during 23:00-08:00 unless urgent.\n\
|
|
If nothing needs attention, reply HEARTBEAT_OK.",
|
|
items.join("\n")
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_profile_serialization_roundtrip() {
|
|
let profile = PsychographicProfile::default();
|
|
let json = serde_json::to_string_pretty(&profile).expect("serialize");
|
|
let deserialized: PsychographicProfile = serde_json::from_str(&json).expect("deserialize");
|
|
assert_eq!(profile.version, deserialized.version);
|
|
assert_eq!(profile.personality, deserialized.personality);
|
|
assert_eq!(profile.communication, deserialized.communication);
|
|
assert_eq!(profile.cohort, deserialized.cohort);
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_cohort_display() {
|
|
assert_eq!(
|
|
UserCohort::BusyProfessional.to_string(),
|
|
"busy professional"
|
|
);
|
|
assert_eq!(UserCohort::Student.to_string(), "student");
|
|
assert_eq!(UserCohort::Other.to_string(), "general");
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_user_md_includes_name() {
|
|
let profile = PsychographicProfile {
|
|
preferred_name: "Alice".into(),
|
|
..Default::default()
|
|
};
|
|
let md = profile.to_user_md();
|
|
assert!(md.contains("**Name**: Alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_user_md_includes_goals() {
|
|
let mut profile = PsychographicProfile::default();
|
|
profile.assistance.goals = vec!["time management".into(), "fitness".into()];
|
|
let md = profile.to_user_md();
|
|
assert!(md.contains("time management, fitness"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_user_md_skips_unknown_fields() {
|
|
let profile = PsychographicProfile::default();
|
|
let md = profile.to_user_md();
|
|
assert!(!md.contains("**User type**"));
|
|
assert!(!md.contains("**Decision style**"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_assistant_directives_high_proactivity() {
|
|
let mut profile = PsychographicProfile::default();
|
|
profile.assistance.proactivity = "high".into();
|
|
profile.preferred_name = "Bob".into();
|
|
let directives = profile.to_assistant_directives();
|
|
assert!(directives.contains("Proactively suggest actions"));
|
|
assert!(directives.contains("Bob's profile"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_heartbeat_md_includes_focus_areas() {
|
|
let profile = PsychographicProfile {
|
|
preferred_name: "Carol".into(),
|
|
assistance: AssistancePreferences {
|
|
focus_areas: vec!["project Alpha".into()],
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
let heartbeat = profile.to_heartbeat_md();
|
|
assert!(heartbeat.contains("Check if Carol"));
|
|
assert!(heartbeat.contains("project Alpha"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_personality_traits_default_is_midpoint() {
|
|
let traits = PersonalityTraits::default();
|
|
assert_eq!(traits.empathy, 50);
|
|
assert_eq!(traits.problem_solving, 50);
|
|
}
|
|
|
|
#[test]
|
|
fn test_personality_trait_score_clamped_to_100() {
|
|
// Values > 100 (including > 255) are clamped to 100
|
|
let json = r#"{"empathy":120,"problem_solving":100,"emotional_intelligence":50,"adaptability":300,"communication":0}"#;
|
|
let traits: PersonalityTraits = serde_json::from_str(json).expect("should parse");
|
|
assert_eq!(traits.empathy, 100);
|
|
assert_eq!(traits.problem_solving, 100);
|
|
assert_eq!(traits.emotional_intelligence, 50);
|
|
assert_eq!(traits.adaptability, 100);
|
|
assert_eq!(traits.communication, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_personality_trait_score_handles_floats_and_negatives() {
|
|
// Floats are rounded, negatives clamped to 0
|
|
let json = r#"{"empathy":75.6,"problem_solving":-10,"emotional_intelligence":50.4,"adaptability":99.5,"communication":0}"#;
|
|
let traits: PersonalityTraits = serde_json::from_str(json).expect("should parse");
|
|
assert_eq!(traits.empathy, 76);
|
|
assert_eq!(traits.problem_solving, 0);
|
|
assert_eq!(traits.emotional_intelligence, 50);
|
|
assert_eq!(traits.adaptability, 100); // 99.5 rounds to 100
|
|
assert_eq!(traits.communication, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_populated_default_is_false() {
|
|
let profile = PsychographicProfile::default();
|
|
assert!(!profile.is_populated());
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_populated_with_name() {
|
|
let profile = PsychographicProfile {
|
|
preferred_name: "Alice".into(),
|
|
..Default::default()
|
|
};
|
|
assert!(profile.is_populated());
|
|
}
|
|
|
|
#[test]
|
|
fn test_backward_compat_old_cohort_format() {
|
|
// Old format: cohort is a bare string
|
|
let json = r#"{
|
|
"version": 1,
|
|
"preferred_name": "Test",
|
|
"personality": {"empathy":50,"problem_solving":50,"emotional_intelligence":50,"adaptability":50,"communication":50},
|
|
"communication": {"detail_level":"balanced","formality":"balanced","tone":"neutral","learning_style":"unknown","social_energy":"unknown","decision_making":"unknown","pace":"unknown"},
|
|
"cohort": "busy_professional",
|
|
"behavior": {"frictions":[],"desired_outcomes":[],"time_wasters":[],"pain_points":[],"strengths":[]},
|
|
"friendship": {"style":"unknown","values":[],"support_style":"unknown","qualities":["reliable","loyal"]},
|
|
"assistance": {"proactivity":"medium","formality":"unknown","focus_areas":[],"routines":[],"goals":[],"interaction_style":"unknown"},
|
|
"context": {"profession":null,"interests":[],"life_stage":null,"challenges":[]},
|
|
"created_at": "2026-02-22T00:00:00Z",
|
|
"updated_at": "2026-02-22T00:00:00Z"
|
|
}"#;
|
|
|
|
let profile: PsychographicProfile =
|
|
serde_json::from_str(json).expect("should parse old format");
|
|
assert_eq!(profile.cohort.cohort, UserCohort::BusyProfessional);
|
|
assert_eq!(profile.cohort.confidence, 0);
|
|
assert!(profile.cohort.indicators.is_empty());
|
|
// Old qualities Vec<String> should map to user_values
|
|
assert_eq!(
|
|
profile.friendship.qualities.user_values,
|
|
vec!["reliable", "loyal"]
|
|
);
|
|
// New fields should have defaults
|
|
assert_eq!(profile.confidence, 0.0);
|
|
assert!(profile.relationship_values.primary.is_empty());
|
|
assert_eq!(profile.interaction_preferences.feedback_style, "direct");
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_format_with_rich_cohort() {
|
|
let json = r#"{
|
|
"version": 2,
|
|
"preferred_name": "Jay",
|
|
"personality": {"empathy":75,"problem_solving":85,"emotional_intelligence":70,"adaptability":80,"communication":72},
|
|
"communication": {"detail_level":"concise","formality":"casual","tone":"warm","learning_style":"hands_on","social_energy":"ambivert","decision_making":"analytical","pace":"fast","response_speed":"quick"},
|
|
"cohort": {"cohort": "busy_professional", "confidence": 85, "indicators": ["mentions deadlines", "talks about team"]},
|
|
"behavior": {"frictions":["context switching"],"desired_outcomes":["more focus time"],"time_wasters":["meetings"],"pain_points":["email overload"],"strengths":["technical depth"],"suggested_support":["automate email triage"]},
|
|
"friendship": {"style":"few_close","values":["authenticity","loyalty"],"support_style":"problem_solver","qualities":{"user_values":["reliability"],"friends_appreciate":["direct advice"],"consistency_pattern":"consistent","primary_role":"the fixer","secondary_roles":["connector"],"challenging_aspects":["impatience"]}},
|
|
"assistance": {"proactivity":"high","formality":"casual","focus_areas":["engineering","health"],"routines":["morning planning"],"goals":["ship product","exercise regularly"],"interaction_style":"direct","notification_preferences":"minimal"},
|
|
"context": {"profession":"software engineer","interests":["AI","fitness","cooking"],"life_stage":"mid-career","challenges":["work-life balance"]},
|
|
"relationship_values": {"primary":["honesty","respect"],"secondary":["humor"],"deal_breakers":["dishonesty"]},
|
|
"interaction_preferences": {"proactivity_style":"proactive","feedback_style":"direct","decision_making":"autonomous"},
|
|
"analysis_metadata": {"message_count":42,"confidence_score":0.85,"analysis_method":"onboarding","update_type":"initial"},
|
|
"confidence": 0.85,
|
|
"created_at": "2026-02-22T00:00:00Z",
|
|
"updated_at": "2026-02-22T00:00:00Z"
|
|
}"#;
|
|
|
|
let profile: PsychographicProfile =
|
|
serde_json::from_str(json).expect("should parse new format");
|
|
assert_eq!(profile.preferred_name, "Jay");
|
|
assert_eq!(profile.personality.empathy, 75);
|
|
assert_eq!(profile.cohort.cohort, UserCohort::BusyProfessional);
|
|
assert_eq!(profile.cohort.confidence, 85);
|
|
assert_eq!(profile.communication.response_speed, "quick");
|
|
assert_eq!(profile.assistance.notification_preferences, "minimal");
|
|
assert_eq!(
|
|
profile.behavior.suggested_support,
|
|
vec!["automate email triage"]
|
|
);
|
|
assert_eq!(
|
|
profile.friendship.qualities.primary_role,
|
|
Some("the fixer".into())
|
|
);
|
|
assert_eq!(
|
|
profile.relationship_values.primary,
|
|
vec!["honesty", "respect"]
|
|
);
|
|
assert_eq!(
|
|
profile.interaction_preferences.proactivity_style,
|
|
"proactive"
|
|
);
|
|
assert_eq!(profile.analysis_metadata.message_count, 42);
|
|
assert!((profile.confidence - 0.85).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn test_profile_from_llm_json_old_format() {
|
|
// Original test: old format with bare cohort enum and Vec qualities
|
|
let json = r#"{
|
|
"version": 1,
|
|
"preferred_name": "Jay",
|
|
"personality": {
|
|
"empathy": 75,
|
|
"problem_solving": 85,
|
|
"emotional_intelligence": 70,
|
|
"adaptability": 80,
|
|
"communication": 72
|
|
},
|
|
"communication": {
|
|
"detail_level": "concise",
|
|
"formality": "casual",
|
|
"tone": "warm",
|
|
"learning_style": "hands_on",
|
|
"social_energy": "ambivert",
|
|
"decision_making": "analytical",
|
|
"pace": "fast"
|
|
},
|
|
"cohort": "busy_professional",
|
|
"behavior": {
|
|
"frictions": ["context switching"],
|
|
"desired_outcomes": ["more focus time"],
|
|
"time_wasters": ["meetings"],
|
|
"pain_points": ["email overload"],
|
|
"strengths": ["technical depth"]
|
|
},
|
|
"friendship": {
|
|
"style": "few_close",
|
|
"values": ["authenticity", "loyalty"],
|
|
"support_style": "problem_solver",
|
|
"qualities": ["reliable"]
|
|
},
|
|
"assistance": {
|
|
"proactivity": "high",
|
|
"formality": "casual",
|
|
"focus_areas": ["engineering", "health"],
|
|
"routines": ["morning planning"],
|
|
"goals": ["ship product", "exercise regularly"],
|
|
"interaction_style": "direct"
|
|
},
|
|
"context": {
|
|
"profession": "software engineer",
|
|
"interests": ["AI", "fitness", "cooking"],
|
|
"life_stage": "mid-career",
|
|
"challenges": ["work-life balance"]
|
|
},
|
|
"created_at": "2026-02-22T00:00:00Z",
|
|
"updated_at": "2026-02-22T00:00:00Z"
|
|
}"#;
|
|
|
|
let profile: PsychographicProfile =
|
|
serde_json::from_str(json).expect("should parse old LLM output");
|
|
assert_eq!(profile.preferred_name, "Jay");
|
|
assert_eq!(profile.personality.empathy, 75);
|
|
assert_eq!(profile.cohort.cohort, UserCohort::BusyProfessional);
|
|
assert_eq!(profile.assistance.proactivity, "high");
|
|
// New fields get defaults
|
|
assert_eq!(profile.communication.response_speed, "unknown");
|
|
assert_eq!(profile.confidence, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_analysis_framework_contains_all_dimensions() {
|
|
assert!(ANALYSIS_FRAMEWORK.contains("COMMUNICATION STYLE"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("PERSONALITY TRAITS"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("SOCIAL & RELATIONSHIP"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("DECISION MAKING"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("BEHAVIORAL PATTERNS"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("CONTEXTUAL INFO"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("ASSISTANCE PREFERENCES"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("USER COHORT"));
|
|
assert!(ANALYSIS_FRAMEWORK.contains("FRIENDSHIP QUALITIES"));
|
|
}
|
|
}
|