1use crate::intelligent_behavior::{
37 config::IntelligentBehaviorConfig,
38 llm_client::{LlmClient, LlmUsage},
39 types::LlmGenerationRequest,
40};
41use chrono::Utc;
42use mockforge_foundation::Result;
43pub 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
53pub struct BehavioralSimulator {
55 llm_client: LlmClient,
57
58 config: IntelligentBehaviorConfig,
60
61 agents: HashMap<String, NarrativeAgent>,
63
64 pub use_existing_personas: bool,
67 pub allow_new_personas: bool,
69 pub max_new_personas: usize,
71}
72
73impl BehavioralSimulator {
74 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 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 pub async fn create_agent(&mut self, request: &CreateAgentRequest) -> Result<NarrativeAgent> {
107 let agent_id = format!("agent-{}", Uuid::new_v4());
108
109 let persona_id = if let Some(ref existing_id) = request.persona_id {
111 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 if !self.allow_new_personas {
122 return Err(mockforge_foundation::Error::internal(
123 "Generating new personas is disabled".to_string(),
124 ));
125 }
126
127 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 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 let behavior_policy = if let Some(ref policy_type) = request.behavior_policy {
148 self.generate_behavior_policy(policy_type).await?
149 } else {
150 BehaviorPolicy {
152 policy_type: "default".to_string(),
153 description: "Default user behavior".to_string(),
154 rules: vec![],
155 }
156 };
157
158 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 pub async fn simulate_behavior(
182 &mut self,
183 request: &SimulateBehaviorRequest,
184 ) -> Result<SimulateBehaviorResponse> {
185 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 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 agent.state_awareness = request.current_state.clone();
201 agent
202 } else {
203 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 agent.state_awareness = request.current_state.clone();
220
221 let behavior_policy = agent.behavior_policy.clone();
223 let agent_clone = agent.clone();
224 let trigger_event_clone = request.trigger_event.clone();
225
226 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, max_tokens: 1000,
235 schema: None,
236 };
237
238 let (response_json, usage) = self.llm_client.generate_with_usage(&llm_request).await?;
239
240 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 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 self.agents.insert(agent.agent_id.clone(), agent.clone());
260
261 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 async fn generate_behavior_policy(&self, policy_type: &str) -> Result<BehaviorPolicy> {
276 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 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 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 fn parse_action_response(&self, response: Value) -> Result<NextAction> {
402 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 fn determine_intention(
439 &self,
440 action: &NextAction,
441 trigger_event: &Option<String>,
442 ) -> Result<Intention> {
443 if let Some(ref trigger) = trigger_event {
445 if trigger.contains("error") || trigger.contains("500") || trigger.contains("timeout") {
446 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 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 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 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 pub fn get_agent(&self, agent_id: &str) -> Option<&NarrativeAgent> {
506 self.agents.get(agent_id)
507 }
508
509 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}