Skip to main content

ai_agents_process/
processor.rs

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