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/// Codes that are valid `$LANG` values but name no language, so they say nothing about the state.
99/// `c`/`posix`/`c.utf-8` are what minimal images ship; the ISO 639-2 special codes `und`
100/// (undetermined), `zxx` (no linguistic content) and `mul` (multiple) say the same in the
101/// standard's own vocabulary. They abstain — like the blank case — instead of pinning English
102/// text to the multilingual checkpoint.
103const LANGUAGE_AGNOSTIC_CODES: [&str; 5] = ["c", "posix", "und", "zxx", "mul"];
104
105/// Python `repr()` of a string: single quotes unless the string contains a single (but not
106/// double) quote. Used so routing `reason` strings match the Python `%r` formatting exactly.
107fn py_repr(s: &str) -> String {
108    let quote = if s.contains('\'') && !s.contains('"') {
109        '"'
110    } else {
111        '\''
112    };
113    let mut out = String::new();
114    out.push(quote);
115    for c in s.chars() {
116        match c {
117            '\\' => out.push_str("\\\\"),
118            '\n' => out.push_str("\\n"),
119            '\r' => out.push_str("\\r"),
120            '\t' => out.push_str("\\t"),
121            c if c == quote => {
122                out.push('\\');
123                out.push(c);
124            }
125            c => out.push(c),
126        }
127    }
128    out.push(quote);
129    out
130}
131
132fn repo_str(spec: &ModelSpec) -> String {
133    match &spec.1 {
134        Some(sub) => format!("{}/{}", spec.0, sub),
135        None => spec.0.clone(),
136    }
137}
138
139/// Normalise a model name (accepting aliases) to a canonical checkpoint key.
140pub fn normalise_name(name: &str) -> Result<String> {
141    let key = name.trim().to_lowercase();
142    let key = aliases()
143        .iter()
144        .find(|(a, _)| *a == key)
145        .map(|(_, v)| v.to_string())
146        .unwrap_or(key);
147    if CANONICAL.contains(&key.as_str()) {
148        Ok(key)
149    } else {
150        let mut alias_names: Vec<&str> = aliases().iter().map(|(a, _)| *a).collect();
151        alias_names.sort();
152        Err(LayaError::Config(format!(
153            "unknown model {:?}; choose one of {:?} (or an alias: {:?})",
154            name,
155            {
156                let mut c = CANONICAL.to_vec();
157                c.sort();
158                c
159            },
160            alias_names
161        )))
162    }
163}
164
165/// Registry spec for a checkpoint name or alias, or `None` when it is not one.
166///
167/// The non-raising sibling of [`normalise_name`], for callers that also accept things the registry
168/// knows nothing about — a Hub repo id, a local directory. Those pass through untouched; a name or
169/// alias the registry does know resolves to its `(repo, subfolder)` pair, so `Agent::load`
170/// (`"typed-decisions"`, `"ml"`, …) and `Router` name the same checkpoint from one table (upstream
171/// `resolve_model_spec`).
172pub fn resolve_model_spec(name: &str) -> Option<ModelSpec> {
173    let key = normalise_name(name).ok()?;
174    default_models()
175        .into_iter()
176        .find(|(k, _)| *k == key)
177        .map(|(_, spec)| spec)
178}
179
180/// Name of the typed-decisions workflow whose question ids these are (exact id-set match), else None.
181pub fn match_typed_decisions_workflow(questions: &Questions) -> Option<String> {
182    let ids: BTreeSet<&str> = questions.keys().map(|s| s.as_str()).collect();
183    for (wf, sig) in typed_decision_workflows() {
184        let sig_set: BTreeSet<&str> = sig.iter().copied().collect();
185        if ids == sig_set {
186            return Some((*wf).to_string());
187        }
188    }
189    None
190}
191
192/// `Some(true/false)` for a language code, or `None` when the code identifies nothing.
193/// Accepts `"en"`, `"EN"`, `"en-US"`, `"en_US"`, `"en_US.UTF-8"`.
194pub fn english_from_code(value: Option<&str>) -> Option<bool> {
195    let value = value?;
196    let code = value.trim().to_lowercase();
197    if code.is_empty() {
198        return None;
199    }
200    let code = code.split('.').next().unwrap_or(""); // en_US.UTF-8 -> en_US
201    let primary = code.replace('_', "-");
202    let primary = primary.split('-').next().unwrap_or(""); // en_US -> en
203    if primary.is_empty() || LANGUAGE_AGNOSTIC_CODES.contains(&primary) {
204        return None;
205    }
206    Some(ENGLISH_SUBTAGS.contains(&primary))
207}
208
209/// The routing outcome: which checkpoint, why, and what was detected.
210#[derive(Debug, Clone)]
211pub struct RouteDecision {
212    pub model: String,
213    pub repo: String,
214    pub reason: String,
215    pub detection: Option<Analysis>,
216    pub workflow: Option<String>,
217}
218
219impl RouteDecision {
220    /// Serialise like the Python `RouteDecision` dict (used for the `routing` key of a result).
221    pub fn to_json(&self) -> Value {
222        json!({
223            "model": self.model,
224            "repo": self.repo,
225            "reason": self.reason,
226            "detection": self.detection.as_ref().map(analysis_to_json),
227            "workflow": self.workflow,
228        })
229    }
230}
231
232fn analysis_to_json(a: &Analysis) -> Value {
233    json!({
234        "script": a.script,
235        "script_profile": a.script_profile.iter().map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>(),
236        "language": a.language,
237        "is_english": a.is_english,
238        "language_undecided": a.language_undecided,
239        "diacritic_rate": a.diacritic_rate,
240        "non_latin_fraction": a.non_latin_fraction,
241        "mixed_segment": a.mixed_segment,
242    })
243}
244
245/// Per-call routing hints (mirror the keyword args of Python `Router.route`).
246#[derive(Debug, Clone, Default)]
247pub struct RouteHints<'a> {
248    pub model: Option<&'a str>,
249    pub task: Option<&'a str>,
250    pub lang: Option<&'a str>,
251    /// A language code hint (the callable form of Python `lang_guess` is not modelled here).
252    pub lang_guess: Option<&'a str>,
253}
254
255/// Options for constructing a [`Router`].
256#[derive(Debug, Clone)]
257pub struct RouterOptions {
258    pub device: Option<String>,
259    pub token: Option<String>,
260    pub max_loaded: usize,
261    pub default: String,
262    pub auto_task_detection: bool,
263    pub standalone_repos: bool,
264    /// A language-code hint applied to every request (checked before built-in detection).
265    pub lang_guess: Option<String>,
266}
267
268impl Default for RouterOptions {
269    fn default() -> Self {
270        RouterOptions {
271            device: None,
272            token: std::env::var("HF_TOKEN").ok(),
273            max_loaded: 2,
274            default: "english".to_string(),
275            auto_task_detection: false,
276            standalone_repos: false,
277            lang_guess: None,
278        }
279    }
280}
281
282/// Lazily loads Laya checkpoints and sends each request to the right one.
283pub struct Router {
284    models: indexmap::IndexMap<String, ModelSpec>,
285    #[allow(dead_code)]
286    device: Option<String>,
287    #[allow(dead_code)]
288    token: Option<String>,
289    default: String,
290    auto_task_detection: bool,
291    lang_guess: Option<String>,
292    #[cfg(feature = "model")]
293    cache: std::sync::Mutex<ModelCache>,
294}
295
296#[cfg(feature = "model")]
297struct ModelCache {
298    agents: std::collections::HashMap<String, std::sync::Arc<crate::agent::Agent>>,
299    order: Vec<String>, // least-recently-used first
300    max_loaded: usize,
301}
302
303impl Router {
304    pub fn new(opts: RouterOptions) -> Result<Router> {
305        let base = if opts.standalone_repos {
306            standalone_models()
307        } else {
308            default_models()
309        };
310        let models: indexmap::IndexMap<String, ModelSpec> =
311            base.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
312        let default = normalise_name(&opts.default)?;
313        Ok(Router {
314            models,
315            device: opts.device,
316            token: opts.token,
317            default,
318            auto_task_detection: opts.auto_task_detection,
319            lang_guess: opts.lang_guess,
320            #[cfg(feature = "model")]
321            cache: std::sync::Mutex::new(ModelCache {
322                agents: std::collections::HashMap::new(),
323                order: Vec::new(),
324                max_loaded: opts.max_loaded.max(1),
325            }),
326        })
327    }
328
329    /// Convenience constructor with default options.
330    pub fn with_defaults() -> Result<Router> {
331        Router::new(RouterOptions::default())
332    }
333
334    fn spec(&self, key: &str) -> &ModelSpec {
335        self.models.get(key).expect("normalised key must exist")
336    }
337
338    fn resolve_hint(&self, hint: Option<&str>) -> Option<bool> {
339        english_from_code(hint)
340    }
341
342    /// Decide which checkpoint to use, without loading or running anything.
343    pub fn route(
344        &self,
345        state: &State,
346        questions: Option<&Questions>,
347        hints: &RouteHints,
348    ) -> Result<RouteDecision> {
349        let empty = Questions::new();
350        let questions = questions.unwrap_or(&empty);
351
352        if let Some(model) = hints.model {
353            let key = normalise_name(model)?;
354            return Ok(RouteDecision {
355                repo: repo_str(self.spec(&key)),
356                model: key,
357                reason: format!("explicit model={}", py_repr(model)),
358                detection: None,
359                workflow: None,
360            });
361        }
362
363        if let Some(task) = hints.task {
364            let normalized = if task.to_lowercase().replace('-', "_") == "typed_decisions" {
365                "typed-decisions"
366            } else {
367                task
368            };
369            let key = normalise_name(normalized)?;
370            return Ok(RouteDecision {
371                repo: repo_str(self.spec(&key)),
372                model: key,
373                reason: format!("explicit task={}", py_repr(task)),
374                detection: None,
375                workflow: None,
376            });
377        }
378
379        let workflow = match_typed_decisions_workflow(questions);
380        if let Some(wf) = &workflow {
381            if self.auto_task_detection {
382                return Ok(RouteDecision {
383                    model: "typed-decisions".to_string(),
384                    repo: repo_str(self.spec("typed-decisions")),
385                    reason: format!(
386                        "question ids match the {} typed-decisions workflow",
387                        py_repr(wf)
388                    ),
389                    detection: None,
390                    workflow: workflow.clone(),
391                });
392            }
393        }
394
395        if let Some(lang) = hints.lang {
396            // An explicit `lang` is decisive only when the code names a language. Blank or
397            // whitespace resolves to no usable hint, so it falls through to lang_guess/detection
398            // exactly as an abstaining hint does; real English/non-English codes still route now.
399            if let Some(resolved) = english_from_code(Some(lang)) {
400                let key = if resolved { "english" } else { "multilingual" };
401                return Ok(RouteDecision {
402                    model: key.to_string(),
403                    repo: repo_str(self.spec(key)),
404                    reason: format!("explicit lang={}", py_repr(lang)),
405                    detection: None,
406                    workflow,
407                });
408            }
409        }
410
411        // Per-call hint first, then the one installed on the Router.
412        for (source, hint) in [
413            ("lang_guess", hints.lang_guess),
414            ("Router(lang_guess=...)", self.lang_guess.as_deref()),
415        ] {
416            if let Some(resolved) = self.resolve_hint(hint) {
417                let key = if resolved { "english" } else { "multilingual" };
418                return Ok(RouteDecision {
419                    model: key.to_string(),
420                    repo: repo_str(self.spec(key)),
421                    reason: format!(
422                        "{}: the caller identified this as {} text",
423                        source,
424                        if resolved { "English" } else { "non-English" }
425                    ),
426                    detection: None,
427                    workflow,
428                });
429            }
430        }
431
432        let det = analyse(state);
433        let (key, reason): (String, String) = if det.script == "unknown" {
434            (
435                self.default.clone(),
436                format!(
437                    "no letters detected in state; using default ({})",
438                    self.default
439                ),
440            )
441        } else if det.script != "latin" {
442            (
443                "multilingual".to_string(),
444                format!(
445                    "non-Latin script ({}, {:.0}% of letters); the English checkpoint cannot read it",
446                    det.script,
447                    100.0 * det.non_latin_fraction
448                ),
449            )
450        } else if !det.is_english {
451            let reason = if let Some(seg) = &det.mixed_segment {
452                // A mostly-English state whose own line/field reads as a non-English language:
453                // the part a question is about is not English even though the whole outvotes it.
454                let truncated: String = seg.chars().take(60).collect();
455                format!(
456                    "Latin script, mostly English, but a line or field reads as {} ({}); the English checkpoint cannot read it",
457                    py_repr(det.language.as_deref().unwrap_or("")),
458                    py_repr(&truncated)
459                )
460            } else if let Some(l) = &det.language {
461                format!(
462                    "Latin script but language looks like {}, not English",
463                    py_repr(l)
464                )
465            } else {
466                format!(
467                    "Latin script, language not identified but {:.0}% non-English letters; not safe for the English checkpoint",
468                    100.0 * det.diacritic_rate
469                )
470            };
471            ("multilingual".to_string(), reason)
472        } else if det.language_undecided {
473            (
474                self.default.clone(),
475                format!(
476                    "Latin script, language not identified and no non-English letters; using default ({})",
477                    self.default
478                ),
479            )
480        } else {
481            ("english".to_string(), "English Latin text".to_string())
482        };
483
484        Ok(RouteDecision {
485            repo: repo_str(self.spec(&key)),
486            model: key,
487            reason,
488            detection: Some(det),
489            workflow,
490        })
491    }
492}
493
494// --------------------------------------------------------------- model-backed loading
495#[cfg(feature = "model")]
496mod loading {
497    use super::*;
498    use crate::agent::{Agent, SystemOneResult};
499    use std::sync::Arc;
500
501    impl Router {
502        /// Return the Agent for `name`, downloading and building it on first use.
503        pub fn load(&self, name: &str) -> Result<Arc<Agent>> {
504            let key = normalise_name(name)?;
505            {
506                let mut cache = self.cache.lock().unwrap();
507                if let Some(agent) = cache.agents.get(&key).cloned() {
508                    touch(&mut cache, &key);
509                    return Ok(agent);
510                }
511            }
512            // Build outside the lock (download can be slow); tolerate a concurrent winner.
513            let (repo, sub) = self.spec(&key).clone();
514            let agent = Arc::new(Agent::load(
515                &repo,
516                crate::agent::LoadOptions {
517                    device: self.device.clone(),
518                    token: self.token.clone(),
519                    subfolder: sub,
520                },
521            )?);
522            let mut cache = self.cache.lock().unwrap();
523            let agent = cache.agents.entry(key.clone()).or_insert(agent).clone();
524            if !cache.order.contains(&key) {
525                cache.order.push(key.clone());
526            }
527            evict(&mut cache);
528            Ok(agent)
529        }
530
531        /// Pre-build the given checkpoints (or all configured ones) so no request pays a load.
532        /// Raises `max_loaded` to fit both the requested and already-resident checkpoints.
533        pub fn preload(&self, names: Option<&[&str]>) -> Result<()> {
534            let names: Vec<String> = match names {
535                Some(ns) => ns
536                    .iter()
537                    .map(|n| normalise_name(n))
538                    .collect::<Result<_>>()?,
539                None => self.models.keys().cloned().collect(),
540            };
541            {
542                let mut cache = self.cache.lock().unwrap();
543                let want: std::collections::HashSet<&String> =
544                    names.iter().chain(cache.order.iter()).collect();
545                cache.max_loaded = cache.max_loaded.max(want.len());
546            }
547            for n in &names {
548                self.load(n)?;
549            }
550            Ok(())
551        }
552
553        /// Free one checkpoint, or all of them.
554        pub fn unload(&self, name: Option<&str>) -> Result<()> {
555            let mut cache = self.cache.lock().unwrap();
556            match name {
557                None => {
558                    cache.agents.clear();
559                    cache.order.clear();
560                }
561                Some(n) => {
562                    let key = normalise_name(n)?;
563                    cache.agents.remove(&key);
564                    cache.order.retain(|k| k != &key);
565                }
566            }
567            Ok(())
568        }
569
570        /// The currently loaded checkpoints (least-recently-used first).
571        pub fn loaded(&self) -> Vec<String> {
572            self.cache.lock().unwrap().order.clone()
573        }
574
575        /// Route, then answer every question in one forward pass on the chosen checkpoint.
576        pub fn predict(
577            &self,
578            state: &State,
579            questions: &Questions,
580            hints: &RouteHints,
581        ) -> Result<SystemOneResult> {
582            let decision = self.route(state, Some(questions), hints)?;
583            let agent = self.load(&decision.model)?;
584            let mut result = agent.system_one(state, questions)?;
585            result.routing = Some(decision.to_json());
586            Ok(result)
587        }
588    }
589
590    fn touch(cache: &mut ModelCache, key: &str) {
591        cache.order.retain(|k| k != key);
592        cache.order.push(key.to_string());
593    }
594
595    fn evict(cache: &mut ModelCache) {
596        while cache.order.len() > cache.max_loaded {
597            let victim = cache.order.remove(0);
598            cache.agents.remove(&victim);
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use serde_json::json;
607
608    fn de_state() -> Value {
609        json!({ "text": "Mein Konto wurde zweimal belastet" })
610    }
611
612    #[test]
613    fn explicit_lang_code_routes() {
614        let r = Router::with_defaults().unwrap();
615        let en = r
616            .route(
617                &de_state(),
618                None,
619                &RouteHints {
620                    lang: Some("en"),
621                    ..Default::default()
622                },
623            )
624            .unwrap();
625        assert_eq!(en.model, "english");
626        assert!(en.reason.contains("explicit lang"));
627
628        let de = r
629            .route(
630                &de_state(),
631                None,
632                &RouteHints {
633                    lang: Some("de"),
634                    ..Default::default()
635                },
636            )
637            .unwrap();
638        assert_eq!(de.model, "multilingual");
639    }
640
641    #[test]
642    fn blank_lang_falls_through_to_detection() {
643        // A blank/whitespace explicit lang names no language, so it must not decide routing;
644        // detection sees German and routes multilingual (upstream #292).
645        let r = Router::with_defaults().unwrap();
646        for blank in ["", "   ", "\t"] {
647            let d = r
648                .route(
649                    &de_state(),
650                    None,
651                    &RouteHints {
652                        lang: Some(blank),
653                        ..Default::default()
654                    },
655                )
656                .unwrap();
657            assert_eq!(
658                d.model, "multilingual",
659                "blank lang {blank:?} should fall through"
660            );
661            assert!(
662                !d.reason.contains("explicit lang"),
663                "blank lang {blank:?} must not route as explicit"
664            );
665        }
666    }
667
668    #[test]
669    fn language_agnostic_codes_abstain() {
670        // `$LANG` values that name no language must not pin routing to a checkpoint; they abstain
671        // like a blank code so detection decides (upstream #368).
672        for code in ["C", "posix", "und", "zxx", "mul", "C.UTF-8", "c_US"] {
673            assert_eq!(
674                english_from_code(Some(code)),
675                None,
676                "{code:?} should name no language"
677            );
678        }
679        // Real codes still resolve.
680        assert_eq!(english_from_code(Some("en")), Some(true));
681        assert_eq!(english_from_code(Some("de")), Some(false));
682    }
683
684    #[test]
685    fn lang_c_falls_through_to_detection() {
686        // An explicit `lang=C` names no language, so German text still routes multilingual.
687        let r = Router::with_defaults().unwrap();
688        let d = r
689            .route(
690                &de_state(),
691                None,
692                &RouteHints {
693                    lang: Some("C"),
694                    ..Default::default()
695                },
696            )
697            .unwrap();
698        assert_eq!(d.model, "multilingual");
699        assert!(!d.reason.contains("explicit lang"));
700    }
701
702    #[test]
703    fn mixed_segment_routes_multilingual_with_reason() {
704        // A mostly-English state whose own field reads as Portuguese routes multilingual with the
705        // "mostly English, but a line or field reads as" reason (upstream #207).
706        let r = Router::with_defaults().unwrap();
707        let state = json!({
708            "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
709            "log": "The server returned an internal error and the request was retried three times before it finally failed on the second attempt with a timeout on the database connection pool that was exhausted"
710        });
711        let d = r
712            .route(&state, Some(&Questions::new()), &RouteHints::default())
713            .unwrap();
714        assert_eq!(d.model, "multilingual");
715        assert_eq!(
716            d.reason,
717            "Latin script, mostly English, but a line or field reads as 'pt' \
718             ('Eu preciso de ajuda com a minha conta pois fui cobrado duas '); \
719             the English checkpoint cannot read it"
720        );
721    }
722}