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