Skip to main content

lc_chains/router_chain/
base.rs

1// lc-chains/src/router_chain/base.rs
2//! Keyword-based [`RouterChain`] and shared routing helpers.
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use lc_core::runnables::RunnableConfig;
9use lc_core::tools::ToolDefinition;
10use serde::Deserialize;
11use serde_json::json;
12use serde_json::Value;
13
14use crate::base::{
15    run_chain_with_callbacks, stream_chain_with_callbacks, BaseChain, ChainError, ChainResult,
16    ChainStream,
17};
18
19use super::destination::RouteDestination;
20
21/// Router Chain
22///
23/// Automatically routes to different Chains based on input content.
24pub struct RouterChain {
25    /// Route destination list.
26    destinations: Vec<RouteDestination>,
27
28    /// Default Chain (used when no match is found).
29    default_chain: Option<Arc<dyn BaseChain>>,
30
31    /// Input key name.
32    input_key: String,
33
34    /// Chain name.
35    name: String,
36
37    /// Whether to print verbose information.
38    verbose: bool,
39}
40
41impl RouterChain {
42    /// Create a new empty [`RouterChain`].
43    pub fn new() -> Self {
44        Self {
45            destinations: Vec::new(),
46            default_chain: None,
47            input_key: "input".to_string(),
48            name: "router_chain".to_string(),
49            verbose: false,
50        }
51    }
52
53    /// Add a route destination.
54    pub fn add_route(
55        mut self,
56        name: impl Into<String>,
57        description: impl Into<String>,
58        chain: Arc<dyn BaseChain>,
59    ) -> Self {
60        self.destinations
61            .push(RouteDestination::new(name, description, chain));
62        self
63    }
64
65    /// Add a route destination with a keyword list for keyword-based routing.
66    pub fn add_route_with_keywords(
67        mut self,
68        name: impl Into<String>,
69        description: impl Into<String>,
70        chain: Arc<dyn BaseChain>,
71        keywords: Vec<&str>,
72    ) -> Self {
73        self.destinations
74            .push(RouteDestination::new(name, description, chain).with_keywords(keywords));
75        self
76    }
77
78    /// Set the default chain used when no route matches.
79    pub fn with_default(mut self, chain: Arc<dyn BaseChain>) -> Self {
80        self.default_chain = Some(chain);
81        self
82    }
83
84    /// Set the input key.
85    pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
86        self.input_key = key.into();
87        self
88    }
89
90    /// Set the chain name.
91    pub fn with_name(mut self, name: impl Into<String>) -> Self {
92        self.name = name.into();
93        self
94    }
95
96    /// Set verbose mode.
97    pub fn with_verbose(mut self, verbose: bool) -> Self {
98        self.verbose = verbose;
99        self
100    }
101
102    /// Get the route destinations.
103    pub fn destinations(&self) -> &[RouteDestination] {
104        &self.destinations
105    }
106
107    /// Get the default chain, if set.
108    pub fn default_chain(&self) -> Option<&Arc<dyn BaseChain>> {
109        self.default_chain.as_ref()
110    }
111
112    /// Keyword-based routing.
113    ///
114    /// Longest-match-first instead of first-match-wins.
115    fn route_by_keywords(&self, input: &str) -> Option<&RouteDestination> {
116        let mut best_match: Option<(&RouteDestination, usize)> = None;
117        for dest in &self.destinations {
118            for keyword in dest.keywords() {
119                if input.contains(keyword) {
120                    let len = keyword.len();
121                    if best_match.is_none() || len > best_match.unwrap().1 {
122                        best_match = Some((dest, len));
123                    }
124                }
125            }
126        }
127        best_match.map(|(dest, _)| dest)
128    }
129
130    /// Select a route destination.
131    fn select_route(&self, input: &str) -> Result<Option<&RouteDestination>, ChainError> {
132        if let Some(dest) = self.route_by_keywords(input) {
133            return Ok(Some(dest));
134        }
135
136        Ok(None)
137    }
138
139    /// Route to a destination/default chain and invoke it, threading `config`
140    /// through `invoke_with_config` (never silently dropping it).
141    async fn route_and_invoke(
142        &self,
143        inputs: HashMap<String, Value>,
144        config: Option<RunnableConfig>,
145    ) -> Result<ChainResult, ChainError> {
146        self.validate_inputs(&inputs)?;
147
148        let input = inputs
149            .get(&self.input_key)
150            .and_then(|v| v.as_str())
151            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
152
153        if self.verbose {
154            println!("\n=== RouterChain execution ===");
155            println!("Input: {}", input);
156            println!("Route destination count: {}", self.destinations.len());
157        }
158
159        let route_result = self.select_route(input)?;
160
161        let chain = match route_result {
162            Some(dest) => {
163                if self.verbose {
164                    println!("Routed to: {} ({})", dest.name(), dest.description());
165                }
166                dest.chain()
167            }
168            None => {
169                if let Some(default) = &self.default_chain {
170                    if self.verbose {
171                        println!("No keyword match, using default Chain");
172                    }
173                    default
174                } else {
175                    return Err(ChainError::ExecutionError(
176                        "No matching route destination and no default Chain configured".to_string(),
177                    ));
178                }
179            }
180        };
181
182        let result = chain.invoke_with_config(inputs, config).await?;
183
184        if self.verbose {
185            println!("=== RouterChain complete ===\n");
186        }
187
188        Ok(result)
189    }
190
191    /// Route to a destination/default chain and stream it, threading `config`
192    /// through `stream_with_config`.
193    async fn route_and_stream(
194        &self,
195        inputs: HashMap<String, Value>,
196        config: Option<RunnableConfig>,
197    ) -> Result<ChainStream, ChainError> {
198        self.validate_inputs(&inputs)?;
199
200        let input = inputs
201            .get(&self.input_key)
202            .and_then(|v| v.as_str())
203            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
204
205        let route_result = self.select_route(input)?;
206
207        let chain = match route_result {
208            Some(dest) => dest.chain(),
209            None => self.default_chain.as_ref().ok_or_else(|| {
210                ChainError::ExecutionError(
211                    "No matching route destination and no default Chain configured".to_string(),
212                )
213            })?,
214        };
215
216        chain.stream_with_config(inputs, config).await
217    }
218}
219
220impl Default for RouterChain {
221    fn default() -> Self {
222        Self::new()
223    }
224}
225
226#[async_trait]
227impl BaseChain for RouterChain {
228    fn input_keys(&self) -> Vec<&str> {
229        vec![&self.input_key]
230    }
231
232    fn output_keys(&self) -> Vec<&str> {
233        let mut seen = std::collections::HashSet::new();
234        let mut result: Vec<&str> = Vec::new();
235
236        for dest in &self.destinations {
237            for key in dest.chain().output_keys() {
238                if seen.insert(key.to_string()) {
239                    result.push(key);
240                }
241            }
242        }
243        if let Some(default) = &self.default_chain {
244            for key in default.output_keys() {
245                if seen.insert(key.to_string()) {
246                    result.push(key);
247                }
248            }
249        }
250
251        if result.is_empty() {
252            vec!["output"]
253        } else {
254            result
255        }
256    }
257
258    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
259        self.route_and_invoke(inputs, None).await
260    }
261
262    /// Execute the Chain with config propagation.
263    ///
264    /// Dispatches this chain's `on_chain_start`/`on_chain_end` and threads
265    /// `config` into the routed destination chain via `invoke_with_config`.
266    async fn invoke_with_config(
267        &self,
268        inputs: HashMap<String, Value>,
269        config: Option<RunnableConfig>,
270    ) -> Result<ChainResult, ChainError> {
271        run_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
272            self.route_and_invoke(inputs, config).await
273        })
274        .await
275    }
276
277    /// Stream execution for RouterChain.
278    ///
279    /// After routing (keyword matching), delegates to the selected chain's
280    /// `stream()` method.
281    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
282        self.route_and_stream(inputs, None).await
283    }
284
285    /// Stream execute the Chain with config propagation.
286    async fn stream_with_config(
287        &self,
288        inputs: HashMap<String, Value>,
289        config: Option<RunnableConfig>,
290    ) -> Result<ChainStream, ChainError> {
291        stream_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
292            self.route_and_stream(inputs, config).await
293        })
294        .await
295    }
296
297    fn name(&self) -> &str {
298        &self.name
299    }
300}
301
302/// Structured routing decision returned by the LLM (P2-5).
303///
304/// Preferred source is the `route_to_destination` tool call's JSON arguments
305/// (`{"destination": ..., "reason": ...}`). `from_text` is a lenient fallback
306/// for providers without tool binding: it tries the same JSON object shape,
307/// then a bare destination name.
308#[derive(Debug, Clone, Deserialize)]
309pub(crate) struct RouteDecision {
310    /// Handler name — must match a configured destination.
311    pub destination: String,
312    /// Optional explanation for the choice (used in verbose diagnostics).
313    pub reason: Option<String>,
314}
315
316impl RouteDecision {
317    /// Parse a lenient text reply: JSON object first, bare name fallback.
318    pub(crate) fn from_text(text: &str) -> Self {
319        let trimmed = text.trim();
320        if let Ok(decision) = serde_json::from_str::<RouteDecision>(trimmed) {
321            return decision;
322        }
323        Self {
324            destination: trimmed.to_string(),
325            reason: None,
326        }
327    }
328}
329
330/// Tool definition that forces the routing LLM to emit a structured
331/// `{destination, reason}` object instead of free text.
332pub(crate) fn route_tool() -> ToolDefinition {
333    ToolDefinition::new(
334        "route_to_destination",
335        "根据用户输入选择最合适的处理 handler,返回目标名称与理由",
336    )
337    .with_parameters(json!({
338        "type": "object",
339        "properties": {
340            "destination": { "type": "string", "description": "目标 handler 名称" },
341            "reason": { "type": "string", "description": "选择该 handler 的理由" }
342        },
343        "required": ["destination"]
344    }))
345}