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                    // 首个命中,或关键字严格更长(最长匹配优先)。
122                    if best_match.is_none_or(|(_, best_len)| len > best_len) {
123                        best_match = Some((dest, len));
124                    }
125                }
126            }
127        }
128        best_match.map(|(dest, _)| dest)
129    }
130
131    /// Select a route destination.
132    fn select_route(&self, input: &str) -> Result<Option<&RouteDestination>, ChainError> {
133        if let Some(dest) = self.route_by_keywords(input) {
134            return Ok(Some(dest));
135        }
136
137        Ok(None)
138    }
139
140    /// Route to a destination/default chain and invoke it, threading `config`
141    /// through `invoke_with_config` (never silently dropping it).
142    async fn route_and_invoke(
143        &self,
144        inputs: HashMap<String, Value>,
145        config: Option<RunnableConfig>,
146    ) -> Result<ChainResult, ChainError> {
147        self.validate_inputs(&inputs)?;
148
149        let input = inputs
150            .get(&self.input_key)
151            .and_then(|v| v.as_str())
152            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
153
154        if self.verbose {
155            println!("\n=== RouterChain execution ===");
156            println!("Input: {}", input);
157            println!("Route destination count: {}", self.destinations.len());
158        }
159
160        let route_result = self.select_route(input)?;
161
162        let chain = match route_result {
163            Some(dest) => {
164                if self.verbose {
165                    println!("Routed to: {} ({})", dest.name(), dest.description());
166                }
167                dest.chain()
168            }
169            None => {
170                if let Some(default) = &self.default_chain {
171                    if self.verbose {
172                        println!("No keyword match, using default Chain");
173                    }
174                    default
175                } else {
176                    return Err(ChainError::ExecutionError(
177                        "No matching route destination and no default Chain configured".to_string(),
178                    ));
179                }
180            }
181        };
182
183        let result = chain.invoke_with_config(inputs, config).await?;
184
185        if self.verbose {
186            println!("=== RouterChain complete ===\n");
187        }
188
189        Ok(result)
190    }
191
192    /// Route to a destination/default chain and stream it, threading `config`
193    /// through `stream_with_config`.
194    async fn route_and_stream(
195        &self,
196        inputs: HashMap<String, Value>,
197        config: Option<RunnableConfig>,
198    ) -> Result<ChainStream, ChainError> {
199        self.validate_inputs(&inputs)?;
200
201        let input = inputs
202            .get(&self.input_key)
203            .and_then(|v| v.as_str())
204            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
205
206        let route_result = self.select_route(input)?;
207
208        let chain = match route_result {
209            Some(dest) => dest.chain(),
210            None => self.default_chain.as_ref().ok_or_else(|| {
211                ChainError::ExecutionError(
212                    "No matching route destination and no default Chain configured".to_string(),
213                )
214            })?,
215        };
216
217        chain.stream_with_config(inputs, config).await
218    }
219}
220
221impl Default for RouterChain {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227#[async_trait]
228impl BaseChain for RouterChain {
229    fn input_keys(&self) -> Vec<&str> {
230        vec![&self.input_key]
231    }
232
233    fn output_keys(&self) -> Vec<&str> {
234        let mut seen = std::collections::HashSet::new();
235        let mut result: Vec<&str> = Vec::new();
236
237        for dest in &self.destinations {
238            for key in dest.chain().output_keys() {
239                if seen.insert(key.to_string()) {
240                    result.push(key);
241                }
242            }
243        }
244        if let Some(default) = &self.default_chain {
245            for key in default.output_keys() {
246                if seen.insert(key.to_string()) {
247                    result.push(key);
248                }
249            }
250        }
251
252        if result.is_empty() {
253            vec!["output"]
254        } else {
255            result
256        }
257    }
258
259    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
260        self.route_and_invoke(inputs, None).await
261    }
262
263    /// Execute the Chain with config propagation.
264    ///
265    /// Dispatches this chain's `on_chain_start`/`on_chain_end` and threads
266    /// `config` into the routed destination chain via `invoke_with_config`.
267    async fn invoke_with_config(
268        &self,
269        inputs: HashMap<String, Value>,
270        config: Option<RunnableConfig>,
271    ) -> Result<ChainResult, ChainError> {
272        run_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
273            self.route_and_invoke(inputs, config).await
274        })
275        .await
276    }
277
278    /// Stream execution for RouterChain.
279    ///
280    /// After routing (keyword matching), delegates to the selected chain's
281    /// `stream()` method.
282    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
283        self.route_and_stream(inputs, None).await
284    }
285
286    /// Stream execute the Chain with config propagation.
287    async fn stream_with_config(
288        &self,
289        inputs: HashMap<String, Value>,
290        config: Option<RunnableConfig>,
291    ) -> Result<ChainStream, ChainError> {
292        let output_key = self.output_keys().first().map(|k| (*k).to_string());
293        stream_chain_with_callbacks(
294            self.name(),
295            inputs,
296            config.clone(),
297            output_key,
298            |inputs| async move { self.route_and_stream(inputs, config).await },
299        )
300        .await
301    }
302
303    fn name(&self) -> &str {
304        &self.name
305    }
306}
307
308/// Structured routing decision returned by the LLM (P2-5).
309///
310/// Preferred source is the `route_to_destination` tool call's JSON arguments
311/// (`{"destination": ..., "reason": ...}`). `from_text` is a lenient fallback
312/// for providers without tool binding: it tries the same JSON object shape,
313/// then a bare destination name.
314#[derive(Debug, Clone, Deserialize)]
315pub(crate) struct RouteDecision {
316    /// Handler name — must match a configured destination.
317    pub destination: String,
318    /// Optional explanation for the choice (used in verbose diagnostics).
319    pub reason: Option<String>,
320}
321
322impl RouteDecision {
323    /// Parse a lenient text reply: JSON object first, bare name fallback.
324    pub(crate) fn from_text(text: &str) -> Self {
325        let trimmed = text.trim();
326        if let Ok(decision) = serde_json::from_str::<RouteDecision>(trimmed) {
327            return decision;
328        }
329        Self {
330            destination: trimmed.to_string(),
331            reason: None,
332        }
333    }
334}
335
336/// Tool definition that forces the routing LLM to emit a structured
337/// `{destination, reason}` object instead of free text.
338pub(crate) fn route_tool() -> ToolDefinition {
339    ToolDefinition::new(
340        "route_to_destination",
341        "根据用户输入选择最合适的处理 handler,返回目标名称与理由",
342    )
343    .with_parameters(json!({
344        "type": "object",
345        "properties": {
346            "destination": { "type": "string", "description": "目标 handler 名称" },
347            "reason": { "type": "string", "description": "选择该 handler 的理由" }
348        },
349        "required": ["destination"]
350    }))
351}