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 seed: None,
237 };
238
239 let (response_json, usage) = self.llm_client.generate_with_usage(&llm_request).await?;
240
241 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 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 self.agents.insert(agent.agent_id.clone(), agent.clone());
261
262 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 async fn generate_behavior_policy(&self, policy_type: &str) -> Result<BehaviorPolicy> {
277 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 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 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 fn parse_action_response(&self, response: Value) -> Result<NextAction> {
403 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 fn determine_intention(
440 &self,
441 action: &NextAction,
442 trigger_event: &Option<String>,
443 ) -> Result<Intention> {
444 if let Some(ref trigger) = trigger_event {
446 if trigger.contains("error") || trigger.contains("500") || trigger.contains("timeout") {
447 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 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 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 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 pub fn get_agent(&self, agent_id: &str) -> Option<&NarrativeAgent> {
507 self.agents.get(agent_id)
508 }
509
510 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}