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
98fn 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
132pub 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
158pub 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
170pub 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(""); let primary = code.replace('_', "-");
180 let primary = primary.split('-').next().unwrap_or(""); if primary.is_empty() {
182 return None;
183 }
184 Some(ENGLISH_SUBTAGS.contains(&primary))
185}
186
187#[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 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#[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 pub lang_guess: Option<&'a str>,
230}
231
232#[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 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
259pub 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>, 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 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 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 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 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#[cfg(feature = "model")]
464mod loading {
465 use super::*;
466 use crate::agent::{Agent, SystemOneResult};
467 use std::sync::Arc;
468
469 impl Router {
470 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 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 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 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 pub fn loaded(&self) -> Vec<String> {
540 self.cache.lock().unwrap().order.clone()
541 }
542
543 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 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}