Skip to main content

mockforge_intelligence/ai_studio/
behavioral_simulator.rs

1//! AI Behavioral Simulation Engine
2//!
3//! This module provides functionality to model users as narrative agents that:
4//! - React to app state (e.g., "cart is empty" → intention: "browse products")
5//! - Form intentions (shop, browse, buy, abandon)
6//! - Respond to errors (rage clicking on 500 errors, retry logic, cart abandonment on payment failure)
7//! - Trigger multi-step interactions automatically
8//! - Maintain session context across interactions
9//!
10//! # Persona Integration Strategy
11//!
12//! - **Primary: Augment existing personas** - Attach behavior policies to existing Smart Personas
13//! - **Secondary: Generate new personas** - When system description introduces roles that don't exist
14//!
15//! # Example Usage
16//!
17//! ```rust,ignore
18//! use mockforge_core::ai_studio::behavioral_simulator::{BehavioralSimulator, CreateAgentRequest};
19//! use mockforge_core::intelligent_behavior::IntelligentBehaviorConfig;
20//!
21//! async fn example() -> mockforge_core::Result<()> {
22//!     let config = IntelligentBehaviorConfig::default();
23//!     let simulator = BehavioralSimulator::new(config);
24//!
25//!     let request = CreateAgentRequest {
26//!         persona_id: Some("existing-persona-123".to_string()),
27//!         behavior_policy: Some("bargain-hunter".to_string()),
28//!         generate_persona: false,
29//!     };
30//!
31//!     let agent = simulator.create_agent(&request).await?;
32//!     Ok(())
33//! }
34//! ```
35
36use crate::intelligent_behavior::{
37    config::IntelligentBehaviorConfig,
38    llm_client::{LlmClient, LlmUsage},
39    types::LlmGenerationRequest,
40};
41use chrono::Utc;
42use mockforge_foundation::Result;
43// Data types re-exported from foundation.
44pub use mockforge_foundation::ai_studio_types::{
45    AppState, BehaviorPolicy, BehavioralTraits, CartItem, CartState, CreateAgentRequest,
46    ErrorEncounter, Intention, Interaction, NarrativeAgent, NextAction, PolicyRule,
47    SimulateBehaviorRequest, SimulateBehaviorResponse,
48};
49use serde_json::Value;
50use std::collections::HashMap;
51use uuid::Uuid;
52
53/// Behavioral Simulator Engine
54pub struct BehavioralSimulator {
55    /// LLM client for behavior generation
56    llm_client: LlmClient,
57
58    /// Configuration
59    config: IntelligentBehaviorConfig,
60
61    /// Active agents (in-memory storage - in production, use database)
62    agents: HashMap<String, NarrativeAgent>,
63
64    /// Configuration for persona integration
65    /// Whether to use existing personas when creating agents (primary mode)
66    pub use_existing_personas: bool,
67    /// Whether to allow generating new personas when needed (secondary mode)
68    pub allow_new_personas: bool,
69    /// Maximum number of new personas that can be generated
70    pub max_new_personas: usize,
71}
72
73impl BehavioralSimulator {
74    /// Create a new behavioral simulator
75    pub fn new(config: IntelligentBehaviorConfig) -> Self {
76        let llm_client = LlmClient::new(config.behavior_model.clone());
77        Self {
78            llm_client,
79            config,
80            agents: HashMap::new(),
81            use_existing_personas: true,
82            allow_new_personas: true,
83            max_new_personas: 5,
84        }
85    }
86
87    /// Create with persona integration settings
88    pub fn with_persona_settings(
89        config: IntelligentBehaviorConfig,
90        use_existing_personas: bool,
91        allow_new_personas: bool,
92        max_new_personas: usize,
93    ) -> Self {
94        let llm_client = LlmClient::new(config.behavior_model.clone());
95        Self {
96            llm_client,
97            config,
98            agents: HashMap::new(),
99            use_existing_personas,
100            allow_new_personas,
101            max_new_personas,
102        }
103    }
104
105    /// Create a new narrative agent
106    pub async fn create_agent(&mut self, request: &CreateAgentRequest) -> Result<NarrativeAgent> {
107        let agent_id = format!("agent-{}", Uuid::new_v4());
108
109        // Determine persona ID
110        let persona_id = if let Some(ref existing_id) = request.persona_id {
111            // Use existing persona if provided
112            if self.use_existing_personas {
113                existing_id.clone()
114            } else {
115                return Err(mockforge_foundation::Error::internal(
116                    "Using existing personas is disabled".to_string(),
117                ));
118            }
119        } else if request.generate_persona {
120            // Generate new persona if allowed
121            if !self.allow_new_personas {
122                return Err(mockforge_foundation::Error::internal(
123                    "Generating new personas is disabled".to_string(),
124                ));
125            }
126
127            // Check limit
128            let new_persona_count =
129                self.agents.values().filter(|a| !a.persona_id.starts_with("existing-")).count();
130
131            if new_persona_count >= self.max_new_personas {
132                return Err(mockforge_foundation::Error::internal(format!(
133                    "Maximum new personas limit ({}) reached",
134                    self.max_new_personas
135                )));
136            }
137
138            // Generate new persona ID (in production, would call persona generator)
139            format!("persona-{}", Uuid::new_v4())
140        } else {
141            return Err(mockforge_foundation::Error::internal(
142                "Either persona_id or generate_persona must be provided".to_string(),
143            ));
144        };
145
146        // Generate behavior policy
147        let behavior_policy = if let Some(ref policy_type) = request.behavior_policy {
148            self.generate_behavior_policy(policy_type).await?
149        } else {
150            // Default policy
151            BehaviorPolicy {
152                policy_type: "default".to_string(),
153                description: "Default user behavior".to_string(),
154                rules: vec![],
155            }
156        };
157
158        // Create agent
159        let agent = NarrativeAgent {
160            agent_id: agent_id.clone(),
161            persona_id,
162            current_intention: Intention::Browse,
163            session_history: Vec::new(),
164            behavioral_traits: BehavioralTraits {
165                patience: 0.7,
166                price_sensitivity: 0.5,
167                risk_tolerance: 0.5,
168                technical_proficiency: 0.5,
169                engagement_level: 0.7,
170            },
171            state_awareness: AppState::default(),
172            behavior_policy,
173            created_at: Utc::now().to_rfc3339(),
174        };
175
176        self.agents.insert(agent_id.clone(), agent.clone());
177        Ok(agent)
178    }
179
180    /// Simulate behavior based on current state and trigger event
181    pub async fn simulate_behavior(
182        &mut self,
183        request: &SimulateBehaviorRequest,
184    ) -> Result<SimulateBehaviorResponse> {
185        // Get or create agent (clone to avoid borrow conflicts)
186        let mut agent = if let Some(ref agent_id) = request.agent_id {
187            self.agents
188                .get(agent_id)
189                .ok_or_else(|| {
190                    mockforge_foundation::Error::internal("Agent not found".to_string())
191                })?
192                .clone()
193        } else if let Some(ref persona_id) = request.persona_id {
194            // Find existing agent for persona or create new one
195            let existing_agent =
196                self.agents.values().find(|a| a.persona_id == *persona_id).cloned();
197
198            if let Some(mut agent) = existing_agent {
199                // Update state
200                agent.state_awareness = request.current_state.clone();
201                agent
202            } else {
203                // Create new agent for persona
204                let create_request = CreateAgentRequest {
205                    persona_id: Some(persona_id.clone()),
206                    behavior_policy: None,
207                    generate_persona: false,
208                    workspace_id: request.workspace_id.clone(),
209                };
210                self.create_agent(&create_request).await?
211            }
212        } else {
213            return Err(mockforge_foundation::Error::internal(
214                "Either agent_id or persona_id must be provided".to_string(),
215            ));
216        };
217
218        // Update agent state
219        agent.state_awareness = request.current_state.clone();
220
221        // Extract values needed for LLM call
222        let behavior_policy = agent.behavior_policy.clone();
223        let agent_clone = agent.clone();
224        let trigger_event_clone = request.trigger_event.clone();
225
226        // Generate next action using LLM
227        let system_prompt = self.build_system_prompt(&behavior_policy);
228        let user_prompt = self.build_user_prompt(&agent_clone, &trigger_event_clone)?;
229
230        let llm_request = LlmGenerationRequest {
231            system_prompt,
232            user_prompt,
233            temperature: 0.8, // Higher temperature for more varied behavior
234            max_tokens: 1000,
235            schema: None,
236        };
237
238        let (response_json, usage) = self.llm_client.generate_with_usage(&llm_request).await?;
239
240        // Parse response (clone response_json since we need it multiple times)
241        let response_json_clone = response_json.clone();
242        let next_action = self.parse_action_response(response_json)?;
243        let intention = self.determine_intention(&next_action, &trigger_event_clone)?;
244        let reasoning = self.extract_reasoning(&response_json_clone)?;
245
246        // Record interaction
247        let interaction = Interaction {
248            timestamp: Utc::now().to_rfc3339(),
249            action: next_action.action_type.clone(),
250            intention: intention.clone(),
251            request: next_action.body.clone(),
252            response: None,
253            result: "pending".to_string(),
254        };
255        agent.session_history.push(interaction);
256        agent.current_intention = intention.clone();
257
258        // Update agent in storage
259        self.agents.insert(agent.agent_id.clone(), agent.clone());
260
261        // Calculate cost
262        let cost_usd = self.estimate_cost(&usage);
263
264        Ok(SimulateBehaviorResponse {
265            next_action,
266            intention,
267            reasoning,
268            agent: Some(agent.clone()),
269            tokens_used: Some(usage.total_tokens),
270            cost_usd: Some(cost_usd),
271        })
272    }
273
274    /// Generate behavior policy for a policy type
275    async fn generate_behavior_policy(&self, policy_type: &str) -> Result<BehaviorPolicy> {
276        // In a full implementation, this would use LLM to generate policy
277        // For now, return a template based on policy type
278        let (description, rules) = match policy_type {
279            "bargain-hunter" => (
280                "Price-sensitive user who looks for deals and discounts".to_string(),
281                vec![
282                    PolicyRule {
283                        condition: "price > threshold".to_string(),
284                        action: "abandon".to_string(),
285                        priority: 10,
286                    },
287                    PolicyRule {
288                        condition: "discount_available".to_string(),
289                        action: "buy".to_string(),
290                        priority: 9,
291                    },
292                ],
293            ),
294            "power-user" => (
295                "Highly engaged user with advanced features".to_string(),
296                vec![
297                    PolicyRule {
298                        condition: "error_encountered".to_string(),
299                        action: "retry".to_string(),
300                        priority: 10,
301                    },
302                    PolicyRule {
303                        condition: "feature_available".to_string(),
304                        action: "explore".to_string(),
305                        priority: 8,
306                    },
307                ],
308            ),
309            "churn-risk" => (
310                "User showing signs of churn".to_string(),
311                vec![
312                    PolicyRule {
313                        condition: "error_encountered".to_string(),
314                        action: "abandon".to_string(),
315                        priority: 10,
316                    },
317                    PolicyRule {
318                        condition: "slow_response".to_string(),
319                        action: "abandon".to_string(),
320                        priority: 9,
321                    },
322                ],
323            ),
324            _ => ("Default user behavior".to_string(), vec![]),
325        };
326
327        Ok(BehaviorPolicy {
328            policy_type: policy_type.to_string(),
329            description,
330            rules,
331        })
332    }
333
334    /// Build system prompt for behavior simulation
335    fn build_system_prompt(&self, behavior_policy: &BehaviorPolicy) -> String {
336        format!(
337            r#"You are modeling a user's behavior in a web application. Your task is to determine what action the user would take next based on:
338
3391. Current app state (cart, authentication, recent errors, etc.)
3402. User's current intention (browse, shop, buy, abandon, retry, navigate)
3413. Behavioral traits (patience, price sensitivity, risk tolerance, etc.)
3424. Behavior policy: {}
343
344Return a JSON object with:
345{{
346  "action_type": "GET|POST|navigate|abandon",
347  "target": "/api/endpoint or page name",
348  "body": {{ ... }} (optional, for POST requests),
349  "query_params": {{ ... }} (optional),
350  "delay_ms": 1000 (optional, delay before action),
351  "reasoning": "Why this action makes sense for this user"
352}}
353
354Consider:
355- User's patience level when encountering errors
356- Price sensitivity when making purchase decisions
357- Engagement level for exploration vs. quick actions
358- Recent errors may trigger retry or abandon
359- Empty cart may trigger browse intention
360- Payment failures may trigger abandon or retry based on patience"#,
361            behavior_policy.description
362        )
363    }
364
365    /// Build user prompt with current state and trigger
366    fn build_user_prompt(
367        &self,
368        agent: &NarrativeAgent,
369        trigger_event: &Option<String>,
370    ) -> Result<String> {
371        let state_json = serde_json::to_string_pretty(&agent.state_awareness).map_err(|e| {
372            mockforge_foundation::Error::internal(format!("Failed to serialize state: {}", e))
373        })?;
374
375        let trigger_text = trigger_event
376            .as_ref()
377            .map(|e| format!("Trigger event: {}", e))
378            .unwrap_or_else(|| "No specific trigger".to_string());
379
380        Ok(format!(
381            r#"Current user state:
382{}
383
384Current intention: {:?}
385Behavioral traits: patience={:.2}, price_sensitivity={:.2}, risk_tolerance={:.2}
386Session history: {} interactions
387{}
388
389What should the user do next?"#,
390            state_json,
391            agent.current_intention,
392            agent.behavioral_traits.patience,
393            agent.behavioral_traits.price_sensitivity,
394            agent.behavioral_traits.risk_tolerance,
395            agent.session_history.len(),
396            trigger_text
397        ))
398    }
399
400    /// Parse LLM response into NextAction
401    fn parse_action_response(&self, response: Value) -> Result<NextAction> {
402        // Try to extract action from response
403        let action_json = if let Some(action) = response.get("action") {
404            action.clone()
405        } else if response.is_object() {
406            response
407        } else {
408            return Err(mockforge_foundation::Error::internal(
409                "LLM response is not a valid JSON object".to_string(),
410            ));
411        };
412
413        let action_type = action_json
414            .get("action_type")
415            .and_then(|v| v.as_str())
416            .unwrap_or("GET")
417            .to_string();
418
419        let target = action_json.get("target").and_then(|v| v.as_str()).unwrap_or("/").to_string();
420
421        let body = action_json.get("body").cloned();
422        let query_params = action_json
423            .get("query_params")
424            .and_then(|v| serde_json::from_value(v.clone()).ok());
425
426        let delay_ms = action_json.get("delay_ms").and_then(|v| v.as_u64());
427
428        Ok(NextAction {
429            action_type,
430            target,
431            body,
432            query_params,
433            delay_ms,
434        })
435    }
436
437    /// Determine intention from action and trigger
438    fn determine_intention(
439        &self,
440        action: &NextAction,
441        trigger_event: &Option<String>,
442    ) -> Result<Intention> {
443        // Determine intention based on action and trigger
444        if let Some(ref trigger) = trigger_event {
445            if trigger.contains("error") || trigger.contains("500") || trigger.contains("timeout") {
446                // Check if user would retry or abandon based on context
447                // For now, default to retry
448                return Ok(Intention::Retry);
449            }
450            if trigger.contains("payment_failed") {
451                return Ok(Intention::Abandon);
452            }
453            if trigger.contains("cart_empty") {
454                return Ok(Intention::Browse);
455            }
456        }
457
458        // Determine from action type
459        match action.action_type.as_str() {
460            "GET" if action.target.contains("/products") || action.target.contains("/browse") => {
461                Ok(Intention::Browse)
462            }
463            "GET" if action.target.contains("/search") => Ok(Intention::Search),
464            "POST" if action.target.contains("/cart") || action.target.contains("/add") => {
465                Ok(Intention::Shop)
466            }
467            "POST"
468                if action.target.contains("/checkout") || action.target.contains("/purchase") =>
469            {
470                Ok(Intention::Buy)
471            }
472            "navigate" => Ok(Intention::Navigate),
473            "abandon" => Ok(Intention::Abandon),
474            _ => Ok(Intention::Browse),
475        }
476    }
477
478    /// Extract reasoning from LLM response
479    fn extract_reasoning(&self, response: &Value) -> Result<String> {
480        if let Some(reasoning) = response.get("reasoning").and_then(|v| v.as_str()) {
481            Ok(reasoning.to_string())
482        } else {
483            Ok("User behavior determined based on current state and traits".to_string())
484        }
485    }
486
487    /// Estimate cost in USD based on token usage
488    fn estimate_cost(&self, usage: &LlmUsage) -> f64 {
489        let cost_per_1k_tokens =
490            match self.config.behavior_model.llm_provider.to_lowercase().as_str() {
491                "openai" => match self.config.behavior_model.model.to_lowercase().as_str() {
492                    model if model.contains("gpt-4") => 0.03,
493                    model if model.contains("gpt-3.5") => 0.002,
494                    _ => 0.002,
495                },
496                "anthropic" => 0.008,
497                "ollama" => 0.0,
498                _ => 0.002,
499            };
500
501        (usage.total_tokens as f64 / 1000.0) * cost_per_1k_tokens
502    }
503
504    /// Get agent by ID
505    pub fn get_agent(&self, agent_id: &str) -> Option<&NarrativeAgent> {
506        self.agents.get(agent_id)
507    }
508
509    /// List all agents
510    pub fn list_agents(&self) -> Vec<&NarrativeAgent> {
511        self.agents.values().collect()
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::intelligent_behavior::config::BehaviorModelConfig;
519
520    fn create_test_config() -> IntelligentBehaviorConfig {
521        IntelligentBehaviorConfig {
522            behavior_model: BehaviorModelConfig {
523                llm_provider: "ollama".to_string(),
524                model: "llama2".to_string(),
525                api_endpoint: Some("http://localhost:11434/api/chat".to_string()),
526                api_key: None,
527                temperature: 0.7,
528                max_tokens: 2000,
529                rules: crate::intelligent_behavior::types::BehaviorRules::default(),
530            },
531            ..Default::default()
532        }
533    }
534
535    #[test]
536    fn test_behavioral_simulator_creation() {
537        let config = create_test_config();
538        let simulator = BehavioralSimulator::new(config);
539        assert!(simulator.use_existing_personas);
540        assert!(simulator.allow_new_personas);
541    }
542
543    #[test]
544    fn test_intention_determination() {
545        let config = create_test_config();
546        let simulator = BehavioralSimulator::new(config);
547
548        let action = NextAction {
549            action_type: "GET".to_string(),
550            target: "/api/products".to_string(),
551            body: None,
552            query_params: None,
553            delay_ms: None,
554        };
555
556        let intention = simulator.determine_intention(&action, &None).unwrap();
557        assert_eq!(intention, Intention::Browse);
558    }
559}