Skip to main content

soothe_client/appkit/
classifier.rs

1//! Event → deliverable / streaming / terminal classification.
2
3use serde_json::Value;
4
5use crate::intent_hints::DEFAULT_DELIVERABLE_PHASES;
6use crate::stream_terminal::{is_turn_end_custom_data, STREAM_END};
7
8/// Classifies turn chunks for product backends.
9#[derive(Debug, Clone)]
10pub struct EventClassifier {
11    /// Deliverable phase names.
12    pub deliverable_phases: Vec<String>,
13    /// Treat status idle as complete.
14    pub treat_status_idle_as_complete: bool,
15}
16
17impl Default for EventClassifier {
18    fn default() -> Self {
19        Self {
20            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
21                .iter()
22                .map(|s| (*s).to_string())
23                .collect(),
24            treat_status_idle_as_complete: false,
25        }
26    }
27}
28
29impl EventClassifier {
30    /// Create with defaults.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// True when custom data is a deliverable phase.
36    pub fn is_deliverable(&self, mode: &str, data: &Value) -> bool {
37        if mode != "custom" {
38            return false;
39        }
40        let phase = data
41            .get("phase")
42            .or_else(|| data.get("type"))
43            .and_then(|v| v.as_str())
44            .unwrap_or("");
45        self.deliverable_phases.iter().any(|p| p == phase)
46    }
47
48    /// True when chunk is a terminal custom.
49    pub fn is_terminal(&self, mode: &str, data: &Value) -> bool {
50        mode == "custom" && is_turn_end_custom_data(data)
51    }
52
53    /// Extract text from a messages-mode chunk when possible.
54    pub fn extract_text(&self, mode: &str, data: &Value) -> Option<String> {
55        if mode == "messages" {
56            if let Some(arr) = data.as_array() {
57                for item in arr {
58                    if let Some(c) = item.get("content").and_then(|v| v.as_str()) {
59                        if !c.is_empty() {
60                            return Some(c.to_string());
61                        }
62                    }
63                    if let Some(c) = item.get("text").and_then(|v| v.as_str()) {
64                        if !c.is_empty() {
65                            return Some(c.to_string());
66                        }
67                    }
68                }
69            }
70            if let Some(c) = data.get("content").and_then(|v| v.as_str()) {
71                return Some(c.to_string());
72            }
73        }
74        if mode == "custom" {
75            if data.get("type").and_then(|v| v.as_str()) == Some(STREAM_END) {
76                return None;
77            }
78            if let Some(c) = data
79                .get("content")
80                .or_else(|| data.get("text"))
81                .and_then(|v| v.as_str())
82            {
83                return Some(c.to_string());
84            }
85        }
86        None
87    }
88}