1use crate::lang::{analyse, Analysis};
7use crate::{LayaError, Questions, Result, State};
8use serde_json::{json, Value};
9use std::collections::BTreeSet;
10
11pub const BUNDLE_REPO: &str = "convaiinnovations/laya";
13
14pub 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
62fn 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
98const LANGUAGE_AGNOSTIC_CODES: [&str; 5] = ["c", "posix", "und", "zxx", "mul"];
104
105fn 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
139pub 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
165pub 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
180pub 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
192pub 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(""); let primary = code.replace('_', "-");
202 let primary = primary.split('-').next().unwrap_or(""); if primary.is_empty() || LANGUAGE_AGNOSTIC_CODES.contains(&primary) {
204 return None;
205 }
206 Some(ENGLISH_SUBTAGS.contains(&primary))
207}
208
209#[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 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#[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 pub lang_guess: Option<&'a str>,
253}
254
255#[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 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
282pub 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>, 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 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 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 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 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 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#[cfg(feature = "model")]
496mod loading {
497 use super::*;
498 use crate::agent::{Agent, SystemOneResult};
499 use std::sync::Arc;
500
501 impl Router {
502 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 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 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 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 pub fn loaded(&self) -> Vec<String> {
572 self.cache.lock().unwrap().order.clone()
573 }
574
575 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 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 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 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 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 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}