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<&str> = pii_config.types.iter().map(pii_type_prompt_label).collect();
409            let action = match pii_config.action {
410                PIIAction::Mask => format!(
411                    "Replace every detected value with '{}' and retain none of its original characters",
412                    pii_config.mask_char.repeat(4)
413                ),
414                PIIAction::Remove => {
415                    "Remove every detected value completely and retain none of its original characters"
416                        .to_string()
417                }
418                PIIAction::Flag => {
419                    "Wrap every detected value with [PII: type]".to_string()
420                }
421            };
422            instructions.push(format!(
423                "PII rule: {}. Types: {}",
424                action,
425                pii_types.join(", ")
426            ));
427        }
428
429        if let Some(harmful_config) = &config.harmful
430            && !harmful_config.detect.is_empty()
431        {
432            let types: Vec<String> = harmful_config
433                .detect
434                .iter()
435                .map(|t| format!("{:?}", t).to_lowercase())
436                .collect();
437            instructions.push(format!("Detect harmful content: {}", types.join(", ")));
438        }
439
440        if !config.remove.is_empty() {
441            instructions.push(format!(
442                "Remove any mentions of: {}",
443                config.remove.join(", ")
444            ));
445        }
446
447        if instructions.is_empty() {
448            return Ok(data);
449        }
450
451        let prompt = format!(
452            "Sanitize the following text according to these rules:\n{}\n\n\
453             Return only the sanitized text, nothing else.\n\n\
454             Text: {}",
455            instructions.join("\n"),
456            data.content
457        );
458
459        let messages = vec![ChatMessage::user(&prompt)];
460        let response = llm
461            .complete(&messages, None)
462            .await
463            .map_err(|e| AgentError::LLM(e.to_string()))?;
464
465        data.content = response.content.trim().to_string();
466        Ok(data)
467    }
468
469    async fn execute_transform(
470        &self,
471        config: &TransformConfig,
472        mut data: ProcessData,
473    ) -> Result<ProcessData> {
474        let prompt = match &config.prompt {
475            Some(p) => p.clone(),
476            None => return Ok(data),
477        };
478
479        let llm = self.get_llm(config.llm.as_deref())?;
480
481        let full_prompt = format!("{}\n\nOriginal text:\n{}", prompt, data.content);
482
483        let messages = vec![ChatMessage::user(&full_prompt)];
484        let response = llm
485            .complete(&messages, None)
486            .await
487            .map_err(|e| AgentError::LLM(e.to_string()))?;
488
489        data.content = response.content.trim().to_string();
490        Ok(data)
491    }
492
493    async fn execute_validate(
494        &self,
495        config: &ValidateConfig,
496        mut data: ProcessData,
497    ) -> Result<ProcessData> {
498        // Rule-based validation
499        for rule in &config.rules {
500            match rule {
501                ValidationRule::MinLength {
502                    min_length,
503                    on_fail,
504                } => {
505                    if data.content.len() < *min_length {
506                        match on_fail.action {
507                            ValidationActionType::Reject => {
508                                data.metadata.rejected = true;
509                                data.metadata.rejection_reason = Some(format!(
510                                    "Content too short: {} < {} characters",
511                                    data.content.len(),
512                                    min_length
513                                ));
514                                return Ok(data);
515                            }
516                            ValidationActionType::Warn => {
517                                data.metadata.warnings.push(format!(
518                                    "Content shorter than {} characters",
519                                    min_length
520                                ));
521                            }
522                            ValidationActionType::Truncate => {} // N/A for min_length
523                        }
524                    }
525                }
526                ValidationRule::MaxLength {
527                    max_length,
528                    on_fail,
529                } => {
530                    if data.content.len() > *max_length {
531                        match on_fail.action {
532                            ValidationActionType::Truncate => {
533                                data.content = data.content.chars().take(*max_length).collect();
534                            }
535                            ValidationActionType::Reject => {
536                                data.metadata.rejected = true;
537                                data.metadata.rejection_reason = Some(format!(
538                                    "Content too long: {} > {} characters",
539                                    data.content.len(),
540                                    max_length
541                                ));
542                                return Ok(data);
543                            }
544                            ValidationActionType::Warn => {
545                                data.metadata
546                                    .warnings
547                                    .push(format!("Content longer than {} characters", max_length));
548                            }
549                        }
550                    }
551                }
552                ValidationRule::Pattern { pattern, on_fail } => {
553                    if let Ok(re) = regex::Regex::new(pattern)
554                        && !re.is_match(&data.content)
555                    {
556                        match on_fail.action {
557                            ValidationActionType::Reject => {
558                                data.metadata.rejected = true;
559                                data.metadata.rejection_reason =
560                                    Some("Content does not match required pattern".to_string());
561                                return Ok(data);
562                            }
563                            ValidationActionType::Warn => {
564                                data.metadata
565                                    .warnings
566                                    .push("Content does not match expected pattern".to_string());
567                            }
568                            ValidationActionType::Truncate => {} // N/A for pattern
569                        }
570                    }
571                }
572            }
573        }
574
575        // LLM-based validation
576        if !config.criteria.is_empty() {
577            let llm = self.get_llm(config.llm.as_deref())?;
578
579            let criteria_list = config
580                .criteria
581                .iter()
582                .enumerate()
583                .map(|(i, c)| format!("{}. {}", i + 1, c))
584                .collect::<Vec<_>>()
585                .join("\n");
586
587            let prompt = format!(
588                "Evaluate if the following content meets these criteria:\n{}\n\n\
589                 Respond with JSON: {{\"passes\": true/false, \"score\": 0.0-1.0, \"issues\": [\"...\"]}}\n\n\
590                 Content: {}",
591                criteria_list, data.content
592            );
593
594            let messages = vec![ChatMessage::user(&prompt)];
595            let response = llm
596                .complete(&messages, None)
597                .await
598                .map_err(|e| AgentError::LLM(e.to_string()))?;
599
600            if let Ok(result) =
601                serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
602            {
603                let score = result.get("score").and_then(|s| s.as_f64()).unwrap_or(1.0) as f32;
604                let passes = result
605                    .get("passes")
606                    .and_then(|p| p.as_bool())
607                    .unwrap_or(true);
608
609                if !passes || score < config.threshold {
610                    match config.on_fail.action {
611                        ValidationFailType::Reject => {
612                            data.metadata.rejected = true;
613                            let issues = result
614                                .get("issues")
615                                .and_then(|i| i.as_array())
616                                .map(|arr| {
617                                    arr.iter()
618                                        .filter_map(|v| v.as_str())
619                                        .collect::<Vec<_>>()
620                                        .join(", ")
621                                })
622                                .unwrap_or_else(|| "Validation failed".to_string());
623                            data.metadata.rejection_reason = Some(issues);
624                            return Ok(data);
625                        }
626                        ValidationFailType::Regenerate => {
627                            data.metadata
628                                .warnings
629                                .push("Content may need regeneration".to_string());
630                        }
631                        ValidationFailType::Warn => {
632                            if let Some(issues) = result.get("issues").and_then(|i| i.as_array()) {
633                                for issue in issues {
634                                    if let Some(s) = issue.as_str() {
635                                        data.metadata.warnings.push(s.to_string());
636                                    }
637                                }
638                            }
639                        }
640                    }
641                }
642            }
643        }
644
645        Ok(data)
646    }
647
648    async fn execute_format(
649        &self,
650        config: &FormatConfig,
651        mut data: ProcessData,
652    ) -> Result<ProcessData> {
653        let template = if let Some(channel) = &config.channel {
654            config
655                .channels
656                .get(channel)
657                .and_then(|c| c.template.as_ref())
658                .or(config.template.as_ref())
659        } else {
660            config.template.as_ref()
661        };
662
663        if let Some(tmpl) = template {
664            // Simple template substitution
665            let mut result = tmpl.clone();
666            result = result.replace("{{ response }}", &data.content);
667            result = result.replace("{{response}}", &data.content);
668
669            // Replace context variables
670            for (key, value) in &data.context {
671                let placeholder = format!("{{{{ context.{} }}}}", key);
672                let placeholder_no_space = format!("{{{{context.{}}}}}", key);
673                let value_str = match value {
674                    serde_json::Value::String(s) => s.clone(),
675                    _ => value.to_string(),
676                };
677                result = result.replace(&placeholder, &value_str);
678                result = result.replace(&placeholder_no_space, &value_str);
679            }
680
681            data.content = result;
682        }
683
684        // Apply channel-specific max_length
685        if let Some(channel) = &config.channel
686            && let Some(channel_config) = config.channels.get(channel)
687            && let Some(max_len) = channel_config.max_length
688            && data.content.len() > max_len
689        {
690            data.content = data.content.chars().take(max_len).collect();
691        }
692
693        Ok(data)
694    }
695
696    async fn execute_enrich(
697        &self,
698        config: &EnrichConfig,
699        mut data: ProcessData,
700    ) -> Result<ProcessData> {
701        let result = match &config.source {
702            EnrichSource::None => return Ok(data),
703            EnrichSource::Api {
704                url,
705                method: _,
706                headers: _,
707                body: _,
708                extract: _,
709            } => {
710                // API enrichment would require HTTP client
711                // For now, add a warning
712                data.metadata
713                    .warnings
714                    .push(format!("API enrichment not yet implemented: {}", url));
715                return Ok(data);
716            }
717            EnrichSource::File { path, format } => {
718                // File enrichment
719                match std::fs::read_to_string(path) {
720                    Ok(content) => match format.as_deref() {
721                        Some("json") => serde_json::from_str(&content).ok(),
722                        Some("yaml") => serde_yaml::from_str(&content).ok(),
723                        _ => Some(serde_json::Value::String(content)),
724                    },
725                    Err(e) => match config.on_error {
726                        EnrichErrorAction::Stop => return Err(AgentError::IoError(e)),
727                        EnrichErrorAction::Continue | EnrichErrorAction::Warn => {
728                            data.metadata
729                                .warnings
730                                .push(format!("File read failed: {}", e));
731                            return Ok(data);
732                        }
733                    },
734                }
735            }
736            EnrichSource::Tool { tool, args: _ } => {
737                // Tool execution would need tool registry access
738                data.metadata
739                    .warnings
740                    .push(format!("Tool enrichment not yet implemented: {}", tool));
741                return Ok(data);
742            }
743        };
744
745        if let Some(value) = result
746            && let Some(context_path) = &config.store_in_context
747        {
748            data.context.insert(context_path.clone(), value);
749        }
750
751        Ok(data)
752    }
753
754    async fn execute_conditional(
755        &self,
756        config: &ConditionalConfig,
757        data: ProcessData,
758    ) -> Result<ProcessData> {
759        let condition_met = self.evaluate_condition(&config.condition, &data);
760
761        let stages = if condition_met {
762            &config.then_stages
763        } else {
764            &config.else_stages
765        };
766
767        let mut result = data;
768        for stage in stages {
769            result = self.execute_stage(stage, result).await?;
770            if result.metadata.rejected {
771                break;
772            }
773        }
774
775        Ok(result)
776    }
777
778    fn evaluate_condition(&self, condition: &Option<ConditionExpr>, data: &ProcessData) -> bool {
779        match condition {
780            None => true,
781            Some(expr) => self.evaluate_condition_expr(expr, data),
782        }
783    }
784
785    fn evaluate_condition_expr(&self, condition: &ConditionExpr, data: &ProcessData) -> bool {
786        match condition {
787            ConditionExpr::All { all } => all.iter().all(|c| self.evaluate_condition_expr(c, data)),
788            ConditionExpr::Any { any } => any.iter().any(|c| self.evaluate_condition_expr(c, data)),
789            ConditionExpr::Simple(map) => self.evaluate_simple_condition(map, data),
790        }
791    }
792
793    fn evaluate_simple_condition(
794        &self,
795        map: &std::collections::HashMap<String, serde_json::Value>,
796        data: &ProcessData,
797    ) -> bool {
798        for (path, expected) in map {
799            let actual = self.get_nested_value(&data.context, path);
800
801            // Handle { exists: true/false }
802            if let Some(obj) = expected.as_object()
803                && let Some(exists_val) = obj.get("exists")
804            {
805                let should_exist = exists_val.as_bool().unwrap_or(true);
806                let does_exist =
807                    actual.is_some() && !matches!(actual, Some(serde_json::Value::Null));
808                if does_exist != should_exist {
809                    return false;
810                }
811                continue;
812            }
813
814            // Direct value comparison
815            match (actual, expected) {
816                (Some(a), e) if a == e => continue,
817                (None, serde_json::Value::Null) => continue,
818                _ => return false,
819            }
820        }
821        true
822    }
823
824    fn get_nested_value<'a>(
825        &self,
826        context: &'a std::collections::HashMap<String, serde_json::Value>,
827        path: &str,
828    ) -> Option<&'a serde_json::Value> {
829        let parts: Vec<&str> = path.split('.').collect();
830        if parts.is_empty() {
831            return None;
832        }
833
834        let mut current: Option<&serde_json::Value> = context.get(parts[0]);
835
836        for part in &parts[1..] {
837            current = current.and_then(|v| {
838                if let serde_json::Value::Object(obj) = v {
839                    obj.get(*part)
840                } else {
841                    None
842                }
843            });
844        }
845
846        current
847    }
848
849    fn get_llm(&self, alias: Option<&str>) -> Result<Arc<dyn LLMProvider>> {
850        let registry = self
851            .llm_registry
852            .as_ref()
853            .ok_or_else(|| AgentError::Config("LLM registry not configured for process".into()))?;
854
855        match alias {
856            Some(name) => registry
857                .get(name)
858                .map_err(|e| AgentError::LLM(e.to_string())),
859            None => registry
860                .router()
861                .or_else(|_| registry.default())
862                .map_err(|e| AgentError::LLM(e.to_string())),
863        }
864    }
865}
866
867fn purpose_hint_for_stages(stages: &[ProcessStage]) -> ProcessPurposeHint {
868    for stage in stages {
869        let hint = process_purpose_hint_for_stage(stage);
870        if hint != ProcessPurposeHint::Other {
871            return hint;
872        }
873    }
874    ProcessPurposeHint::Other
875}
876
877// Returns explicit natural-language labels so multiword PII types remain unambiguous to router models.
878fn pii_type_prompt_label(pii_type: &PIIType) -> &'static str {
879    match pii_type {
880        PIIType::Email => "email address",
881        PIIType::Phone => "phone number",
882        PIIType::CreditCard => "credit card number",
883        PIIType::Ssn => "social security number",
884        PIIType::IpAddress => "IP address",
885        PIIType::Name => "person name",
886        PIIType::Address => "physical address",
887    }
888}
889
890fn process_purpose_hint_for_stage(stage: &ProcessStage) -> ProcessPurposeHint {
891    match stage {
892        ProcessStage::Detect(_) => ProcessPurposeHint::Detect,
893        ProcessStage::Extract(_) => ProcessPurposeHint::Extract,
894        ProcessStage::Validate(_) => ProcessPurposeHint::Validate,
895        ProcessStage::Sanitize(_) | ProcessStage::Transform(_) => ProcessPurposeHint::Transform,
896        ProcessStage::Conditional(config) => {
897            let then_hint = purpose_hint_for_stages(&config.config.then_stages);
898            if then_hint != ProcessPurposeHint::Other {
899                then_hint
900            } else {
901                purpose_hint_for_stages(&config.config.else_stages)
902            }
903        }
904        _ => ProcessPurposeHint::Other,
905    }
906}
907
908fn extract_json(response: &str) -> String {
909    let trimmed = response.trim();
910
911    if let Some(json) = trimmed.strip_prefix("```json")
912        && let Some(end) = json.find("```")
913    {
914        return json[..end].trim().to_string();
915    }
916
917    if let Some(fenced) = trimmed.strip_prefix("```")
918        && let Some(end) = fenced.find("```")
919    {
920        return fenced[..end].trim().to_string();
921    }
922
923    if let Some(start) = trimmed.find('{')
924        && let Some(end) = trimmed.rfind('}')
925    {
926        return trimmed[start..=end].to_string();
927    }
928
929    trimmed.to_string()
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935
936    #[test]
937    fn test_process_data_new() {
938        let data = ProcessData::new("test content");
939        assert_eq!(data.content, "test content");
940        assert_eq!(data.original, "test content");
941        assert!(data.context.is_empty());
942    }
943
944    #[test]
945    fn test_process_data_with_context() {
946        let data = ProcessData::new("test").with_context("key", serde_json::json!("value"));
947        assert!(data.context.contains_key("key"));
948    }
949
950    #[tokio::test]
951    async fn test_normalize_trim() {
952        let processor = ProcessProcessor::default();
953        let config = NormalizeConfig {
954            trim: true,
955            ..Default::default()
956        };
957        let data = ProcessData::new("  hello world  ");
958        let result = processor.execute_normalize(&config, data).await.unwrap();
959        assert_eq!(result.content, "hello world");
960    }
961
962    #[tokio::test]
963    async fn test_normalize_collapse_whitespace() {
964        let processor = ProcessProcessor::default();
965        let config = NormalizeConfig {
966            trim: true,
967            collapse_whitespace: true,
968            ..Default::default()
969        };
970        let data = ProcessData::new("hello    world\n\ntest");
971        let result = processor.execute_normalize(&config, data).await.unwrap();
972        assert_eq!(result.content, "hello world test");
973    }
974
975    #[tokio::test]
976    async fn test_normalize_lowercase() {
977        let processor = ProcessProcessor::default();
978        let config = NormalizeConfig {
979            lowercase: true,
980            ..Default::default()
981        };
982        let data = ProcessData::new("Hello World");
983        let result = processor.execute_normalize(&config, data).await.unwrap();
984        assert_eq!(result.content, "hello world");
985    }
986
987    #[tokio::test]
988    async fn test_validate_min_length_reject() {
989        let processor = ProcessProcessor::default();
990        let config = ValidateConfig {
991            rules: vec![ValidationRule::MinLength {
992                min_length: 10,
993                on_fail: ValidationAction {
994                    action: ValidationActionType::Reject,
995                    message: None,
996                },
997            }],
998            ..Default::default()
999        };
1000        let data = ProcessData::new("short");
1001        let result = processor.execute_validate(&config, data).await.unwrap();
1002        assert!(result.metadata.rejected);
1003    }
1004
1005    #[tokio::test]
1006    async fn test_validate_max_length_truncate() {
1007        let processor = ProcessProcessor::default();
1008        let config = ValidateConfig {
1009            rules: vec![ValidationRule::MaxLength {
1010                max_length: 5,
1011                on_fail: ValidationAction {
1012                    action: ValidationActionType::Truncate,
1013                    message: None,
1014                },
1015            }],
1016            ..Default::default()
1017        };
1018        let data = ProcessData::new("hello world");
1019        let result = processor.execute_validate(&config, data).await.unwrap();
1020        assert_eq!(result.content, "hello");
1021        assert!(!result.metadata.rejected);
1022    }
1023
1024    #[tokio::test]
1025    async fn test_format_simple_template() {
1026        let processor = ProcessProcessor::default();
1027        let config = FormatConfig {
1028            template: Some("Response: {{ response }}".to_string()),
1029            ..Default::default()
1030        };
1031        let data = ProcessData::new("Hello!");
1032        let result = processor.execute_format(&config, data).await.unwrap();
1033        assert_eq!(result.content, "Response: Hello!");
1034    }
1035
1036    #[test]
1037    fn test_extract_json() {
1038        assert_eq!(extract_json(r#"{"key": 1}"#), r#"{"key": 1}"#);
1039        assert_eq!(extract_json("```json\n{\"key\": 1}\n```"), r#"{"key": 1}"#);
1040        assert_eq!(extract_json("Some text {\"key\": 1} more"), r#"{"key": 1}"#);
1041    }
1042
1043    #[test]
1044    fn test_evaluate_condition_empty() {
1045        let processor = ProcessProcessor::default();
1046        let data = ProcessData::new("test");
1047        assert!(processor.evaluate_condition(&None, &data));
1048    }
1049
1050    #[test]
1051    fn test_evaluate_condition_simple_exists_true() {
1052        let processor = ProcessProcessor::default();
1053        let mut data = ProcessData::new("test");
1054        data.context.insert(
1055            "session".to_string(),
1056            serde_json::json!({ "user_name": "Alice" }),
1057        );
1058
1059        let mut map = std::collections::HashMap::new();
1060        map.insert(
1061            "session.user_name".to_string(),
1062            serde_json::json!({ "exists": true }),
1063        );
1064        let condition = ConditionExpr::Simple(map);
1065
1066        assert!(processor.evaluate_condition_expr(&condition, &data));
1067    }
1068
1069    #[test]
1070    fn test_evaluate_condition_simple_exists_false() {
1071        let processor = ProcessProcessor::default();
1072        let data = ProcessData::new("test");
1073
1074        let mut map = std::collections::HashMap::new();
1075        map.insert(
1076            "session.user_name".to_string(),
1077            serde_json::json!({ "exists": false }),
1078        );
1079        let condition = ConditionExpr::Simple(map);
1080
1081        assert!(processor.evaluate_condition_expr(&condition, &data));
1082    }
1083
1084    #[test]
1085    fn test_evaluate_condition_all() {
1086        let processor = ProcessProcessor::default();
1087        let mut data = ProcessData::new("test");
1088        data.context.insert(
1089            "session".to_string(),
1090            serde_json::json!({ "user_name": "Alice", "language": "en" }),
1091        );
1092
1093        let mut map1 = std::collections::HashMap::new();
1094        map1.insert(
1095            "session.user_name".to_string(),
1096            serde_json::json!({ "exists": true }),
1097        );
1098        let mut map2 = std::collections::HashMap::new();
1099        map2.insert(
1100            "session.language".to_string(),
1101            serde_json::json!({ "exists": true }),
1102        );
1103
1104        let condition = ConditionExpr::All {
1105            all: 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_any() {
1113        let processor = ProcessProcessor::default();
1114        let mut data = ProcessData::new("test");
1115        data.context.insert(
1116            "session".to_string(),
1117            serde_json::json!({ "tier": "premium" }),
1118        );
1119
1120        let mut map1 = std::collections::HashMap::new();
1121        map1.insert("session.tier".to_string(), serde_json::json!("premium"));
1122        let mut map2 = std::collections::HashMap::new();
1123        map2.insert("session.tier".to_string(), serde_json::json!("enterprise"));
1124
1125        let condition = ConditionExpr::Any {
1126            any: vec![ConditionExpr::Simple(map1), ConditionExpr::Simple(map2)],
1127        };
1128
1129        assert!(processor.evaluate_condition_expr(&condition, &data));
1130    }
1131
1132    #[test]
1133    fn test_evaluate_condition_value_match() {
1134        let processor = ProcessProcessor::default();
1135        let mut data = ProcessData::new("test");
1136        data.context.insert(
1137            "input".to_string(),
1138            serde_json::json!({ "sentiment": "negative" }),
1139        );
1140
1141        let mut map = std::collections::HashMap::new();
1142        map.insert("input.sentiment".to_string(), serde_json::json!("negative"));
1143        let condition = ConditionExpr::Simple(map);
1144
1145        assert!(processor.evaluate_condition_expr(&condition, &data));
1146    }
1147
1148    #[test]
1149    fn test_get_nested_value() {
1150        let processor = ProcessProcessor::default();
1151        let mut context = std::collections::HashMap::new();
1152        context.insert(
1153            "session".to_string(),
1154            serde_json::json!({ "user": { "name": "Alice" } }),
1155        );
1156
1157        let result = processor.get_nested_value(&context, "session.user.name");
1158        assert_eq!(result, Some(&serde_json::json!("Alice")));
1159
1160        let result = processor.get_nested_value(&context, "session.nonexistent");
1161        assert!(result.is_none());
1162    }
1163
1164    //
1165    // LLM-based stage tests (detect, extract, sanitize, transform, validate)
1166    //
1167    fn create_mock_registry(response: &str) -> Arc<ai_agents_llm::LLMRegistry> {
1168        use ai_agents_llm::mock::MockLLMProvider;
1169        let mut mock = MockLLMProvider::new("test");
1170        mock.set_response(response);
1171        let mut registry = ai_agents_llm::LLMRegistry::new();
1172        registry.register("default", std::sync::Arc::new(mock));
1173        registry.set_default("default");
1174        std::sync::Arc::new(registry)
1175    }
1176
1177    fn create_mock_registry_multi(responses: Vec<&str>) -> Arc<ai_agents_llm::LLMRegistry> {
1178        use ai_agents_llm::mock::MockLLMProvider;
1179        let mut mock = MockLLMProvider::new("test");
1180        mock.set_responses(responses.into_iter().map(String::from).collect(), true);
1181        let mut registry = ai_agents_llm::LLMRegistry::new();
1182        registry.register("default", std::sync::Arc::new(mock));
1183        registry.set_default("default");
1184        std::sync::Arc::new(registry)
1185    }
1186
1187    #[tokio::test]
1188    async fn test_detect_stage_language_sentiment() {
1189        let registry = create_mock_registry(
1190            r#"{"language": "ko", "sentiment": "positive", "intent": "greeting"}"#,
1191        );
1192        let config = ProcessConfig {
1193            input: vec![ProcessStage::Detect(DetectStage {
1194                id: Some("detect_test".to_string()),
1195                condition: None,
1196                config: DetectConfig {
1197                    llm: None,
1198                    detect: vec![DetectionType::Language, DetectionType::Sentiment],
1199                    intents: vec![IntentDefinition {
1200                        id: "greeting".to_string(),
1201                        description: "User says hello".to_string(),
1202                    }],
1203                    store_in_context: {
1204                        let mut m = std::collections::HashMap::new();
1205                        m.insert("language".to_string(), "input.language".to_string());
1206                        m.insert("sentiment".to_string(), "input.sentiment".to_string());
1207                        m
1208                    },
1209                },
1210            })],
1211            ..Default::default()
1212        };
1213        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1214        let result = processor.process_input("안녕하세요!").await.unwrap();
1215
1216        assert_eq!(
1217            result.context.get("input.language"),
1218            Some(&serde_json::json!("ko"))
1219        );
1220        assert_eq!(
1221            result.context.get("input.sentiment"),
1222            Some(&serde_json::json!("positive"))
1223        );
1224        assert!(
1225            result
1226                .metadata
1227                .stages_executed
1228                .contains(&"detect_test".to_string())
1229        );
1230    }
1231
1232    #[tokio::test]
1233    async fn test_extract_stage_entities() {
1234        let registry = create_mock_registry(r#"{"order_number": "ORD-12345", "urgency": "high"}"#);
1235        let config = ProcessConfig {
1236            input: vec![ProcessStage::Extract(ExtractStage {
1237                id: Some("extract_test".to_string()),
1238                condition: None,
1239                config: ExtractConfig {
1240                    llm: None,
1241                    schema: {
1242                        let mut m = std::collections::HashMap::new();
1243                        m.insert(
1244                            "order_number".to_string(),
1245                            FieldSchema {
1246                                field_type: FieldType::String,
1247                                description: Some("Order number".to_string()),
1248                                required: true,
1249                                values: vec![],
1250                            },
1251                        );
1252                        m.insert(
1253                            "urgency".to_string(),
1254                            FieldSchema {
1255                                field_type: FieldType::Enum,
1256                                description: Some("Urgency level".to_string()),
1257                                required: false,
1258                                values: vec![
1259                                    "low".to_string(),
1260                                    "medium".to_string(),
1261                                    "high".to_string(),
1262                                ],
1263                            },
1264                        );
1265                        m
1266                    },
1267                    store_in_context: Some("extracted".to_string()),
1268                },
1269            })],
1270            ..Default::default()
1271        };
1272        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1273        let result = processor
1274            .process_input("My order ORD-12345 is urgent!")
1275            .await
1276            .unwrap();
1277
1278        let extracted = result.context.get("extracted").unwrap();
1279        assert_eq!(extracted["order_number"], "ORD-12345");
1280        assert_eq!(extracted["urgency"], "high");
1281    }
1282
1283    #[test]
1284    fn test_pii_prompt_labels_are_unambiguous() {
1285        assert_eq!(
1286            pii_type_prompt_label(&PIIType::CreditCard),
1287            "credit card number"
1288        );
1289        assert_eq!(
1290            pii_type_prompt_label(&PIIType::Ssn),
1291            "social security number"
1292        );
1293        assert_eq!(pii_type_prompt_label(&PIIType::IpAddress), "IP address");
1294    }
1295
1296    #[tokio::test]
1297    async fn test_sanitize_stage_pii_masking() {
1298        let registry = create_mock_registry("Call me at ****-****-**** or email at ****@****.com");
1299        let config = ProcessConfig {
1300            input: vec![ProcessStage::Sanitize(SanitizeStage {
1301                id: Some("sanitize_test".to_string()),
1302                condition: None,
1303                config: SanitizeConfig {
1304                    llm: None,
1305                    pii: Some(PIISanitizeConfig {
1306                        action: PIIAction::Mask,
1307                        types: vec![PIIType::Phone, PIIType::Email],
1308                        mask_char: "*".to_string(),
1309                    }),
1310                    harmful: None,
1311                    remove: vec![],
1312                },
1313            })],
1314            ..Default::default()
1315        };
1316        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1317        let result = processor
1318            .process_input("Call me at 010-1234-5678 or email at user@example.com")
1319            .await
1320            .unwrap();
1321
1322        // LLM returns sanitized text
1323        assert!(result.content.contains("****"));
1324        assert!(!result.content.contains("010-1234-5678"));
1325        assert!(!result.content.contains("user@example.com"));
1326    }
1327
1328    #[tokio::test]
1329    async fn test_transform_stage_tone_adjustment() {
1330        let registry = create_mock_registry(
1331            "I understand your frustration. Let me help you resolve this issue right away.",
1332        );
1333        let config = ProcessConfig {
1334            output: vec![ProcessStage::Transform(TransformStage {
1335                id: Some("tone_test".to_string()),
1336                condition: None,
1337                config: TransformConfig {
1338                    llm: None,
1339                    prompt: Some("Rewrite to be more empathetic.".to_string()),
1340                    max_output_tokens: None,
1341                },
1342            })],
1343            ..Default::default()
1344        };
1345        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1346
1347        let input_context = std::collections::HashMap::new();
1348        let result = processor
1349            .process_output("Your request is being processed.", &input_context)
1350            .await
1351            .unwrap();
1352
1353        assert!(result.content.contains("understand"));
1354    }
1355
1356    #[tokio::test]
1357    async fn test_validate_stage_llm_criteria() {
1358        let registry = create_mock_registry(
1359            r#"{"passes": false, "score": 0.3, "issues": ["Response is too vague"]}"#,
1360        );
1361        let config = ProcessConfig {
1362            output: vec![ProcessStage::Validate(ValidateStage {
1363                id: Some("quality_test".to_string()),
1364                condition: None,
1365                config: ValidateConfig {
1366                    rules: vec![],
1367                    llm: None,
1368                    criteria: vec!["Response is specific and actionable".to_string()],
1369                    threshold: 0.7,
1370                    on_fail: ValidationFailAction {
1371                        action: ValidationFailType::Warn,
1372                        ..Default::default()
1373                    },
1374                },
1375            })],
1376            ..Default::default()
1377        };
1378        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1379
1380        let input_context = std::collections::HashMap::new();
1381        let result = processor
1382            .process_output("It depends.", &input_context)
1383            .await
1384            .unwrap();
1385
1386        // Should have a warning because score (0.3) < threshold (0.7)
1387        assert!(
1388            result.metadata.warnings.iter().any(|w| w.contains("vague")),
1389            "Expected warning about vague response, got: {:?}",
1390            result.metadata.warnings
1391        );
1392    }
1393
1394    #[tokio::test]
1395    async fn test_validate_stage_llm_criteria_reject() {
1396        let registry = create_mock_registry(
1397            r#"{"passes": false, "score": 0.2, "issues": ["Contains harmful content"]}"#,
1398        );
1399        let config = ProcessConfig {
1400            output: vec![ProcessStage::Validate(ValidateStage {
1401                id: Some("reject_test".to_string()),
1402                condition: None,
1403                config: ValidateConfig {
1404                    rules: vec![],
1405                    llm: None,
1406                    criteria: vec!["Response is safe".to_string()],
1407                    threshold: 0.7,
1408                    on_fail: ValidationFailAction {
1409                        action: ValidationFailType::Reject,
1410                        ..Default::default()
1411                    },
1412                },
1413            })],
1414            ..Default::default()
1415        };
1416        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1417
1418        let input_context = std::collections::HashMap::new();
1419        let result = processor
1420            .process_output("Dangerous content here.", &input_context)
1421            .await
1422            .unwrap();
1423
1424        assert!(result.metadata.rejected);
1425        assert!(
1426            result
1427                .metadata
1428                .rejection_reason
1429                .as_ref()
1430                .unwrap()
1431                .contains("harmful")
1432        );
1433    }
1434
1435    #[tokio::test]
1436    async fn test_full_input_pipeline_chain() {
1437        // normalize → detect → extract pipeline
1438        let registry = create_mock_registry_multi(vec![
1439            // detect response
1440            r#"{"language": "en", "sentiment": "neutral"}"#,
1441            // extract response
1442            r#"{"user_name": "Alice", "topic": "billing"}"#,
1443        ]);
1444        let config = ProcessConfig {
1445            input: vec![
1446                ProcessStage::Normalize(NormalizeStage {
1447                    id: Some("norm".to_string()),
1448                    condition: None,
1449                    config: NormalizeConfig {
1450                        trim: true,
1451                        collapse_whitespace: true,
1452                        ..Default::default()
1453                    },
1454                }),
1455                ProcessStage::Detect(DetectStage {
1456                    id: Some("detect".to_string()),
1457                    condition: None,
1458                    config: DetectConfig {
1459                        llm: None,
1460                        detect: vec![DetectionType::Language, DetectionType::Sentiment],
1461                        intents: vec![],
1462                        store_in_context: {
1463                            let mut m = std::collections::HashMap::new();
1464                            m.insert("language".to_string(), "input.language".to_string());
1465                            m
1466                        },
1467                    },
1468                }),
1469                ProcessStage::Extract(ExtractStage {
1470                    id: Some("extract".to_string()),
1471                    condition: None,
1472                    config: ExtractConfig {
1473                        llm: None,
1474                        schema: {
1475                            let mut m = std::collections::HashMap::new();
1476                            m.insert(
1477                                "user_name".to_string(),
1478                                FieldSchema {
1479                                    field_type: FieldType::String,
1480                                    description: Some("User name".to_string()),
1481                                    ..Default::default()
1482                                },
1483                            );
1484                            m
1485                        },
1486                        store_in_context: Some("entities".to_string()),
1487                    },
1488                }),
1489            ],
1490            ..Default::default()
1491        };
1492        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1493        let result = processor
1494            .process_input("  Hi, I'm   Alice and I have a billing question  ")
1495            .await
1496            .unwrap();
1497
1498        // Verify normalize ran
1499        assert_eq!(
1500            result.content,
1501            "Hi, I'm Alice and I have a billing question"
1502        );
1503
1504        // Verify detect stored context
1505        assert_eq!(
1506            result.context.get("input.language"),
1507            Some(&serde_json::json!("en"))
1508        );
1509
1510        // Verify extract stored context
1511        let entities = result.context.get("entities").unwrap();
1512        assert_eq!(entities["user_name"], "Alice");
1513
1514        // Verify all stages executed in order
1515        assert_eq!(
1516            result.metadata.stages_executed,
1517            vec!["norm", "detect", "extract"]
1518        );
1519    }
1520
1521    #[tokio::test]
1522    async fn test_conditional_stage_skips_on_false() {
1523        let registry = create_mock_registry(r#"{"language": "en"}"#);
1524        let config = ProcessConfig {
1525            input: vec![ProcessStage::Detect(DetectStage {
1526                id: Some("should_skip".to_string()),
1527                condition: Some(ConditionExpr::Simple({
1528                    let mut map = std::collections::HashMap::new();
1529                    map.insert("needs_detection".to_string(), serde_json::json!(true));
1530                    map
1531                })),
1532                config: DetectConfig {
1533                    llm: None,
1534                    detect: vec![DetectionType::Language],
1535                    ..Default::default()
1536                },
1537            })],
1538            ..Default::default()
1539        };
1540        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1541        let result = processor.process_input("Hello").await.unwrap();
1542
1543        // Stage should be skipped because "needs_detection" is not in context
1544        assert!(
1545            !result
1546                .metadata
1547                .stages_executed
1548                .contains(&"should_skip".to_string()),
1549            "Stage should have been skipped"
1550        );
1551    }
1552
1553    #[tokio::test]
1554    async fn test_stage_skipped_when_condition_false() {
1555        let config = ProcessConfig {
1556            input: vec![ProcessStage::Extract(ExtractStage {
1557                id: Some("skip_me".to_string()),
1558                condition: Some(ConditionExpr::Simple({
1559                    let mut map = std::collections::HashMap::new();
1560                    map.insert(
1561                        "session.user".to_string(),
1562                        serde_json::json!({ "exists": false }),
1563                    );
1564                    map
1565                })),
1566                config: ExtractConfig::default(),
1567            })],
1568            settings: ProcessSettings {
1569                debug: ProcessDebugConfig {
1570                    log_stages: true,
1571                    ..Default::default()
1572                },
1573                ..Default::default()
1574            },
1575            ..Default::default()
1576        };
1577        let processor = ProcessProcessor::new(config);
1578
1579        let mut data = ProcessData::new("test");
1580        data.context.insert(
1581            "session".to_string(),
1582            serde_json::json!({ "user": "Alice" }),
1583        );
1584
1585        let result = processor.process_input("test").await.unwrap();
1586        assert!(
1587            !result
1588                .metadata
1589                .stages_executed
1590                .contains(&"skip_me".to_string())
1591        );
1592    }
1593}