1use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::time::Instant;
8
9use ai_agents_core::{AgentError, ChatMessage, LLMProvider, Result};
10use ai_agents_llm::LLMRegistry;
11
12use super::config::*;
13
14#[derive(Debug, Clone, Default)]
15pub struct ProcessData {
16 pub content: String,
17 pub original: String,
18 pub context: HashMap<String, serde_json::Value>,
19 pub metadata: ProcessMetadata,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct ProcessMetadata {
24 pub stages_executed: Vec<String>,
25 pub timing: HashMap<String, u64>,
26 pub warnings: Vec<String>,
27 pub rejected: bool,
28 pub rejection_reason: Option<String>,
29}
30
31impl ProcessData {
32 pub fn new(content: impl Into<String>) -> Self {
33 let content = content.into();
34 Self {
35 original: content.clone(),
36 content,
37 context: HashMap::new(),
38 metadata: ProcessMetadata::default(),
39 }
40 }
41
42 pub fn with_context(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
43 self.context.insert(key.into(), value);
44 self
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ProcessPurposeHint {
51 Detect,
52 Extract,
53 Validate,
54 Transform,
55 Other,
56}
57
58pub type ProcessStageFuture<'a> = Pin<Box<dyn Future<Output = Result<ProcessData>> + Send + 'a>>;
60
61pub trait ProcessStageObserver: Send + Sync {
63 fn observe<'a>(
65 &'a self,
66 hint: ProcessPurposeHint,
67 future: ProcessStageFuture<'a>,
68 ) -> ProcessStageFuture<'a>;
69}
70
71pub struct ProcessProcessor {
73 config: ProcessConfig,
74 llm_registry: Option<Arc<LLMRegistry>>,
75 stage_observer: Option<Arc<dyn ProcessStageObserver>>,
76}
77
78impl std::fmt::Debug for ProcessProcessor {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("ProcessProcessor")
81 .field("config", &self.config)
82 .field("has_llm_registry", &self.llm_registry.is_some())
83 .field("has_stage_observer", &self.stage_observer.is_some())
84 .finish()
85 }
86}
87
88impl Default for ProcessProcessor {
89 fn default() -> Self {
90 Self::new(ProcessConfig::default())
91 }
92}
93
94impl ProcessProcessor {
95 pub fn new(config: ProcessConfig) -> Self {
96 Self {
97 config,
98 llm_registry: None,
99 stage_observer: None,
100 }
101 }
102
103 pub fn with_llm_registry(mut self, registry: Arc<LLMRegistry>) -> Self {
104 self.llm_registry = Some(registry);
105 self
106 }
107
108 pub fn with_stage_observer(mut self, observer: Arc<dyn ProcessStageObserver>) -> Self {
110 self.stage_observer = Some(observer);
111 self
112 }
113
114 pub fn input_purpose_hint(&self) -> ProcessPurposeHint {
116 purpose_hint_for_stages(&self.config.input)
117 }
118
119 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 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 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 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 => {} }
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 => {} }
570 }
571 }
572 }
573 }
574
575 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 let mut result = tmpl.clone();
666 result = result.replace("{{ response }}", &data.content);
667 result = result.replace("{{response}}", &data.content);
668
669 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 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 data.metadata
713 .warnings
714 .push(format!("API enrichment not yet implemented: {}", url));
715 return Ok(data);
716 }
717 EnrichSource::File { path, format } => {
718 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 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 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 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
877fn 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 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 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 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 let registry = create_mock_registry_multi(vec![
1439 r#"{"language": "en", "sentiment": "neutral"}"#,
1441 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 assert_eq!(
1500 result.content,
1501 "Hi, I'm Alice and I have a billing question"
1502 );
1503
1504 assert_eq!(
1506 result.context.get("input.language"),
1507 Some(&serde_json::json!("en"))
1508 );
1509
1510 let entities = result.context.get("entities").unwrap();
1512 assert_eq!(entities["user_name"], "Alice");
1513
1514 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 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}