1use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::time::Instant;
8
9use ai_agents_core::{AgentError, ChatMessage, LLMProvider, Result};
10use ai_agents_llm::LLMRegistry;
11
12use super::config::*;
13
14#[derive(Debug, Clone, Default)]
15pub struct ProcessData {
16 pub content: String,
17 pub original: String,
18 pub context: HashMap<String, serde_json::Value>,
19 pub metadata: ProcessMetadata,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct ProcessMetadata {
24 pub stages_executed: Vec<String>,
25 pub timing: HashMap<String, u64>,
26 pub warnings: Vec<String>,
27 pub rejected: bool,
28 pub rejection_reason: Option<String>,
29}
30
31impl ProcessData {
32 pub fn new(content: impl Into<String>) -> Self {
33 let content = content.into();
34 Self {
35 original: content.clone(),
36 content,
37 context: HashMap::new(),
38 metadata: ProcessMetadata::default(),
39 }
40 }
41
42 pub fn with_context(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
43 self.context.insert(key.into(), value);
44 self
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ProcessPurposeHint {
51 Detect,
52 Extract,
53 Validate,
54 Transform,
55 Other,
56}
57
58pub type ProcessStageFuture<'a> = Pin<Box<dyn Future<Output = Result<ProcessData>> + Send + 'a>>;
60
61pub trait ProcessStageObserver: Send + Sync {
63 fn observe<'a>(
65 &'a self,
66 hint: ProcessPurposeHint,
67 future: ProcessStageFuture<'a>,
68 ) -> ProcessStageFuture<'a>;
69}
70
71pub struct ProcessProcessor {
73 config: ProcessConfig,
74 llm_registry: Option<Arc<LLMRegistry>>,
75 stage_observer: Option<Arc<dyn ProcessStageObserver>>,
76}
77
78impl std::fmt::Debug for ProcessProcessor {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("ProcessProcessor")
81 .field("config", &self.config)
82 .field("has_llm_registry", &self.llm_registry.is_some())
83 .field("has_stage_observer", &self.stage_observer.is_some())
84 .finish()
85 }
86}
87
88impl Default for ProcessProcessor {
89 fn default() -> Self {
90 Self::new(ProcessConfig::default())
91 }
92}
93
94impl ProcessProcessor {
95 pub fn new(config: ProcessConfig) -> Self {
96 Self {
97 config,
98 llm_registry: None,
99 stage_observer: None,
100 }
101 }
102
103 pub fn config(&self) -> &ProcessConfig {
105 &self.config
106 }
107
108 pub fn with_llm_registry(mut self, registry: Arc<LLMRegistry>) -> Self {
109 self.llm_registry = Some(registry);
110 self
111 }
112
113 pub fn with_stage_observer(mut self, observer: Arc<dyn ProcessStageObserver>) -> Self {
115 self.stage_observer = Some(observer);
116 self
117 }
118
119 pub fn input_purpose_hint(&self) -> ProcessPurposeHint {
121 purpose_hint_for_stages(&self.config.input)
122 }
123
124 pub fn output_purpose_hint(&self) -> ProcessPurposeHint {
126 purpose_hint_for_stages(&self.config.output)
127 }
128
129 pub async fn process_input(&self, input: &str) -> Result<ProcessData> {
130 let mut data = ProcessData::new(input);
131
132 for stage in &self.config.input {
133 data = self.execute_stage(stage, data).await?;
134 if data.metadata.rejected {
135 break;
136 }
137 }
138
139 Ok(data)
140 }
141
142 pub async fn process_output(
143 &self,
144 output: &str,
145 input_context: &HashMap<String, serde_json::Value>,
146 ) -> Result<ProcessData> {
147 let mut data = ProcessData::new(output);
148 data.context = input_context.clone();
149
150 for stage in &self.config.output {
151 data = self.execute_stage(stage, data).await?;
152 if data.metadata.rejected {
153 break;
154 }
155 }
156
157 Ok(data)
158 }
159
160 fn execute_stage<'a>(
161 &'a self,
162 stage: &'a ProcessStage,
163 data: ProcessData,
164 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ProcessData>> + Send + 'a>> {
165 Box::pin(async move {
166 let start = Instant::now();
167 let stage_name = stage
168 .id()
169 .map(String::from)
170 .unwrap_or_else(|| self.get_stage_type_name(stage));
171
172 if let Some(condition) = stage.condition()
174 && !self.evaluate_condition_expr(condition, &data)
175 {
176 if self.config.settings.debug.log_stages {
177 tracing::debug!(
178 "[Process] Stage skipped (condition not met): {}",
179 stage_name
180 );
181 }
182 return Ok(data);
183 }
184
185 if self.config.settings.debug.log_stages {
186 tracing::debug!("[Process] Executing stage: {}", stage_name);
187 }
188
189 let data_clone = data.clone();
190 let hint = process_purpose_hint_for_stage(stage);
191 let run_stage: ProcessStageFuture<'a> = Box::pin(async move {
192 match stage {
193 ProcessStage::Normalize(s) => self.execute_normalize(&s.config, data).await,
194 ProcessStage::Detect(s) => self.execute_detect(&s.config, data).await,
195 ProcessStage::Extract(s) => self.execute_extract(&s.config, data).await,
196 ProcessStage::Sanitize(s) => self.execute_sanitize(&s.config, data).await,
197 ProcessStage::Transform(s) => self.execute_transform(&s.config, data).await,
198 ProcessStage::Validate(s) => self.execute_validate(&s.config, data).await,
199 ProcessStage::Format(s) => self.execute_format(&s.config, data).await,
200 ProcessStage::Enrich(s) => self.execute_enrich(&s.config, data).await,
201 ProcessStage::Conditional(s) => self.execute_conditional(&s.config, data).await,
202 }
203 });
204 let result = if let Some(observer) = self.stage_observer.as_ref() {
205 observer.observe(hint, run_stage).await
206 } else {
207 run_stage.await
208 };
209
210 match result {
211 Ok(mut d) => {
212 d.metadata.stages_executed.push(stage_name.clone());
213 if self.config.settings.debug.include_timing {
214 d.metadata
215 .timing
216 .insert(stage_name, start.elapsed().as_millis() as u64);
217 }
218 Ok(d)
219 }
220 Err(error @ AgentError::Config(_))
221 if self
222 .llm_registry
223 .as_ref()
224 .is_some_and(|registry| registry.router_roles().is_some()) =>
225 {
226 Err(error)
227 }
228 Err(e) => {
229 let mut fallback_data = data_clone;
230 match self.config.settings.on_stage_error.default {
231 StageErrorAction::Stop => Err(e),
232 StageErrorAction::Continue => {
233 fallback_data
234 .metadata
235 .warnings
236 .push(format!("Stage {} failed: {}", stage_name, e));
237 Ok(fallback_data)
238 }
239 StageErrorAction::Retry => {
240 if let Some(retry_config) = &self.config.settings.on_stage_error.retry {
241 for _ in 0..retry_config.max_retries {
242 tokio::time::sleep(std::time::Duration::from_millis(
243 retry_config.backoff_ms,
244 ))
245 .await;
246 }
247 }
248 fallback_data
249 .metadata
250 .warnings
251 .push(format!("Stage {} failed after retries: {}", stage_name, e));
252 Ok(fallback_data)
253 }
254 }
255 }
256 }
257 })
258 }
259
260 fn get_stage_type_name(&self, stage: &ProcessStage) -> String {
261 match stage {
262 ProcessStage::Normalize(_) => "normalize".to_string(),
263 ProcessStage::Detect(_) => "detect".to_string(),
264 ProcessStage::Extract(_) => "extract".to_string(),
265 ProcessStage::Sanitize(_) => "sanitize".to_string(),
266 ProcessStage::Transform(_) => "transform".to_string(),
267 ProcessStage::Validate(_) => "validate".to_string(),
268 ProcessStage::Format(_) => "format".to_string(),
269 ProcessStage::Enrich(_) => "enrich".to_string(),
270 ProcessStage::Conditional(_) => "conditional".to_string(),
271 }
272 }
273
274 async fn execute_normalize(
275 &self,
276 config: &NormalizeConfig,
277 mut data: ProcessData,
278 ) -> Result<ProcessData> {
279 let mut content = data.content.clone();
280
281 if config.trim {
282 content = content.trim().to_string();
283 }
284
285 if config.collapse_whitespace {
286 content = content.split_whitespace().collect::<Vec<_>>().join(" ");
287 }
288
289 if config.lowercase {
290 content = content.to_lowercase();
291 }
292
293 data.content = content;
297 Ok(data)
298 }
299
300 async fn execute_detect(
301 &self,
302 config: &DetectConfig,
303 mut data: ProcessData,
304 ) -> Result<ProcessData> {
305 let llm = self.get_llm(ai_agents_llm::LLMRole::ProcessDetect, config.llm.as_deref())?;
306
307 let detection_types: Vec<&str> = config
308 .detect
309 .iter()
310 .map(|d| match d {
311 DetectionType::Language => "language (ISO 639-1 code)",
312 DetectionType::Sentiment => "sentiment (positive, negative, neutral)",
313 DetectionType::Intent => "intent",
314 DetectionType::Topic => "topic",
315 DetectionType::Formality => "formality (formal, informal)",
316 DetectionType::Urgency => "urgency (low, medium, high, critical)",
317 })
318 .collect();
319
320 let intents_desc = if !config.intents.is_empty() {
321 let intents: Vec<String> = config
322 .intents
323 .iter()
324 .map(|i| format!("- {}: {}", i.id, i.description))
325 .collect();
326 format!("\n\nAvailable intents:\n{}", intents.join("\n"))
327 } else {
328 String::new()
329 };
330
331 let prompt = format!(
332 "Analyze the following text and detect: {}\n{}\n\n\
333 Respond with JSON only: {{\"language\": \"...\", \"sentiment\": \"...\", \"intent\": \"...\", ...}}\n\n\
334 Text: {}",
335 detection_types.join(", "),
336 intents_desc,
337 data.content
338 );
339
340 let messages = vec![ChatMessage::user(&prompt)];
341 let response = llm
342 .complete(&messages, None)
343 .await
344 .map_err(|e| AgentError::LLM(e.to_string()))?;
345
346 if let Ok(result) =
347 serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
348 {
349 for (key, context_path) in &config.store_in_context {
350 if let Some(value) = result.get(key) {
351 data.context.insert(context_path.clone(), value.clone());
352 }
353 }
354 data.context.insert("detection".to_string(), result);
355 }
356
357 Ok(data)
358 }
359
360 async fn execute_extract(
361 &self,
362 config: &ExtractConfig,
363 mut data: ProcessData,
364 ) -> Result<ProcessData> {
365 let llm = self.get_llm(
366 ai_agents_llm::LLMRole::ProcessExtract,
367 config.llm.as_deref(),
368 )?;
369
370 let schema_desc: Vec<String> = config
371 .schema
372 .iter()
373 .map(|(name, schema)| {
374 let type_str = format!("{:?}", schema.field_type).to_lowercase();
375 let desc = schema.description.as_deref().unwrap_or("");
376 let values = if !schema.values.is_empty() {
377 format!(" (values: {})", schema.values.join(", "))
378 } else {
379 String::new()
380 };
381 let required = if schema.required { " [required]" } else { "" };
382 format!("- {}: {} - {}{}{}", name, type_str, desc, values, required)
383 })
384 .collect();
385
386 let prompt = format!(
387 "Extract the following fields from the text:\n{}\n\n\
388 Respond with JSON only. Use null for fields not found.\n\n\
389 Text: {}",
390 schema_desc.join("\n"),
391 data.content
392 );
393
394 let messages = vec![ChatMessage::user(&prompt)];
395 let response = llm
396 .complete(&messages, None)
397 .await
398 .map_err(|e| AgentError::LLM(e.to_string()))?;
399
400 if let Ok(result) =
401 serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
402 {
403 if let Some(context_path) = &config.store_in_context {
404 data.context.insert(context_path.clone(), result.clone());
405 }
406 data.context.insert("extracted".to_string(), result);
407 }
408
409 Ok(data)
410 }
411
412 async fn execute_sanitize(
413 &self,
414 config: &SanitizeConfig,
415 mut data: ProcessData,
416 ) -> Result<ProcessData> {
417 let llm = self.get_llm(
418 ai_agents_llm::LLMRole::ProcessSanitize,
419 config.llm.as_deref(),
420 )?;
421
422 let mut instructions = Vec::new();
423
424 if let Some(pii_config) = &config.pii
425 && !pii_config.types.is_empty()
426 {
427 let pii_types: Vec<&str> = pii_config.types.iter().map(pii_type_prompt_label).collect();
428 let action = match pii_config.action {
429 PIIAction::Mask => format!(
430 "Replace every detected value with '{}' and retain none of its original characters",
431 pii_config.mask_char.repeat(4)
432 ),
433 PIIAction::Remove => {
434 "Remove every detected value completely and retain none of its original characters"
435 .to_string()
436 }
437 PIIAction::Flag => {
438 "Wrap every detected value with [PII: type]".to_string()
439 }
440 };
441 instructions.push(format!(
442 "PII rule: {}. Types: {}",
443 action,
444 pii_types.join(", ")
445 ));
446 }
447
448 if let Some(harmful_config) = &config.harmful
449 && !harmful_config.detect.is_empty()
450 {
451 let types: Vec<String> = harmful_config
452 .detect
453 .iter()
454 .map(|t| format!("{:?}", t).to_lowercase())
455 .collect();
456 instructions.push(format!("Detect harmful content: {}", types.join(", ")));
457 }
458
459 if !config.remove.is_empty() {
460 instructions.push(format!(
461 "Remove any mentions of: {}",
462 config.remove.join(", ")
463 ));
464 }
465
466 if instructions.is_empty() {
467 return Ok(data);
468 }
469
470 let prompt = format!(
471 "Sanitize the following text according to these rules:\n{}\n\n\
472 Return only the sanitized text, nothing else.\n\n\
473 Text: {}",
474 instructions.join("\n"),
475 data.content
476 );
477
478 let messages = vec![ChatMessage::user(&prompt)];
479 let response = llm
480 .complete(&messages, None)
481 .await
482 .map_err(|e| AgentError::LLM(e.to_string()))?;
483
484 data.content = response.content.trim().to_string();
485 Ok(data)
486 }
487
488 async fn execute_transform(
489 &self,
490 config: &TransformConfig,
491 mut data: ProcessData,
492 ) -> Result<ProcessData> {
493 let prompt = match &config.prompt {
494 Some(p) => p.clone(),
495 None => return Ok(data),
496 };
497
498 let llm = self.get_llm(
499 ai_agents_llm::LLMRole::ProcessTransform,
500 config.llm.as_deref(),
501 )?;
502
503 let full_prompt = format!("{}\n\nOriginal text:\n{}", prompt, data.content);
504
505 let messages = vec![ChatMessage::user(&full_prompt)];
506 let response = llm
507 .complete(&messages, None)
508 .await
509 .map_err(|e| AgentError::LLM(e.to_string()))?;
510
511 data.content = response.content.trim().to_string();
512 Ok(data)
513 }
514
515 async fn execute_validate(
516 &self,
517 config: &ValidateConfig,
518 mut data: ProcessData,
519 ) -> Result<ProcessData> {
520 for rule in &config.rules {
522 match rule {
523 ValidationRule::MinLength {
524 min_length,
525 on_fail,
526 } => {
527 if data.content.len() < *min_length {
528 match on_fail.action {
529 ValidationActionType::Reject => {
530 data.metadata.rejected = true;
531 data.metadata.rejection_reason = Some(format!(
532 "Content too short: {} < {} characters",
533 data.content.len(),
534 min_length
535 ));
536 return Ok(data);
537 }
538 ValidationActionType::Warn => {
539 data.metadata.warnings.push(format!(
540 "Content shorter than {} characters",
541 min_length
542 ));
543 }
544 ValidationActionType::Truncate => {} }
546 }
547 }
548 ValidationRule::MaxLength {
549 max_length,
550 on_fail,
551 } => {
552 if data.content.len() > *max_length {
553 match on_fail.action {
554 ValidationActionType::Truncate => {
555 data.content = data.content.chars().take(*max_length).collect();
556 }
557 ValidationActionType::Reject => {
558 data.metadata.rejected = true;
559 data.metadata.rejection_reason = Some(format!(
560 "Content too long: {} > {} characters",
561 data.content.len(),
562 max_length
563 ));
564 return Ok(data);
565 }
566 ValidationActionType::Warn => {
567 data.metadata
568 .warnings
569 .push(format!("Content longer than {} characters", max_length));
570 }
571 }
572 }
573 }
574 ValidationRule::Pattern { pattern, on_fail } => {
575 if let Ok(re) = regex::Regex::new(pattern)
576 && !re.is_match(&data.content)
577 {
578 match on_fail.action {
579 ValidationActionType::Reject => {
580 data.metadata.rejected = true;
581 data.metadata.rejection_reason =
582 Some("Content does not match required pattern".to_string());
583 return Ok(data);
584 }
585 ValidationActionType::Warn => {
586 data.metadata
587 .warnings
588 .push("Content does not match expected pattern".to_string());
589 }
590 ValidationActionType::Truncate => {} }
592 }
593 }
594 }
595 }
596
597 if !config.criteria.is_empty() {
599 let llm = self.get_llm(
600 ai_agents_llm::LLMRole::ProcessValidate,
601 config.llm.as_deref(),
602 )?;
603
604 let criteria_list = config
605 .criteria
606 .iter()
607 .enumerate()
608 .map(|(i, c)| format!("{}. {}", i + 1, c))
609 .collect::<Vec<_>>()
610 .join("\n");
611
612 let prompt = format!(
613 "Evaluate if the following content meets these criteria:\n{}\n\n\
614 Respond with JSON: {{\"passes\": true/false, \"score\": 0.0-1.0, \"issues\": [\"...\"]}}\n\n\
615 Content: {}",
616 criteria_list, data.content
617 );
618
619 let messages = vec![ChatMessage::user(&prompt)];
620 let response = llm
621 .complete(&messages, None)
622 .await
623 .map_err(|e| AgentError::LLM(e.to_string()))?;
624
625 if let Ok(result) =
626 serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
627 {
628 let score = result.get("score").and_then(|s| s.as_f64()).unwrap_or(1.0) as f32;
629 let passes = result
630 .get("passes")
631 .and_then(|p| p.as_bool())
632 .unwrap_or(true);
633
634 if !passes || score < config.threshold {
635 match config.on_fail.action {
636 ValidationFailType::Reject => {
637 data.metadata.rejected = true;
638 let issues = result
639 .get("issues")
640 .and_then(|i| i.as_array())
641 .map(|arr| {
642 arr.iter()
643 .filter_map(|v| v.as_str())
644 .collect::<Vec<_>>()
645 .join(", ")
646 })
647 .unwrap_or_else(|| "Validation failed".to_string());
648 data.metadata.rejection_reason = Some(issues);
649 return Ok(data);
650 }
651 ValidationFailType::Regenerate => {
652 data.metadata
653 .warnings
654 .push("Content may need regeneration".to_string());
655 }
656 ValidationFailType::Warn => {
657 if let Some(issues) = result.get("issues").and_then(|i| i.as_array()) {
658 for issue in issues {
659 if let Some(s) = issue.as_str() {
660 data.metadata.warnings.push(s.to_string());
661 }
662 }
663 }
664 }
665 }
666 }
667 }
668 }
669
670 Ok(data)
671 }
672
673 async fn execute_format(
674 &self,
675 config: &FormatConfig,
676 mut data: ProcessData,
677 ) -> Result<ProcessData> {
678 let template = if let Some(channel) = &config.channel {
679 config
680 .channels
681 .get(channel)
682 .and_then(|c| c.template.as_ref())
683 .or(config.template.as_ref())
684 } else {
685 config.template.as_ref()
686 };
687
688 if let Some(tmpl) = template {
689 let mut result = tmpl.clone();
691 result = result.replace("{{ response }}", &data.content);
692 result = result.replace("{{response}}", &data.content);
693
694 for (key, value) in &data.context {
696 let placeholder = format!("{{{{ context.{} }}}}", key);
697 let placeholder_no_space = format!("{{{{context.{}}}}}", key);
698 let value_str = match value {
699 serde_json::Value::String(s) => s.clone(),
700 _ => value.to_string(),
701 };
702 result = result.replace(&placeholder, &value_str);
703 result = result.replace(&placeholder_no_space, &value_str);
704 }
705
706 data.content = result;
707 }
708
709 if let Some(channel) = &config.channel
711 && let Some(channel_config) = config.channels.get(channel)
712 && let Some(max_len) = channel_config.max_length
713 && data.content.len() > max_len
714 {
715 data.content = data.content.chars().take(max_len).collect();
716 }
717
718 Ok(data)
719 }
720
721 async fn execute_enrich(
722 &self,
723 config: &EnrichConfig,
724 mut data: ProcessData,
725 ) -> Result<ProcessData> {
726 let result = match &config.source {
727 EnrichSource::None => return Ok(data),
728 EnrichSource::Api {
729 url,
730 method: _,
731 headers: _,
732 body: _,
733 extract: _,
734 } => {
735 data.metadata
738 .warnings
739 .push(format!("API enrichment not yet implemented: {}", url));
740 return Ok(data);
741 }
742 EnrichSource::File { path, format } => {
743 match std::fs::read_to_string(path) {
745 Ok(content) => match format.as_deref() {
746 Some("json") => serde_json::from_str(&content).ok(),
747 Some("yaml") => serde_yaml::from_str(&content).ok(),
748 _ => Some(serde_json::Value::String(content)),
749 },
750 Err(e) => match config.on_error {
751 EnrichErrorAction::Stop => return Err(AgentError::IoError(e)),
752 EnrichErrorAction::Continue | EnrichErrorAction::Warn => {
753 data.metadata
754 .warnings
755 .push(format!("File read failed: {}", e));
756 return Ok(data);
757 }
758 },
759 }
760 }
761 EnrichSource::Tool { tool, args: _ } => {
762 data.metadata
764 .warnings
765 .push(format!("Tool enrichment not yet implemented: {}", tool));
766 return Ok(data);
767 }
768 };
769
770 if let Some(value) = result
771 && let Some(context_path) = &config.store_in_context
772 {
773 data.context.insert(context_path.clone(), value);
774 }
775
776 Ok(data)
777 }
778
779 async fn execute_conditional(
780 &self,
781 config: &ConditionalConfig,
782 data: ProcessData,
783 ) -> Result<ProcessData> {
784 let condition_met = self.evaluate_condition(&config.condition, &data);
785
786 let stages = if condition_met {
787 &config.then_stages
788 } else {
789 &config.else_stages
790 };
791
792 let mut result = data;
793 for stage in stages {
794 result = self.execute_stage(stage, result).await?;
795 if result.metadata.rejected {
796 break;
797 }
798 }
799
800 Ok(result)
801 }
802
803 fn evaluate_condition(&self, condition: &Option<ConditionExpr>, data: &ProcessData) -> bool {
804 match condition {
805 None => true,
806 Some(expr) => self.evaluate_condition_expr(expr, data),
807 }
808 }
809
810 fn evaluate_condition_expr(&self, condition: &ConditionExpr, data: &ProcessData) -> bool {
811 match condition {
812 ConditionExpr::All { all } => all.iter().all(|c| self.evaluate_condition_expr(c, data)),
813 ConditionExpr::Any { any } => any.iter().any(|c| self.evaluate_condition_expr(c, data)),
814 ConditionExpr::Simple(map) => self.evaluate_simple_condition(map, data),
815 }
816 }
817
818 fn evaluate_simple_condition(
819 &self,
820 map: &std::collections::HashMap<String, serde_json::Value>,
821 data: &ProcessData,
822 ) -> bool {
823 for (path, expected) in map {
824 let actual = self.get_nested_value(&data.context, path);
825
826 if let Some(obj) = expected.as_object()
828 && let Some(exists_val) = obj.get("exists")
829 {
830 let should_exist = exists_val.as_bool().unwrap_or(true);
831 let does_exist =
832 actual.is_some() && !matches!(actual, Some(serde_json::Value::Null));
833 if does_exist != should_exist {
834 return false;
835 }
836 continue;
837 }
838
839 match (actual, expected) {
841 (Some(a), e) if a == e => continue,
842 (None, serde_json::Value::Null) => continue,
843 _ => return false,
844 }
845 }
846 true
847 }
848
849 fn get_nested_value<'a>(
850 &self,
851 context: &'a std::collections::HashMap<String, serde_json::Value>,
852 path: &str,
853 ) -> Option<&'a serde_json::Value> {
854 let parts: Vec<&str> = path.split('.').collect();
855 if parts.is_empty() {
856 return None;
857 }
858
859 let mut current: Option<&serde_json::Value> = context.get(parts[0]);
860
861 for part in &parts[1..] {
862 current = current.and_then(|v| {
863 if let serde_json::Value::Object(obj) = v {
864 obj.get(*part)
865 } else {
866 None
867 }
868 });
869 }
870
871 current
872 }
873
874 fn get_llm(
876 &self,
877 role: ai_agents_llm::LLMRole,
878 alias: Option<&str>,
879 ) -> Result<Arc<dyn LLMProvider>> {
880 let registry = self
881 .llm_registry
882 .as_ref()
883 .ok_or_else(|| AgentError::Config("LLM registry not configured for process".into()))?;
884
885 if let Some(resolved) = registry
886 .resolve_role_override(role, alias)
887 .map_err(|e| AgentError::Config(e.to_string()))?
888 {
889 return Ok(resolved.provider);
890 }
891 match alias {
892 Some(name) => registry
893 .get(name)
894 .map_err(|e| AgentError::LLM(e.to_string())),
895 None => registry
896 .router()
897 .or_else(|_| registry.default())
898 .map_err(|e| AgentError::LLM(e.to_string())),
899 }
900 }
901}
902
903fn purpose_hint_for_stages(stages: &[ProcessStage]) -> ProcessPurposeHint {
904 for stage in stages {
905 let hint = process_purpose_hint_for_stage(stage);
906 if hint != ProcessPurposeHint::Other {
907 return hint;
908 }
909 }
910 ProcessPurposeHint::Other
911}
912
913fn pii_type_prompt_label(pii_type: &PIIType) -> &'static str {
915 match pii_type {
916 PIIType::Email => "email address",
917 PIIType::Phone => "phone number",
918 PIIType::CreditCard => "credit card number",
919 PIIType::Ssn => "social security number",
920 PIIType::IpAddress => "IP address",
921 PIIType::Name => "person name",
922 PIIType::Address => "physical address",
923 }
924}
925
926fn process_purpose_hint_for_stage(stage: &ProcessStage) -> ProcessPurposeHint {
927 match stage {
928 ProcessStage::Detect(_) => ProcessPurposeHint::Detect,
929 ProcessStage::Extract(_) => ProcessPurposeHint::Extract,
930 ProcessStage::Validate(_) => ProcessPurposeHint::Validate,
931 ProcessStage::Sanitize(_) | ProcessStage::Transform(_) => ProcessPurposeHint::Transform,
932 ProcessStage::Conditional(config) => {
933 let then_hint = purpose_hint_for_stages(&config.config.then_stages);
934 if then_hint != ProcessPurposeHint::Other {
935 then_hint
936 } else {
937 purpose_hint_for_stages(&config.config.else_stages)
938 }
939 }
940 _ => ProcessPurposeHint::Other,
941 }
942}
943
944fn extract_json(response: &str) -> String {
945 let trimmed = response.trim();
946
947 if let Some(json) = trimmed.strip_prefix("```json")
948 && let Some(end) = json.find("```")
949 {
950 return json[..end].trim().to_string();
951 }
952
953 if let Some(fenced) = trimmed.strip_prefix("```")
954 && let Some(end) = fenced.find("```")
955 {
956 return fenced[..end].trim().to_string();
957 }
958
959 if let Some(start) = trimmed.find('{')
960 && let Some(end) = trimmed.rfind('}')
961 {
962 return trimmed[start..=end].to_string();
963 }
964
965 trimmed.to_string()
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn test_process_data_new() {
974 let data = ProcessData::new("test content");
975 assert_eq!(data.content, "test content");
976 assert_eq!(data.original, "test content");
977 assert!(data.context.is_empty());
978 }
979
980 #[test]
981 fn test_process_data_with_context() {
982 let data = ProcessData::new("test").with_context("key", serde_json::json!("value"));
983 assert!(data.context.contains_key("key"));
984 }
985
986 #[tokio::test]
987 async fn test_normalize_trim() {
988 let processor = ProcessProcessor::default();
989 let config = NormalizeConfig {
990 trim: true,
991 ..Default::default()
992 };
993 let data = ProcessData::new(" hello world ");
994 let result = processor.execute_normalize(&config, data).await.unwrap();
995 assert_eq!(result.content, "hello world");
996 }
997
998 #[tokio::test]
999 async fn test_normalize_collapse_whitespace() {
1000 let processor = ProcessProcessor::default();
1001 let config = NormalizeConfig {
1002 trim: true,
1003 collapse_whitespace: true,
1004 ..Default::default()
1005 };
1006 let data = ProcessData::new("hello world\n\ntest");
1007 let result = processor.execute_normalize(&config, data).await.unwrap();
1008 assert_eq!(result.content, "hello world test");
1009 }
1010
1011 #[tokio::test]
1012 async fn test_normalize_lowercase() {
1013 let processor = ProcessProcessor::default();
1014 let config = NormalizeConfig {
1015 lowercase: true,
1016 ..Default::default()
1017 };
1018 let data = ProcessData::new("Hello World");
1019 let result = processor.execute_normalize(&config, data).await.unwrap();
1020 assert_eq!(result.content, "hello world");
1021 }
1022
1023 #[tokio::test]
1024 async fn test_validate_min_length_reject() {
1025 let processor = ProcessProcessor::default();
1026 let config = ValidateConfig {
1027 rules: vec![ValidationRule::MinLength {
1028 min_length: 10,
1029 on_fail: ValidationAction {
1030 action: ValidationActionType::Reject,
1031 message: None,
1032 },
1033 }],
1034 ..Default::default()
1035 };
1036 let data = ProcessData::new("short");
1037 let result = processor.execute_validate(&config, data).await.unwrap();
1038 assert!(result.metadata.rejected);
1039 }
1040
1041 #[tokio::test]
1042 async fn test_validate_max_length_truncate() {
1043 let processor = ProcessProcessor::default();
1044 let config = ValidateConfig {
1045 rules: vec![ValidationRule::MaxLength {
1046 max_length: 5,
1047 on_fail: ValidationAction {
1048 action: ValidationActionType::Truncate,
1049 message: None,
1050 },
1051 }],
1052 ..Default::default()
1053 };
1054 let data = ProcessData::new("hello world");
1055 let result = processor.execute_validate(&config, data).await.unwrap();
1056 assert_eq!(result.content, "hello");
1057 assert!(!result.metadata.rejected);
1058 }
1059
1060 #[tokio::test]
1061 async fn test_format_simple_template() {
1062 let processor = ProcessProcessor::default();
1063 let config = FormatConfig {
1064 template: Some("Response: {{ response }}".to_string()),
1065 ..Default::default()
1066 };
1067 let data = ProcessData::new("Hello!");
1068 let result = processor.execute_format(&config, data).await.unwrap();
1069 assert_eq!(result.content, "Response: Hello!");
1070 }
1071
1072 #[test]
1073 fn test_extract_json() {
1074 assert_eq!(extract_json(r#"{"key": 1}"#), r#"{"key": 1}"#);
1075 assert_eq!(extract_json("```json\n{\"key\": 1}\n```"), r#"{"key": 1}"#);
1076 assert_eq!(extract_json("Some text {\"key\": 1} more"), r#"{"key": 1}"#);
1077 }
1078
1079 #[test]
1080 fn test_evaluate_condition_empty() {
1081 let processor = ProcessProcessor::default();
1082 let data = ProcessData::new("test");
1083 assert!(processor.evaluate_condition(&None, &data));
1084 }
1085
1086 #[test]
1087 fn test_evaluate_condition_simple_exists_true() {
1088 let processor = ProcessProcessor::default();
1089 let mut data = ProcessData::new("test");
1090 data.context.insert(
1091 "session".to_string(),
1092 serde_json::json!({ "user_name": "Alice" }),
1093 );
1094
1095 let mut map = std::collections::HashMap::new();
1096 map.insert(
1097 "session.user_name".to_string(),
1098 serde_json::json!({ "exists": true }),
1099 );
1100 let condition = ConditionExpr::Simple(map);
1101
1102 assert!(processor.evaluate_condition_expr(&condition, &data));
1103 }
1104
1105 #[test]
1106 fn test_evaluate_condition_simple_exists_false() {
1107 let processor = ProcessProcessor::default();
1108 let data = ProcessData::new("test");
1109
1110 let mut map = std::collections::HashMap::new();
1111 map.insert(
1112 "session.user_name".to_string(),
1113 serde_json::json!({ "exists": false }),
1114 );
1115 let condition = ConditionExpr::Simple(map);
1116
1117 assert!(processor.evaluate_condition_expr(&condition, &data));
1118 }
1119
1120 #[test]
1121 fn test_evaluate_condition_all() {
1122 let processor = ProcessProcessor::default();
1123 let mut data = ProcessData::new("test");
1124 data.context.insert(
1125 "session".to_string(),
1126 serde_json::json!({ "user_name": "Alice", "language": "en" }),
1127 );
1128
1129 let mut map1 = std::collections::HashMap::new();
1130 map1.insert(
1131 "session.user_name".to_string(),
1132 serde_json::json!({ "exists": true }),
1133 );
1134 let mut map2 = std::collections::HashMap::new();
1135 map2.insert(
1136 "session.language".to_string(),
1137 serde_json::json!({ "exists": true }),
1138 );
1139
1140 let condition = ConditionExpr::All {
1141 all: vec![ConditionExpr::Simple(map1), ConditionExpr::Simple(map2)],
1142 };
1143
1144 assert!(processor.evaluate_condition_expr(&condition, &data));
1145 }
1146
1147 #[test]
1148 fn test_evaluate_condition_any() {
1149 let processor = ProcessProcessor::default();
1150 let mut data = ProcessData::new("test");
1151 data.context.insert(
1152 "session".to_string(),
1153 serde_json::json!({ "tier": "premium" }),
1154 );
1155
1156 let mut map1 = std::collections::HashMap::new();
1157 map1.insert("session.tier".to_string(), serde_json::json!("premium"));
1158 let mut map2 = std::collections::HashMap::new();
1159 map2.insert("session.tier".to_string(), serde_json::json!("enterprise"));
1160
1161 let condition = ConditionExpr::Any {
1162 any: vec![ConditionExpr::Simple(map1), ConditionExpr::Simple(map2)],
1163 };
1164
1165 assert!(processor.evaluate_condition_expr(&condition, &data));
1166 }
1167
1168 #[test]
1169 fn test_evaluate_condition_value_match() {
1170 let processor = ProcessProcessor::default();
1171 let mut data = ProcessData::new("test");
1172 data.context.insert(
1173 "input".to_string(),
1174 serde_json::json!({ "sentiment": "negative" }),
1175 );
1176
1177 let mut map = std::collections::HashMap::new();
1178 map.insert("input.sentiment".to_string(), serde_json::json!("negative"));
1179 let condition = ConditionExpr::Simple(map);
1180
1181 assert!(processor.evaluate_condition_expr(&condition, &data));
1182 }
1183
1184 #[test]
1185 fn test_get_nested_value() {
1186 let processor = ProcessProcessor::default();
1187 let mut context = std::collections::HashMap::new();
1188 context.insert(
1189 "session".to_string(),
1190 serde_json::json!({ "user": { "name": "Alice" } }),
1191 );
1192
1193 let result = processor.get_nested_value(&context, "session.user.name");
1194 assert_eq!(result, Some(&serde_json::json!("Alice")));
1195
1196 let result = processor.get_nested_value(&context, "session.nonexistent");
1197 assert!(result.is_none());
1198 }
1199
1200 fn create_mock_registry(response: &str) -> Arc<ai_agents_llm::LLMRegistry> {
1204 use ai_agents_llm::mock::MockLLMProvider;
1205 let mut mock = MockLLMProvider::new("test");
1206 mock.set_response(response);
1207 let mut registry = ai_agents_llm::LLMRegistry::new();
1208 registry.register("default", std::sync::Arc::new(mock));
1209 registry.set_default("default");
1210 std::sync::Arc::new(registry)
1211 }
1212
1213 fn create_mock_registry_multi(responses: Vec<&str>) -> Arc<ai_agents_llm::LLMRegistry> {
1214 use ai_agents_llm::mock::MockLLMProvider;
1215 let mut mock = MockLLMProvider::new("test");
1216 mock.set_responses(responses.into_iter().map(String::from).collect(), true);
1217 let mut registry = ai_agents_llm::LLMRegistry::new();
1218 registry.register("default", std::sync::Arc::new(mock));
1219 registry.set_default("default");
1220 std::sync::Arc::new(registry)
1221 }
1222
1223 #[tokio::test]
1224 async fn test_detect_stage_language_sentiment() {
1225 let registry = create_mock_registry(
1226 r#"{"language": "ko", "sentiment": "positive", "intent": "greeting"}"#,
1227 );
1228 let config = ProcessConfig {
1229 input: vec![ProcessStage::Detect(DetectStage {
1230 id: Some("detect_test".to_string()),
1231 condition: None,
1232 config: DetectConfig {
1233 llm: None,
1234 detect: vec![DetectionType::Language, DetectionType::Sentiment],
1235 intents: vec![IntentDefinition {
1236 id: "greeting".to_string(),
1237 description: "User says hello".to_string(),
1238 }],
1239 store_in_context: {
1240 let mut m = std::collections::HashMap::new();
1241 m.insert("language".to_string(), "input.language".to_string());
1242 m.insert("sentiment".to_string(), "input.sentiment".to_string());
1243 m
1244 },
1245 },
1246 })],
1247 ..Default::default()
1248 };
1249 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1250 let result = processor.process_input("안녕하세요!").await.unwrap();
1251
1252 assert_eq!(
1253 result.context.get("input.language"),
1254 Some(&serde_json::json!("ko"))
1255 );
1256 assert_eq!(
1257 result.context.get("input.sentiment"),
1258 Some(&serde_json::json!("positive"))
1259 );
1260 assert!(
1261 result
1262 .metadata
1263 .stages_executed
1264 .contains(&"detect_test".to_string())
1265 );
1266 }
1267
1268 #[tokio::test]
1269 async fn test_extract_stage_entities() {
1270 let registry = create_mock_registry(r#"{"order_number": "ORD-12345", "urgency": "high"}"#);
1271 let config = ProcessConfig {
1272 input: vec![ProcessStage::Extract(ExtractStage {
1273 id: Some("extract_test".to_string()),
1274 condition: None,
1275 config: ExtractConfig {
1276 llm: None,
1277 schema: {
1278 let mut m = std::collections::HashMap::new();
1279 m.insert(
1280 "order_number".to_string(),
1281 FieldSchema {
1282 field_type: FieldType::String,
1283 description: Some("Order number".to_string()),
1284 required: true,
1285 values: vec![],
1286 },
1287 );
1288 m.insert(
1289 "urgency".to_string(),
1290 FieldSchema {
1291 field_type: FieldType::Enum,
1292 description: Some("Urgency level".to_string()),
1293 required: false,
1294 values: vec![
1295 "low".to_string(),
1296 "medium".to_string(),
1297 "high".to_string(),
1298 ],
1299 },
1300 );
1301 m
1302 },
1303 store_in_context: Some("extracted".to_string()),
1304 },
1305 })],
1306 ..Default::default()
1307 };
1308 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1309 let result = processor
1310 .process_input("My order ORD-12345 is urgent!")
1311 .await
1312 .unwrap();
1313
1314 let extracted = result.context.get("extracted").unwrap();
1315 assert_eq!(extracted["order_number"], "ORD-12345");
1316 assert_eq!(extracted["urgency"], "high");
1317 }
1318
1319 #[test]
1320 fn test_pii_prompt_labels_are_unambiguous() {
1321 assert_eq!(
1322 pii_type_prompt_label(&PIIType::CreditCard),
1323 "credit card number"
1324 );
1325 assert_eq!(
1326 pii_type_prompt_label(&PIIType::Ssn),
1327 "social security number"
1328 );
1329 assert_eq!(pii_type_prompt_label(&PIIType::IpAddress), "IP address");
1330 }
1331
1332 #[tokio::test]
1333 async fn test_sanitize_stage_pii_masking() {
1334 let registry = create_mock_registry("Call me at ****-****-**** or email at ****@****.com");
1335 let config = ProcessConfig {
1336 input: vec![ProcessStage::Sanitize(SanitizeStage {
1337 id: Some("sanitize_test".to_string()),
1338 condition: None,
1339 config: SanitizeConfig {
1340 llm: None,
1341 pii: Some(PIISanitizeConfig {
1342 action: PIIAction::Mask,
1343 types: vec![PIIType::Phone, PIIType::Email],
1344 mask_char: "*".to_string(),
1345 }),
1346 harmful: None,
1347 remove: vec![],
1348 },
1349 })],
1350 ..Default::default()
1351 };
1352 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1353 let result = processor
1354 .process_input("Call me at 010-1234-5678 or email at user@example.com")
1355 .await
1356 .unwrap();
1357
1358 assert!(result.content.contains("****"));
1360 assert!(!result.content.contains("010-1234-5678"));
1361 assert!(!result.content.contains("user@example.com"));
1362 }
1363
1364 #[tokio::test]
1365 async fn test_transform_stage_tone_adjustment() {
1366 let registry = create_mock_registry(
1367 "I understand your frustration. Let me help you resolve this issue right away.",
1368 );
1369 let config = ProcessConfig {
1370 output: vec![ProcessStage::Transform(TransformStage {
1371 id: Some("tone_test".to_string()),
1372 condition: None,
1373 config: TransformConfig {
1374 llm: None,
1375 prompt: Some("Rewrite to be more empathetic.".to_string()),
1376 max_output_tokens: None,
1377 },
1378 })],
1379 ..Default::default()
1380 };
1381 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1382
1383 let input_context = std::collections::HashMap::new();
1384 let result = processor
1385 .process_output("Your request is being processed.", &input_context)
1386 .await
1387 .unwrap();
1388
1389 assert!(result.content.contains("understand"));
1390 }
1391
1392 #[tokio::test]
1393 async fn test_validate_stage_llm_criteria() {
1394 let registry = create_mock_registry(
1395 r#"{"passes": false, "score": 0.3, "issues": ["Response is too vague"]}"#,
1396 );
1397 let config = ProcessConfig {
1398 output: vec![ProcessStage::Validate(ValidateStage {
1399 id: Some("quality_test".to_string()),
1400 condition: None,
1401 config: ValidateConfig {
1402 rules: vec![],
1403 llm: None,
1404 criteria: vec!["Response is specific and actionable".to_string()],
1405 threshold: 0.7,
1406 on_fail: ValidationFailAction {
1407 action: ValidationFailType::Warn,
1408 ..Default::default()
1409 },
1410 },
1411 })],
1412 ..Default::default()
1413 };
1414 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1415
1416 let input_context = std::collections::HashMap::new();
1417 let result = processor
1418 .process_output("It depends.", &input_context)
1419 .await
1420 .unwrap();
1421
1422 assert!(
1424 result.metadata.warnings.iter().any(|w| w.contains("vague")),
1425 "Expected warning about vague response, got: {:?}",
1426 result.metadata.warnings
1427 );
1428 }
1429
1430 #[tokio::test]
1431 async fn test_validate_stage_llm_criteria_reject() {
1432 let registry = create_mock_registry(
1433 r#"{"passes": false, "score": 0.2, "issues": ["Contains harmful content"]}"#,
1434 );
1435 let config = ProcessConfig {
1436 output: vec![ProcessStage::Validate(ValidateStage {
1437 id: Some("reject_test".to_string()),
1438 condition: None,
1439 config: ValidateConfig {
1440 rules: vec![],
1441 llm: None,
1442 criteria: vec!["Response is safe".to_string()],
1443 threshold: 0.7,
1444 on_fail: ValidationFailAction {
1445 action: ValidationFailType::Reject,
1446 ..Default::default()
1447 },
1448 },
1449 })],
1450 ..Default::default()
1451 };
1452 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1453
1454 let input_context = std::collections::HashMap::new();
1455 let result = processor
1456 .process_output("Dangerous content here.", &input_context)
1457 .await
1458 .unwrap();
1459
1460 assert!(result.metadata.rejected);
1461 assert!(
1462 result
1463 .metadata
1464 .rejection_reason
1465 .as_ref()
1466 .unwrap()
1467 .contains("harmful")
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn test_full_input_pipeline_chain() {
1473 let registry = create_mock_registry_multi(vec![
1475 r#"{"language": "en", "sentiment": "neutral"}"#,
1477 r#"{"user_name": "Alice", "topic": "billing"}"#,
1479 ]);
1480 let config = ProcessConfig {
1481 input: vec![
1482 ProcessStage::Normalize(NormalizeStage {
1483 id: Some("norm".to_string()),
1484 condition: None,
1485 config: NormalizeConfig {
1486 trim: true,
1487 collapse_whitespace: true,
1488 ..Default::default()
1489 },
1490 }),
1491 ProcessStage::Detect(DetectStage {
1492 id: Some("detect".to_string()),
1493 condition: None,
1494 config: DetectConfig {
1495 llm: None,
1496 detect: vec![DetectionType::Language, DetectionType::Sentiment],
1497 intents: vec![],
1498 store_in_context: {
1499 let mut m = std::collections::HashMap::new();
1500 m.insert("language".to_string(), "input.language".to_string());
1501 m
1502 },
1503 },
1504 }),
1505 ProcessStage::Extract(ExtractStage {
1506 id: Some("extract".to_string()),
1507 condition: None,
1508 config: ExtractConfig {
1509 llm: None,
1510 schema: {
1511 let mut m = std::collections::HashMap::new();
1512 m.insert(
1513 "user_name".to_string(),
1514 FieldSchema {
1515 field_type: FieldType::String,
1516 description: Some("User name".to_string()),
1517 ..Default::default()
1518 },
1519 );
1520 m
1521 },
1522 store_in_context: Some("entities".to_string()),
1523 },
1524 }),
1525 ],
1526 ..Default::default()
1527 };
1528 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1529 let result = processor
1530 .process_input(" Hi, I'm Alice and I have a billing question ")
1531 .await
1532 .unwrap();
1533
1534 assert_eq!(
1536 result.content,
1537 "Hi, I'm Alice and I have a billing question"
1538 );
1539
1540 assert_eq!(
1542 result.context.get("input.language"),
1543 Some(&serde_json::json!("en"))
1544 );
1545
1546 let entities = result.context.get("entities").unwrap();
1548 assert_eq!(entities["user_name"], "Alice");
1549
1550 assert_eq!(
1552 result.metadata.stages_executed,
1553 vec!["norm", "detect", "extract"]
1554 );
1555 }
1556
1557 #[tokio::test]
1558 async fn test_conditional_stage_skips_on_false() {
1559 let registry = create_mock_registry(r#"{"language": "en"}"#);
1560 let config = ProcessConfig {
1561 input: vec![ProcessStage::Detect(DetectStage {
1562 id: Some("should_skip".to_string()),
1563 condition: Some(ConditionExpr::Simple({
1564 let mut map = std::collections::HashMap::new();
1565 map.insert("needs_detection".to_string(), serde_json::json!(true));
1566 map
1567 })),
1568 config: DetectConfig {
1569 llm: None,
1570 detect: vec![DetectionType::Language],
1571 ..Default::default()
1572 },
1573 })],
1574 ..Default::default()
1575 };
1576 let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1577 let result = processor.process_input("Hello").await.unwrap();
1578
1579 assert!(
1581 !result
1582 .metadata
1583 .stages_executed
1584 .contains(&"should_skip".to_string()),
1585 "Stage should have been skipped"
1586 );
1587 }
1588
1589 #[tokio::test]
1590 async fn test_stage_skipped_when_condition_false() {
1591 let config = ProcessConfig {
1592 input: vec![ProcessStage::Extract(ExtractStage {
1593 id: Some("skip_me".to_string()),
1594 condition: Some(ConditionExpr::Simple({
1595 let mut map = std::collections::HashMap::new();
1596 map.insert(
1597 "session.user".to_string(),
1598 serde_json::json!({ "exists": false }),
1599 );
1600 map
1601 })),
1602 config: ExtractConfig::default(),
1603 })],
1604 settings: ProcessSettings {
1605 debug: ProcessDebugConfig {
1606 log_stages: true,
1607 ..Default::default()
1608 },
1609 ..Default::default()
1610 },
1611 ..Default::default()
1612 };
1613 let processor = ProcessProcessor::new(config);
1614
1615 let mut data = ProcessData::new("test");
1616 data.context.insert(
1617 "session".to_string(),
1618 serde_json::json!({ "user": "Alice" }),
1619 );
1620
1621 let result = processor.process_input("test").await.unwrap();
1622 assert!(
1623 !result
1624 .metadata
1625 .stages_executed
1626 .contains(&"skip_me".to_string())
1627 );
1628 }
1629}