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