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            seed: None,
237        };
238
239        let (response_json, usage) = self.llm_client.generate_with_usage(&llm_request).await?;
240
241        // Parse response (clone response_json since we need it multiple times)
242        let response_json_clone = response_json.clone();
243        let next_action = self.parse_action_response(response_json)?;
244        let intention = self.determine_intention(&next_action, &trigger_event_clone)?;
245        let reasoning = self.extract_reasoning(&response_json_clone)?;
246
247        // Record interaction
248        let interaction = Interaction {
249            timestamp: Utc::now().to_rfc3339(),
250            action: next_action.action_type.clone(),
251            intention: intention.clone(),
252            request: next_action.body.clone(),
253            response: None,
254            result: "pending".to_string(),
255        };
256        agent.session_history.push(interaction);
257        agent.current_intention = intention.clone();
258
259        // Update agent in storage
260        self.agents.insert(agent.agent_id.clone(), agent.clone());
261
262        // Calculate cost
263        let cost_usd = self.estimate_cost(&usage);
264
265        Ok(SimulateBehaviorResponse {
266            next_action,
267            intention,
268            reasoning,
269            agent: Some(agent.clone()),
270            tokens_used: Some(usage.total_tokens),
271            cost_usd: Some(cost_usd),
272        })
273    }
274
275    /// Generate behavior policy for a policy type
276    async fn generate_behavior_policy(&self, policy_type: &str) -> Result<BehaviorPolicy> {
277        // In a full implementation, this would use LLM to generate policy
278        // For now, return a template based on policy type
279        let (description, rules) = match policy_type {
280            "bargain-hunter" => (
281                "Price-sensitive user who looks for deals and discounts".to_string(),
282                vec![
283                    PolicyRule {
284                        condition: "price > threshold".to_string(),
285                        action: "abandon".to_string(),
286                        priority: 10,
287                    },
288                    PolicyRule {
289                        condition: "discount_available".to_string(),
290                        action: "buy".to_string(),
291                        priority: 9,
292                    },
293                ],
294            ),
295            "power-user" => (
296                "Highly engaged user with advanced features".to_string(),
297                vec![
298                    PolicyRule {
299                        condition: "error_encountered".to_string(),
300                        action: "retry".to_string(),
301                        priority: 10,
302                    },
303                    PolicyRule {
304                        condition: "feature_available".to_string(),
305                        action: "explore".to_string(),
306                        priority: 8,
307                    },
308                ],
309            ),
310            "churn-risk" => (
311                "User showing signs of churn".to_string(),
312                vec![
313                    PolicyRule {
314                        condition: "error_encountered".to_string(),
315                        action: "abandon".to_string(),
316                        priority: 10,
317                    },
318                    PolicyRule {
319                        condition: "slow_response".to_string(),
320                        action: "abandon".to_string(),
321                        priority: 9,
322                    },
323                ],
324            ),
325            _ => ("Default user behavior".to_string(), vec![]),
326        };
327
328        Ok(BehaviorPolicy {
329            policy_type: policy_type.to_string(),
330            description,
331            rules,
332        })
333    }
334
335    /// Build system prompt for behavior simulation
336    fn build_system_prompt(&self, behavior_policy: &BehaviorPolicy) -> String {
337        format!(
338            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:
339
3401. Current app state (cart, authentication, recent errors, etc.)
3412. User's current intention (browse, shop, buy, abandon, retry, navigate)
3423. Behavioral traits (patience, price sensitivity, risk tolerance, etc.)
3434. Behavior policy: {}
344
345Return a JSON object with:
346{{
347  "action_type": "GET|POST|navigate|abandon",
348  "target": "/api/endpoint or page name",
349  "body": {{ ... }} (optional, for POST requests),
350  "query_params": {{ ... }} (optional),
351  "delay_ms": 1000 (optional, delay before action),
352  "reasoning": "Why this action makes sense for this user"
353}}
354
355Consider:
356- User's patience level when encountering errors
357- Price sensitivity when making purchase decisions
358- Engagement level for exploration vs. quick actions
359- Recent errors may trigger retry or abandon
360- Empty cart may trigger browse intention
361- Payment failures may trigger abandon or retry based on patience"#,
362            behavior_policy.description
363        )
364    }
365
366    /// Build user prompt with current state and trigger
367    fn build_user_prompt(
368        &self,
369        agent: &NarrativeAgent,
370        trigger_event: &Option<String>,
371    ) -> Result<String> {
372        let state_json = serde_json::to_string_pretty(&agent.state_awareness).map_err(|e| {
373            mockforge_foundation::Error::internal(format!("Failed to serialize state: {}", e))
374        })?;
375
376        let trigger_text = trigger_event
377            .as_ref()
378            .map(|e| format!("Trigger event: {}", e))
379            .unwrap_or_else(|| "No specific trigger".to_string());
380
381        Ok(format!(
382            r#"Current user state:
383{}
384
385Current intention: {:?}
386Behavioral traits: patience={:.2}, price_sensitivity={:.2}, risk_tolerance={:.2}
387Session history: {} interactions
388{}
389
390What should the user do next?"#,
391            state_json,
392            agent.current_intention,
393            agent.behavioral_traits.patience,
394            agent.behavioral_traits.price_sensitivity,
395            agent.behavioral_traits.risk_tolerance,
396            agent.session_history.len(),
397            trigger_text
398        ))
399    }
400
401    /// Parse LLM response into NextAction
402    fn parse_action_response(&self, response: Value) -> Result<NextAction> {
403        // Try to extract action from response
404        let action_json = if let Some(action) = response.get("action") {
405            action.clone()
406        } else if response.is_object() {
407            response
408        } else {
409            return Err(mockforge_foundation::Error::internal(
410                "LLM response is not a valid JSON object".to_string(),
411            ));
412        };
413
414        let action_type = action_json
415            .get("action_type")
416            .and_then(|v| v.as_str())
417            .unwrap_or("GET")
418            .to_string();
419
420        let target = action_json.get("target").and_then(|v| v.as_str()).unwrap_or("/").to_string();
421
422        let body = action_json.get("body").cloned();
423        let query_params = action_json
424            .get("query_params")
425            .and_then(|v| serde_json::from_value(v.clone()).ok());
426
427        let delay_ms = action_json.get("delay_ms").and_then(|v| v.as_u64());
428
429        Ok(NextAction {
430            action_type,
431            target,
432            body,
433            query_params,
434            delay_ms,
435        })
436    }
437
438    /// Determine intention from action and trigger
439    fn determine_intention(
440        &self,
441        action: &NextAction,
442        trigger_event: &Option<String>,
443    ) -> Result<Intention> {
444        // Determine intention based on action and trigger
445        if let Some(ref trigger) = trigger_event {
446            if trigger.contains("error") || trigger.contains("500") || trigger.contains("timeout") {
447                // Check if user would retry or abandon based on context
448                // For now, default to retry
449                return Ok(Intention::Retry);
450            }
451            if trigger.contains("payment_failed") {
452                return Ok(Intention::Abandon);
453            }
454            if trigger.contains("cart_empty") {
455                return Ok(Intention::Browse);
456            }
457        }
458
459        // Determine from action type
460        match action.action_type.as_str() {
461            "GET" if action.target.contains("/products") || action.target.contains("/browse") => {
462                Ok(Intention::Browse)
463            }
464            "GET" if action.target.contains("/search") => Ok(Intention::Search),
465            "POST" if action.target.contains("/cart") || action.target.contains("/add") => {
466                Ok(Intention::Shop)
467            }
468            "POST"
469                if action.target.contains("/checkout") || action.target.contains("/purchase") =>
470            {
471                Ok(Intention::Buy)
472            }
473            "navigate" => Ok(Intention::Navigate),
474            "abandon" => Ok(Intention::Abandon),
475            _ => Ok(Intention::Browse),
476        }
477    }
478
479    /// Extract reasoning from LLM response
480    fn extract_reasoning(&self, response: &Value) -> Result<String> {
481        if let Some(reasoning) = response.get("reasoning").and_then(|v| v.as_str()) {
482            Ok(reasoning.to_string())
483        } else {
484            Ok("User behavior determined based on current state and traits".to_string())
485        }
486    }
487
488    /// Estimate cost in USD based on token usage
489    fn estimate_cost(&self, usage: &LlmUsage) -> f64 {
490        let cost_per_1k_tokens =
491            match self.config.behavior_model.llm_provider.to_lowercase().as_str() {
492                "openai" => match self.config.behavior_model.model.to_lowercase().as_str() {
493                    model if model.contains("gpt-4") => 0.03,
494                    model if model.contains("gpt-3.5") => 0.002,
495                    _ => 0.002,
496                },
497                "anthropic" => 0.008,
498                "ollama" => 0.0,
499                _ => 0.002,
500            };
501
502        (usage.total_tokens as f64 / 1000.0) * cost_per_1k_tokens
503    }
504
505    /// Get agent by ID
506    pub fn get_agent(&self, agent_id: &str) -> Option<&NarrativeAgent> {
507        self.agents.get(agent_id)
508    }
509
510    /// List all agents
511    pub fn list_agents(&self) -> Vec<&NarrativeAgent> {
512        self.agents.values().collect()
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::intelligent_behavior::config::BehaviorModelConfig;
520
521    fn create_test_config() -> IntelligentBehaviorConfig {
522        IntelligentBehaviorConfig {
523            behavior_model: BehaviorModelConfig {
524                llm_provider: "ollama".to_string(),
525                model: "llama2".to_string(),
526                api_endpoint: Some("http://localhost:11434/api/chat".to_string()),
527                api_key: None,
528                temperature: 0.7,
529                max_tokens: 2000,
530                rules: crate::intelligent_behavior::types::BehaviorRules::default(),
531                seed: None,
532            },
533            ..Default::default()
534        }
535    }
536
537    #[test]
538    fn test_behavioral_simulator_creation() {
539        let config = create_test_config();
540        let simulator = BehavioralSimulator::new(config);
541        assert!(simulator.use_existing_personas);
542        assert!(simulator.allow_new_personas);
543    }
544
545    #[test]
546    fn test_intention_determination() {
547        let config = create_test_config();
548        let simulator = BehavioralSimulator::new(config);
549
550        let action = NextAction {
551            action_type: "GET".to_string(),
552            target: "/api/products".to_string(),
553            body: None,
554            query_params: None,
555            delay_ms: None,
556        };
557
558        let intention = simulator.determine_intention(&action, &None).unwrap();
559        assert_eq!(intention, Intention::Browse);
560    }
561}