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 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
177pub 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(""); let primary = code.replace('_', "-");
187 let primary = primary.split('-').next().unwrap_or(""); if primary.is_empty() || LANGUAGE_AGNOSTIC_CODES.contains(&primary) {
189 return None;
190 }
191 Some(ENGLISH_SUBTAGS.contains(&primary))
192}
193
194#[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 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#[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 pub lang_guess: Option<&'a str>,
238}
239
240#[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 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
267pub 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>, 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 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 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 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 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 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#[cfg(feature = "model")]
481mod loading {
482 use super::*;
483 use crate::agent::{Agent, SystemOneResult};
484 use std::sync::Arc;
485
486 impl Router {
487 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 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 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 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 pub fn loaded(&self) -> Vec<String> {
557 self.cache.lock().unwrap().order.clone()
558 }
559
560 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 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 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 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 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 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}