Skip to main content

laya/
router.rs

1//! Route a request to the Laya checkpoint best suited to it. Ported from `laya/router.py`.
2//!
3//! The routing *decision* (`route`) is pure and always available. Loading/running checkpoints
4//! (`load`, `predict`) requires the `model` feature.
5
6use crate::lang::{analyse, Analysis};
7use crate::{LayaError, Questions, Result, State};
8use serde_json::{json, Value};
9use std::collections::BTreeSet;
10
11/// The hub repo that bundles all three checkpoints.
12pub const BUNDLE_REPO: &str = "convaiinnovations/laya";
13
14/// A checkpoint location: `(repo_or_path, optional_subfolder)`.
15pub type ModelSpec = (String, Option<String>);
16
17fn default_models() -> Vec<(&'static str, ModelSpec)> {
18    vec![
19        ("english", (BUNDLE_REPO.to_string(), None)),
20        (
21            "multilingual",
22            (BUNDLE_REPO.to_string(), Some("multilingual".to_string())),
23        ),
24        (
25            "typed-decisions",
26            (BUNDLE_REPO.to_string(), Some("typed-decisions".to_string())),
27        ),
28    ]
29}
30
31fn standalone_models() -> Vec<(&'static str, ModelSpec)> {
32    vec![
33        ("english", ("convaiinnovations/laya".to_string(), None)),
34        (
35            "multilingual",
36            ("convaiinnovations/laya-multilingual".to_string(), None),
37        ),
38        (
39            "typed-decisions",
40            ("convaiinnovations/laya-typed-decisions".to_string(), None),
41        ),
42    ]
43}
44
45const CANONICAL: [&str; 3] = ["english", "multilingual", "typed-decisions"];
46
47fn aliases() -> &'static [(&'static str, &'static str)] {
48    &[
49        ("en", "english"),
50        ("laya", "english"),
51        ("default", "english"),
52        ("multi", "multilingual"),
53        ("ml", "multilingual"),
54        ("laya-multilingual", "multilingual"),
55        ("typed", "typed-decisions"),
56        ("typed_decisions", "typed-decisions"),
57        ("laya-typed-decisions", "typed-decisions"),
58        ("decisions", "typed-decisions"),
59    ]
60}
61
62/// Question-id signatures of the four typed-decisions workflows.
63fn typed_decision_workflows() -> &'static [(&'static str, &'static [&'static str])] {
64    &[
65        (
66            "agent_trace_observability",
67            &["action", "needs_review", "outcome", "risk", "urgency"],
68        ),
69        (
70            "customer_service",
71            &["action", "category", "churn_risk", "needs_human", "urgency"],
72        ),
73        (
74            "invoice_processing",
75            &[
76                "discrepancy_severity",
77                "disposition",
78                "duplicate",
79                "matches_order",
80                "urgency",
81            ],
82        ),
83        (
84            "security_incidents",
85            &[
86                "credential_compromise",
87                "disposition",
88                "severity",
89                "true_positive",
90                "urgency",
91            ],
92        ),
93    ]
94}
95
96const ENGLISH_SUBTAGS: [&str; 3] = ["en", "eng", "english"];
97
98/// Python `repr()` of a string: single quotes unless the string contains a single (but not
99/// double) quote. Used so routing `reason` strings match the Python `%r` formatting exactly.
100fn py_repr(s: &str) -> String {
101    let quote = if s.contains('\'') && !s.contains('"') {
102        '"'
103    } else {
104        '\''
105    };
106    let mut out = String::new();
107    out.push(quote);
108    for c in s.chars() {
109        match c {
110            '\\' => out.push_str("\\\\"),
111            '\n' => out.push_str("\\n"),
112            '\r' => out.push_str("\\r"),
113            '\t' => out.push_str("\\t"),
114            c if c == quote => {
115                out.push('\\');
116                out.push(c);
117            }
118            c => out.push(c),
119        }
120    }
121    out.push(quote);
122    out
123}
124
125fn repo_str(spec: &ModelSpec) -> String {
126    match &spec.1 {
127        Some(sub) => format!("{}/{}", spec.0, sub),
128        None => spec.0.clone(),
129    }
130}
131
132/// Normalise a model name (accepting aliases) to a canonical checkpoint key.
133pub fn normalise_name(name: &str) -> Result<String> {
134    let key = name.trim().to_lowercase();
135    let key = aliases()
136        .iter()
137        .find(|(a, _)| *a == key)
138        .map(|(_, v)| v.to_string())
139        .unwrap_or(key);
140    if CANONICAL.contains(&key.as_str()) {
141        Ok(key)
142    } else {
143        let mut alias_names: Vec<&str> = aliases().iter().map(|(a, _)| *a).collect();
144        alias_names.sort();
145        Err(LayaError::Config(format!(
146            "unknown model {:?}; choose one of {:?} (or an alias: {:?})",
147            name,
148            {
149                let mut c = CANONICAL.to_vec();
150                c.sort();
151                c
152            },
153            alias_names
154        )))
155    }
156}
157
158/// Name of the typed-decisions workflow whose question ids these are (exact id-set match), else None.
159pub fn match_typed_decisions_workflow(questions: &Questions) -> Option<String> {
160    let ids: BTreeSet<&str> = questions.keys().map(|s| s.as_str()).collect();
161    for (wf, sig) in typed_decision_workflows() {
162        let sig_set: BTreeSet<&str> = sig.iter().copied().collect();
163        if ids == sig_set {
164            return Some((*wf).to_string());
165        }
166    }
167    None
168}
169
170/// `Some(true/false)` for a language code, or `None` when the code identifies nothing.
171/// Accepts `"en"`, `"EN"`, `"en-US"`, `"en_US"`, `"en_US.UTF-8"`.
172pub fn english_from_code(value: Option<&str>) -> Option<bool> {
173    let value = value?;
174    let code = value.trim().to_lowercase();
175    if code.is_empty() {
176        return None;
177    }
178    let code = code.split('.').next().unwrap_or(""); // en_US.UTF-8 -> en_US
179    let primary = code.replace('_', "-");
180    let primary = primary.split('-').next().unwrap_or(""); // en_US -> en
181    if primary.is_empty() {
182        return None;
183    }
184    Some(ENGLISH_SUBTAGS.contains(&primary))
185}
186
187/// The routing outcome: which checkpoint, why, and what was detected.
188#[derive(Debug, Clone)]
189pub struct RouteDecision {
190    pub model: String,
191    pub repo: String,
192    pub reason: String,
193    pub detection: Option<Analysis>,
194    pub workflow: Option<String>,
195}
196
197impl RouteDecision {
198    /// Serialise like the Python `RouteDecision` dict (used for the `routing` key of a result).
199    pub fn to_json(&self) -> Value {
200        json!({
201            "model": self.model,
202            "repo": self.repo,
203            "reason": self.reason,
204            "detection": self.detection.as_ref().map(analysis_to_json),
205            "workflow": self.workflow,
206        })
207    }
208}
209
210fn analysis_to_json(a: &Analysis) -> Value {
211    json!({
212        "script": a.script,
213        "script_profile": a.script_profile.iter().map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>(),
214        "language": a.language,
215        "is_english": a.is_english,
216        "language_undecided": a.language_undecided,
217        "diacritic_rate": a.diacritic_rate,
218        "non_latin_fraction": a.non_latin_fraction,
219    })
220}
221
222/// Per-call routing hints (mirror the keyword args of Python `Router.route`).
223#[derive(Debug, Clone, Default)]
224pub struct RouteHints<'a> {
225    pub model: Option<&'a str>,
226    pub task: Option<&'a str>,
227    pub lang: Option<&'a str>,
228    /// A language code hint (the callable form of Python `lang_guess` is not modelled here).
229    pub lang_guess: Option<&'a str>,
230}
231
232/// Options for constructing a [`Router`].
233#[derive(Debug, Clone)]
234pub struct RouterOptions {
235    pub device: Option<String>,
236    pub token: Option<String>,
237    pub max_loaded: usize,
238    pub default: String,
239    pub auto_task_detection: bool,
240    pub standalone_repos: bool,
241    /// A language-code hint applied to every request (checked before built-in detection).
242    pub lang_guess: Option<String>,
243}
244
245impl Default for RouterOptions {
246    fn default() -> Self {
247        RouterOptions {
248            device: None,
249            token: std::env::var("HF_TOKEN").ok(),
250            max_loaded: 2,
251            default: "english".to_string(),
252            auto_task_detection: false,
253            standalone_repos: false,
254            lang_guess: None,
255        }
256    }
257}
258
259/// Lazily loads Laya checkpoints and sends each request to the right one.
260pub struct Router {
261    models: indexmap::IndexMap<String, ModelSpec>,
262    #[allow(dead_code)]
263    device: Option<String>,
264    #[allow(dead_code)]
265    token: Option<String>,
266    default: String,
267    auto_task_detection: bool,
268    lang_guess: Option<String>,
269    #[cfg(feature = "model")]
270    cache: std::sync::Mutex<ModelCache>,
271}
272
273#[cfg(feature = "model")]
274struct ModelCache {
275    agents: std::collections::HashMap<String, std::sync::Arc<crate::agent::Agent>>,
276    order: Vec<String>, // least-recently-used first
277    max_loaded: usize,
278}
279
280impl Router {
281    pub fn new(opts: RouterOptions) -> Result<Router> {
282        let base = if opts.standalone_repos {
283            standalone_models()
284        } else {
285            default_models()
286        };
287        let models: indexmap::IndexMap<String, ModelSpec> =
288            base.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
289        let default = normalise_name(&opts.default)?;
290        Ok(Router {
291            models,
292            device: opts.device,
293            token: opts.token,
294            default,
295            auto_task_detection: opts.auto_task_detection,
296            lang_guess: opts.lang_guess,
297            #[cfg(feature = "model")]
298            cache: std::sync::Mutex::new(ModelCache {
299                agents: std::collections::HashMap::new(),
300                order: Vec::new(),
301                max_loaded: opts.max_loaded.max(1),
302            }),
303        })
304    }
305
306    /// Convenience constructor with default options.
307    pub fn with_defaults() -> Result<Router> {
308        Router::new(RouterOptions::default())
309    }
310
311    fn spec(&self, key: &str) -> &ModelSpec {
312        self.models.get(key).expect("normalised key must exist")
313    }
314
315    fn resolve_hint(&self, hint: Option<&str>) -> Option<bool> {
316        english_from_code(hint)
317    }
318
319    /// Decide which checkpoint to use, without loading or running anything.
320    pub fn route(
321        &self,
322        state: &State,
323        questions: Option<&Questions>,
324        hints: &RouteHints,
325    ) -> Result<RouteDecision> {
326        let empty = Questions::new();
327        let questions = questions.unwrap_or(&empty);
328
329        if let Some(model) = hints.model {
330            let key = normalise_name(model)?;
331            return Ok(RouteDecision {
332                repo: repo_str(self.spec(&key)),
333                model: key,
334                reason: format!("explicit model={}", py_repr(model)),
335                detection: None,
336                workflow: None,
337            });
338        }
339
340        if let Some(task) = hints.task {
341            let normalized = if task.to_lowercase().replace('-', "_") == "typed_decisions" {
342                "typed-decisions"
343            } else {
344                task
345            };
346            let key = normalise_name(normalized)?;
347            return Ok(RouteDecision {
348                repo: repo_str(self.spec(&key)),
349                model: key,
350                reason: format!("explicit task={}", py_repr(task)),
351                detection: None,
352                workflow: None,
353            });
354        }
355
356        let workflow = match_typed_decisions_workflow(questions);
357        if let Some(wf) = &workflow {
358            if self.auto_task_detection {
359                return Ok(RouteDecision {
360                    model: "typed-decisions".to_string(),
361                    repo: repo_str(self.spec("typed-decisions")),
362                    reason: format!(
363                        "question ids match the {} typed-decisions workflow",
364                        py_repr(wf)
365                    ),
366                    detection: None,
367                    workflow: workflow.clone(),
368                });
369            }
370        }
371
372        if let Some(lang) = hints.lang {
373            // An explicit `lang` is decisive only when the code names a language. Blank or
374            // whitespace resolves to no usable hint, so it falls through to lang_guess/detection
375            // exactly as an abstaining hint does; real English/non-English codes still route now.
376            if let Some(resolved) = english_from_code(Some(lang)) {
377                let key = if resolved { "english" } else { "multilingual" };
378                return Ok(RouteDecision {
379                    model: key.to_string(),
380                    repo: repo_str(self.spec(key)),
381                    reason: format!("explicit lang={}", py_repr(lang)),
382                    detection: None,
383                    workflow,
384                });
385            }
386        }
387
388        // Per-call hint first, then the one installed on the Router.
389        for (source, hint) in [
390            ("lang_guess", hints.lang_guess),
391            ("Router(lang_guess=...)", self.lang_guess.as_deref()),
392        ] {
393            if let Some(resolved) = self.resolve_hint(hint) {
394                let key = if resolved { "english" } else { "multilingual" };
395                return Ok(RouteDecision {
396                    model: key.to_string(),
397                    repo: repo_str(self.spec(key)),
398                    reason: format!(
399                        "{}: the caller identified this as {} text",
400                        source,
401                        if resolved { "English" } else { "non-English" }
402                    ),
403                    detection: None,
404                    workflow,
405                });
406            }
407        }
408
409        let det = analyse(state);
410        let (key, reason): (String, String) = if det.script == "unknown" {
411            (
412                self.default.clone(),
413                format!(
414                    "no letters detected in state; using default ({})",
415                    self.default
416                ),
417            )
418        } else if det.script != "latin" {
419            (
420                "multilingual".to_string(),
421                format!(
422                    "non-Latin script ({}, {:.0}% of letters); the English checkpoint cannot read it",
423                    det.script,
424                    100.0 * det.non_latin_fraction
425                ),
426            )
427        } else if !det.is_english {
428            let reason = if let Some(l) = &det.language {
429                format!(
430                    "Latin script but language looks like {}, not English",
431                    py_repr(l)
432                )
433            } else {
434                format!(
435                    "Latin script, language not identified but {:.0}% non-English letters; not safe for the English checkpoint",
436                    100.0 * det.diacritic_rate
437                )
438            };
439            ("multilingual".to_string(), reason)
440        } else if det.language_undecided {
441            (
442                self.default.clone(),
443                format!(
444                    "Latin script, language not identified and no non-English letters; using default ({})",
445                    self.default
446                ),
447            )
448        } else {
449            ("english".to_string(), "English Latin text".to_string())
450        };
451
452        Ok(RouteDecision {
453            repo: repo_str(self.spec(&key)),
454            model: key,
455            reason,
456            detection: Some(det),
457            workflow,
458        })
459    }
460}
461
462// --------------------------------------------------------------- model-backed loading
463#[cfg(feature = "model")]
464mod loading {
465    use super::*;
466    use crate::agent::{Agent, SystemOneResult};
467    use std::sync::Arc;
468
469    impl Router {
470        /// Return the Agent for `name`, downloading and building it on first use.
471        pub fn load(&self, name: &str) -> Result<Arc<Agent>> {
472            let key = normalise_name(name)?;
473            {
474                let mut cache = self.cache.lock().unwrap();
475                if let Some(agent) = cache.agents.get(&key).cloned() {
476                    touch(&mut cache, &key);
477                    return Ok(agent);
478                }
479            }
480            // Build outside the lock (download can be slow); tolerate a concurrent winner.
481            let (repo, sub) = self.spec(&key).clone();
482            let agent = Arc::new(Agent::load(
483                &repo,
484                crate::agent::LoadOptions {
485                    device: self.device.clone(),
486                    token: self.token.clone(),
487                    subfolder: sub,
488                },
489            )?);
490            let mut cache = self.cache.lock().unwrap();
491            let agent = cache.agents.entry(key.clone()).or_insert(agent).clone();
492            if !cache.order.contains(&key) {
493                cache.order.push(key.clone());
494            }
495            evict(&mut cache);
496            Ok(agent)
497        }
498
499        /// Pre-build the given checkpoints (or all configured ones) so no request pays a load.
500        /// Raises `max_loaded` to fit both the requested and already-resident checkpoints.
501        pub fn preload(&self, names: Option<&[&str]>) -> Result<()> {
502            let names: Vec<String> = match names {
503                Some(ns) => ns
504                    .iter()
505                    .map(|n| normalise_name(n))
506                    .collect::<Result<_>>()?,
507                None => self.models.keys().cloned().collect(),
508            };
509            {
510                let mut cache = self.cache.lock().unwrap();
511                let want: std::collections::HashSet<&String> =
512                    names.iter().chain(cache.order.iter()).collect();
513                cache.max_loaded = cache.max_loaded.max(want.len());
514            }
515            for n in &names {
516                self.load(n)?;
517            }
518            Ok(())
519        }
520
521        /// Free one checkpoint, or all of them.
522        pub fn unload(&self, name: Option<&str>) -> Result<()> {
523            let mut cache = self.cache.lock().unwrap();
524            match name {
525                None => {
526                    cache.agents.clear();
527                    cache.order.clear();
528                }
529                Some(n) => {
530                    let key = normalise_name(n)?;
531                    cache.agents.remove(&key);
532                    cache.order.retain(|k| k != &key);
533                }
534            }
535            Ok(())
536        }
537
538        /// The currently loaded checkpoints (least-recently-used first).
539        pub fn loaded(&self) -> Vec<String> {
540            self.cache.lock().unwrap().order.clone()
541        }
542
543        /// Route, then answer every question in one forward pass on the chosen checkpoint.
544        pub fn predict(
545            &self,
546            state: &State,
547            questions: &Questions,
548            hints: &RouteHints,
549        ) -> Result<SystemOneResult> {
550            let decision = self.route(state, Some(questions), hints)?;
551            let agent = self.load(&decision.model)?;
552            let mut result = agent.system_one(state, questions)?;
553            result.routing = Some(decision.to_json());
554            Ok(result)
555        }
556    }
557
558    fn touch(cache: &mut ModelCache, key: &str) {
559        cache.order.retain(|k| k != key);
560        cache.order.push(key.to_string());
561    }
562
563    fn evict(cache: &mut ModelCache) {
564        while cache.order.len() > cache.max_loaded {
565            let victim = cache.order.remove(0);
566            cache.agents.remove(&victim);
567        }
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use serde_json::json;
575
576    fn de_state() -> Value {
577        json!({ "text": "Mein Konto wurde zweimal belastet" })
578    }
579
580    #[test]
581    fn explicit_lang_code_routes() {
582        let r = Router::with_defaults().unwrap();
583        let en = r
584            .route(
585                &de_state(),
586                None,
587                &RouteHints {
588                    lang: Some("en"),
589                    ..Default::default()
590                },
591            )
592            .unwrap();
593        assert_eq!(en.model, "english");
594        assert!(en.reason.contains("explicit lang"));
595
596        let de = r
597            .route(
598                &de_state(),
599                None,
600                &RouteHints {
601                    lang: Some("de"),
602                    ..Default::default()
603                },
604            )
605            .unwrap();
606        assert_eq!(de.model, "multilingual");
607    }
608
609    #[test]
610    fn blank_lang_falls_through_to_detection() {
611        // A blank/whitespace explicit lang names no language, so it must not decide routing;
612        // detection sees German and routes multilingual (upstream #292).
613        let r = Router::with_defaults().unwrap();
614        for blank in ["", "   ", "\t"] {
615            let d = r
616                .route(
617                    &de_state(),
618                    None,
619                    &RouteHints {
620                        lang: Some(blank),
621                        ..Default::default()
622                    },
623                )
624                .unwrap();
625            assert_eq!(
626                d.model, "multilingual",
627                "blank lang {blank:?} should fall through"
628            );
629            assert!(
630                !d.reason.contains("explicit lang"),
631                "blank lang {blank:?} must not route as explicit"
632            );
633        }
634    }
635}