From 2ede0ee4529c85cd9a177d6af09b487f56a81431 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 7 Aug 2026 17:39:38 +0300 Subject: [PATCH] feat(routing): per-task rules on top of complexity tiers; hosted router is the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router has always returned two signals — task label and complexity tier — but only the tier picked models. [routing.tasks] adds `label → ordered models`: a matching rule's models lead the candidate chain and the tier chain follows as fallback, so pinning translations to a local model never weakens failover. Requests whose chain was headed by a task rule report route_source "router+task-rule". The Routing page grows a per-task editor built from the account's LIVE taxonomy (GET /v1/taxonomies via the new /admin/api/routing/labels proxy; built-in label set when the router is unreachable), with localized task names, each task's share of 7-day traffic, "by complexity (no rule)" as the explicit empty state, and custom labels for other taxonomies. Deleting a model scrubs it from task rules like it always did from tiers. Also fixes the default config pointing at a placeholder localhost:8080 router with a nonexistent taxonomy — a fresh install now targets the hosted allaigate router (IP endpoint, verify_tls off, data-assistant, 12 s oracle timeout) and degrades gracefully until a key is set. Verified end-to-end against the live router: translation → task-rule model, math → tier path. 16 new strings in all 7 languages. --- config/gateway.example.toml | 8 +- docs/ROUTING.md | 20 +++++ docs/ROUTING.ru.md | 19 +++++ src/admin/mod.rs | 67 +++++++++++++++- src/config.rs | 33 +++++++- src/pipeline.rs | 12 ++- src/router_client.rs | 17 +++++ src/routing.rs | 109 +++++++++++++++++++++----- web/css/styles.css | 6 ++ web/js/api.js | 1 + web/js/i18n/de.js | 18 +++++ web/js/i18n/en.js | 18 +++++ web/js/i18n/es.js | 18 +++++ web/js/i18n/fr.js | 18 +++++ web/js/i18n/ru.js | 18 +++++ web/js/i18n/tr.js | 18 +++++ web/js/i18n/zh.js | 18 +++++ web/js/views/routing.js | 147 ++++++++++++++++++++++++++++-------- 18 files changed, 507 insertions(+), 58 deletions(-) diff --git a/config/gateway.example.toml b/config/gateway.example.toml index 98e8a2c..ee5508b 100644 --- a/config/gateway.example.toml +++ b/config/gateway.example.toml @@ -14,11 +14,11 @@ listen = "0.0.0.0:9000" # NOTE: on hard prompts the router escalates to an oracle LLM (~10 s) — for the # hosted router set timeout_ms = 12000+, otherwise the gateway degrades to default. [router] -url = "http://localhost:8080" +url = "https://138.226.222.209" # hosted allaigate router (or your own cortiq-router URL) api_key_env = "CORTIQ_ROUTER_KEY" # name of the env var holding the router key -timeout_ms = 800 # timeout for the /v1/route call (12000+ for oracle) -taxonomy_id = "general.task-type" # for allaigate: "data-assistant" -verify_tls = true # false if the router uses an IP / self-signed cert +timeout_ms = 12000 # hard prompts escalate to an oracle (~10 s) +taxonomy_id = "data-assistant" # the hosted router's task taxonomy +verify_tls = false # the hosted endpoint is IP-bound; true for your own domain # --- how to pick the text to classify from the conversation ----------------- [route] diff --git a/docs/ROUTING.md b/docs/ROUTING.md index 7f33ff7..2260833 100644 --- a/docs/ROUTING.md +++ b/docs/ROUTING.md @@ -162,3 +162,23 @@ fine-tune the task mask. Over time this makes routing fit your specific pool mor enabled = false # the ground-truth source is set by the integrator (e.g. the X-Cortiq-Correct-Label header) ``` + +## Task-based routing + +The router classifies every request twice: a **task label** (what kind of work it +is) and a **complexity tier** (how hard it is). Tiers pick *how much model* you +need; task rules pick *which* model is best at the job: + +```toml +[routing.tasks] +translation = ["local-qwen"] # translations are cheap — keep them local +code = ["claude-sonnet", "gpt"] # code goes to the strongest coder first +``` + +A matching rule's models go **first** in the candidate chain; the complexity +chain follows as fallback, so a task pin never weakens failover. Tasks without +a rule route by complexity alone. The available labels come from the router +(`GET /v1/taxonomies`) — the admin console's Routing page builds the editor +from your account's live taxonomy and shows each task's share of recent +traffic. When a rule supplied the head of the chain the response reports +`route_source: "router+task-rule"`. diff --git a/docs/ROUTING.ru.md b/docs/ROUTING.ru.md index b546fc3..0daf794 100644 --- a/docs/ROUTING.ru.md +++ b/docs/ROUTING.ru.md @@ -160,3 +160,22 @@ cooldown = "30s" enabled = false # источник истины задаёт интегратор (например, заголовок X-Cortiq-Correct-Label) ``` + +## Распределение по задачам + +Роутер классифицирует каждый запрос дважды: **тип задачи** (что это за работа) +и **уровень сложности** (насколько она трудна). Уровни выбирают, *сколько +модели* нужно; правила по задачам — *какая* модель лучше в этом деле: + +```toml +[routing.tasks] +translation = ["local-qwen"] # переводы дёшевы — оставляем локально +code = ["claude-sonnet", "gpt"] # код — сильнейшему кодеру +``` + +Модели подходящего правила идут **первыми** в цепочке кандидатов; цепочка по +сложности следует за ними как резерв — правило по задаче не ослабляет failover. +Задачи без правила маршрутизируются только по сложности. Список меток приходит +с роутера (`GET /v1/taxonomies`) — вкладка Роутинг строит редактор по живой +таксономии аккаунта и показывает долю каждой задачи в трафике. Когда цепочку +возглавило правило задачи, ответ сообщает `route_source: "router+task-rule"`. diff --git a/src/admin/mod.rs b/src/admin/mod.rs index c60ea89..3fd6a00 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -155,6 +155,7 @@ pub fn api_routes(admin_token: String) -> Router { ) .route("/admin/api/models/:id/probe", post(probe_model)) .route("/admin/api/routing", get(get_routing).put(put_routing)) + .route("/admin/api/routing/labels", get(get_routing_labels)) .route( "/admin/api/protocols", get(get_protocols).put(put_protocols), @@ -744,7 +745,7 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { if routing.default == id { routing.default = String::new(); } - for tt in routing.tiers.values_mut() { + for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) { match tt { crate::config::TierTargets::List(v) => v.retain(|x| x != id), crate::config::TierTargets::One(s) if s == id => { @@ -753,6 +754,11 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { crate::config::TierTargets::One(_) => {} } } + // an emptied task rule means "no rule", not "no candidates" + routing.tasks.retain(|_, tt| match tt { + crate::config::TierTargets::List(v) => !v.is_empty(), + crate::config::TierTargets::One(s) => !s.is_empty(), + }); } /// Whether any managed server or static model in `cfg` still uses `path` as its @@ -893,17 +899,70 @@ async fn probe_model(State(state): State, Path(id): Path) - async fn get_routing(State(state): State) -> ApiResult { let live = state.live(); + let tasks: HashMap> = live + .cfg + .routing + .tasks + .iter() + .map(|(k, v)| { + ( + k.clone(), + match v { + TierTargets::List(l) => l.clone(), + TierTargets::One(s) => vec![s.clone()], + }, + ) + }) + .collect(); ok(json!({ "tiers": tiers_to_map(&live.cfg.routing), + "tasks": tasks, "default": live.cfg.routing.default, "policy": live.cfg.routing.policy, })) } +/// Task labels of the active taxonomy, straight from the router (`GET +/// /v1/taxonomies`) — the UI builds the per-task rule editor from this, so a +/// custom taxonomy shows its own labels without a gateway update. +async fn get_routing_labels(State(state): State) -> ApiResult { + let live = state.live(); + match live.router.taxonomies().await { + Ok(Some(v)) => { + let want = live.cfg.router.taxonomy_id.clone().unwrap_or_default(); + let mut labels: Vec = Vec::new(); + if let Some(arr) = v["taxonomies"].as_array() { + for tx in arr { + let id = tx["taxonomy_id"].as_str().unwrap_or(""); + if want.is_empty() || id == want || labels.is_empty() { + if let Some(ls) = tx["labels"].as_array() { + let this: Vec = ls + .iter() + .filter_map(|l| l.as_str().map(String::from)) + .collect(); + if id == want || labels.is_empty() { + labels = this; + } + } + } + } + } + if labels.is_empty() { + ok(json!({ "labels": Value::Null, "source": "router-empty" })) + } else { + ok(json!({ "labels": labels, "source": "router" })) + } + } + _ => ok(json!({ "labels": Value::Null, "source": "unavailable" })), + } +} + #[derive(Deserialize)] struct RoutingBody { #[serde(default)] tiers: HashMap>, + #[serde(default)] + tasks: HashMap>, default: String, #[serde(default)] policy: RoutingPolicy, @@ -917,6 +976,12 @@ async fn put_routing(State(state): State, Json(body): Json, } /// A routing tier is either a list of model_ids or the string `default`. @@ -724,13 +730,16 @@ impl Default for Config { fn default() -> Self { Self { listen: "0.0.0.0:9000".to_string(), + // The hosted allaigate router — the thing this gateway is built + // around. Without a key the pipeline degrades gracefully to local + // models and the console asks for one; nothing breaks. router: RouterCfg { enabled: true, - url: "http://localhost:8080".to_string(), - timeout_ms: default_router_timeout(), - verify_tls: true, + url: "https://138.226.222.209".to_string(), + timeout_ms: 12_000, // hard prompts escalate to an oracle (~10 s) + verify_tls: false, // the hosted endpoint is IP-bound api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()), - taxonomy_id: Some("general.task-type".to_string()), + taxonomy_id: Some("data-assistant".to_string()), }, route: RouteCfg::default(), models: Vec::new(), @@ -738,6 +747,7 @@ impl Default for Config { tiers: HashMap::new(), default: String::new(), policy: RoutingPolicy::default(), + tasks: HashMap::new(), }, breaker: BreakerCfg::default(), protocols: ProtocolsCfg { @@ -862,6 +872,21 @@ impl Config { } } } + for (task, targets) in &self.routing.tasks { + let list = match targets { + TierTargets::List(v) => v.clone(), + TierTargets::One(s) => vec![s.clone()], + }; + for id in &list { + if !ids.contains(id.as_str()) { + anyhow::bail!( + "config: routing.tasks.{} references unknown model id '{}'", + task, + id + ); + } + } + } Ok(()) } diff --git a/src/pipeline.rs b/src/pipeline.rs index 5aeee6f..387ac6c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -232,8 +232,16 @@ impl Pipeline { match live.router.route(&text, prof).await { Ok(Some(dec)) => { let src = dec.source.clone(); - let tier = dec.complexity_tier.clone(); - (dec, src, live.routing.candidates(&tier)) + let (cands, task_rule) = live + .routing + .candidates_for(&dec.task_label, &dec.complexity_tier); + // surface which rule chose the head of the chain + let src = if task_rule { + format!("{src}+task-rule") + } else { + src + }; + (dec, src, cands) } Ok(None) | Err(_) => { tracing::warn!( diff --git a/src/router_client.rs b/src/router_client.rs index 05e0617..88db840 100644 --- a/src/router_client.rs +++ b/src/router_client.rs @@ -71,6 +71,23 @@ impl RouterClient { /// Classify text. Returns `Ok(None)` on network error/timeout — /// the caller treats this as "router unavailable" and uses the default tier. + /// `GET /v1/taxonomies` — the account's taxonomies with their label sets. + /// `Ok(None)` when the router is disabled or has no key configured. + pub async fn taxonomies(&self) -> anyhow::Result> { + if self.url.is_empty() { + return Ok(None); + } + let mut req = self.http.get(format!("{}/v1/taxonomies", self.url)); + if let Some(k) = &self.api_key { + req = req.bearer_auth(k); + } + let resp = req.send().await?; + if !resp.status().is_success() { + anyhow::bail!("router /v1/taxonomies: HTTP {}", resp.status()); + } + Ok(Some(resp.json().await?)) + } + pub async fn route(&self, text: &str, profile: &str) -> anyhow::Result> { let mut body = serde_json::json!({ "input": { "text": text }, diff --git a/src/routing.rs b/src/routing.rs index a49a637..6d006ad 100644 --- a/src/routing.rs +++ b/src/routing.rs @@ -3,38 +3,70 @@ use crate::config::{Config, TierTargets}; +fn to_list(t: &TierTargets) -> Vec { + match t { + TierTargets::List(v) => v.clone(), + TierTargets::One(s) => vec![s.clone()], + } +} + pub struct RoutingTable { tiers: std::collections::HashMap>, + /// Per-task rules — a matching rule's models lead the candidate chain. + tasks: std::collections::HashMap>, default: String, } impl RoutingTable { pub fn from_config(cfg: &Config) -> Self { - let tiers = cfg - .routing - .tiers - .iter() - .map(|(tier, targets)| { - let list = match targets { - TierTargets::List(v) => v.clone(), - TierTargets::One(s) => vec![s.clone()], - }; - (tier.clone(), list) - }) - .collect(); Self { - tiers, + tiers: cfg + .routing + .tiers + .iter() + .map(|(tier, targets)| (tier.clone(), to_list(targets))) + .collect(), + tasks: cfg + .routing + .tasks + .iter() + .map(|(task, targets)| (task.clone(), to_list(targets))) + .filter(|(_, v)| !v.is_empty()) + .collect(), default: cfg.routing.default.clone(), } } + /// Candidates for `(task, tier)`, in preference order, deduplicated: + /// the task rule's models first (when one exists), then the tier chain — + /// so a task pin adds preference without ever weakening failover. + /// Returns `(candidates, task_rule_matched)`. + pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec, bool) { + let mut out: Vec = Vec::new(); + let matched = if let Some(rule) = self.tasks.get(task) { + out.extend(rule.iter().cloned()); + true + } else { + false + }; + let tier_chain = self + .tiers + .get(tier) + .cloned() + .unwrap_or_else(|| vec![self.default.clone()]); + for id in tier_chain { + if !out.contains(&id) { + out.push(id); + } + } + out.retain(|id| !id.is_empty()); + (out, matched) + } + /// Candidates for the given complexity tier, in preference order. /// If the tier is not defined, the sole candidate is `default`. pub fn candidates(&self, tier: &str) -> Vec { - self.tiers - .get(tier) - .cloned() - .unwrap_or_else(|| vec![self.default.clone()]) + self.candidates_for("", tier).0 } /// Default model (used when the router is unavailable). @@ -46,3 +78,46 @@ impl RoutingTable { // TODO(v0.2): cost_aware mode and circuit breaker — pick the cheapest available // model no lower than min_class[tier], respecting max_cost_usd_per_request, // and skip models whose circuit breaker is open. + +#[cfg(test)] +mod tests { + use super::*; + + fn table(tasks: &[(&str, &[&str])], tiers: &[(&str, &[&str])], default: &str) -> RoutingTable { + RoutingTable { + tiers: tiers + .iter() + .map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect())) + .collect(), + tasks: tasks + .iter() + .map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect())) + .collect(), + default: default.to_string(), + } + } + + #[test] + fn task_rule_leads_tier_chain_follows() { + let t = table( + &[("translation", &["local"])], + &[("low", &["cheap", "local"]), ("high", &["big"])], + "cheap", + ); + let (c, matched) = t.candidates_for("translation", "high"); + assert!(matched); + assert_eq!(c, vec!["local", "big"]); + // dedup: rule model already in the tier chain appears once, first + let (c, _) = t.candidates_for("translation", "low"); + assert_eq!(c, vec!["local", "cheap"]); + } + + #[test] + fn no_rule_falls_back_to_tier_then_default() { + let t = table(&[], &[("low", &["a"])], "d"); + assert_eq!(t.candidates_for("qa", "low").0, vec!["a"]); + let (c, matched) = t.candidates_for("qa", "unknown-tier"); + assert!(!matched); + assert_eq!(c, vec!["d"]); + } +} diff --git a/web/css/styles.css b/web/css/styles.css index 31eb743..508b0b2 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -585,6 +585,12 @@ body.modal-open { overflow: hidden; } /* Featured gallery: the list grows as new models are published, so give it more room than the search grid before it starts scrolling. */ .featured-section .hf-grid { max-height: min(62vh, 760px); } +/* Routing: per-task rule rows */ +.taskrow { align-items: flex-start; } +.taskname { min-width: 190px; line-height: 1.35; } +.taskname .mono { font-size: 10.5px; opacity: .75; } +.chips-empty { padding: 4px 0; font-style: italic; } + /* Shell notices (open admin warning, update available) */ .notice { display: flex; align-items: center; gap: 8px; diff --git a/web/js/api.js b/web/js/api.js index 77eae01..3214772 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -68,6 +68,7 @@ export const api = { // routing getRouting: () => j("GET", "/routing"), + routingLabels: () => j("GET", "/routing/labels"), putRouting: (b) => j("PUT", "/routing", b), // protocols diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index 617f396..214d0ae 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -545,4 +545,22 @@ export default { "bench.running": "misst…", "bench.failed": "fehlgeschlagen", "bench.none": "Noch keine lokalen .cmf-Chat-Modelle.", + + // task-based routing + "task.chitchat": "Smalltalk", + "task.code": "Code", + "task.creative-writing": "Kreatives Schreiben", + "task.extraction": "Datenextraktion", + "task.math": "Mathematik", + "task.qa": "Fragen & Antworten", + "task.summarization": "Zusammenfassung", + "task.translation": "Übersetzung", + "routing.tasks.title": "Aufgabenbasiertes Routing", + "routing.tasks.hint": "Die Modelle einer Regel kommen für ihre Aufgabe zuerst; die Komplexitätskette bleibt als Fallback. Leer = nur nach Komplexität.", + "routing.tasks.inherit": "nach Komplexität (keine Regel)", + "routing.tasks.share": "{p}% der Anfragen (7 T)", + "routing.tasks.fromRouter": "Labels live vom Router", + "routing.tasks.builtin": "Router nicht erreichbar — eingebauter Labelsatz", + "routing.tasks.addCustom": "Aufgabe hinzufügen", + "routing.tasks.addPlaceholder": "eigenes Label…", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 9549cc0..50dbc46 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -538,4 +538,22 @@ export default { "bench.running": "measuring…", "bench.failed": "failed", "bench.none": "No local .cmf chat models yet.", + + // task-based routing + "task.chitchat": "Small talk", + "task.code": "Code", + "task.creative-writing": "Creative writing", + "task.extraction": "Data extraction", + "task.math": "Math", + "task.qa": "Q&A", + "task.summarization": "Summarization", + "task.translation": "Translation", + "routing.tasks.title": "Task-based routing", + "routing.tasks.hint": "A rule's models go first for that task; the complexity chain below still applies as fallback. Empty = route by complexity only.", + "routing.tasks.inherit": "by complexity (no rule)", + "routing.tasks.share": "{p}% of requests (7d)", + "routing.tasks.fromRouter": "labels live from the router", + "routing.tasks.builtin": "router unreachable — built-in label set", + "routing.tasks.addCustom": "Add task", + "routing.tasks.addPlaceholder": "custom label…", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index 12701e7..374c6df 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -545,4 +545,22 @@ export default { "bench.running": "midiendo…", "bench.failed": "falló", "bench.none": "Aún no hay modelos de chat .cmf locales.", + + // task-based routing + "task.chitchat": "Charla", + "task.code": "Código", + "task.creative-writing": "Escritura creativa", + "task.extraction": "Extracción de datos", + "task.math": "Matemáticas", + "task.qa": "Preguntas y respuestas", + "task.summarization": "Resumen", + "task.translation": "Traducción", + "routing.tasks.title": "Enrutado por tarea", + "routing.tasks.hint": "Los modelos de una regla van primero para su tarea; la cadena por complejidad sigue como respaldo. Vacío = solo por complejidad.", + "routing.tasks.inherit": "por complejidad (sin regla)", + "routing.tasks.share": "{p}% de las peticiones (7 d)", + "routing.tasks.fromRouter": "etiquetas en vivo del router", + "routing.tasks.builtin": "router inaccesible — conjunto integrado", + "routing.tasks.addCustom": "Añadir tarea", + "routing.tasks.addPlaceholder": "etiqueta propia…", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index 0905922..6e5be47 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -545,4 +545,22 @@ export default { "bench.running": "mesure…", "bench.failed": "échec", "bench.none": "Pas encore de modèles de chat .cmf locaux.", + + // task-based routing + "task.chitchat": "Bavardage", + "task.code": "Code", + "task.creative-writing": "Écriture créative", + "task.extraction": "Extraction de données", + "task.math": "Mathématiques", + "task.qa": "Questions-réponses", + "task.summarization": "Résumé", + "task.translation": "Traduction", + "routing.tasks.title": "Routage par tâche", + "routing.tasks.hint": "Les modèles d'une règle passent en premier pour leur tâche ; la chaîne par complexité reste en secours. Vide = complexité seule.", + "routing.tasks.inherit": "par complexité (aucune règle)", + "routing.tasks.share": "{p}% des requêtes (7 j)", + "routing.tasks.fromRouter": "étiquettes reçues du routeur", + "routing.tasks.builtin": "routeur injoignable — jeu d'étiquettes intégré", + "routing.tasks.addCustom": "Ajouter une tâche", + "routing.tasks.addPlaceholder": "étiquette personnalisée…", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 343d65d..29d9889 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -537,4 +537,22 @@ export default { "bench.running": "замер…", "bench.failed": "ошибка", "bench.none": "Локальных чат-моделей .cmf пока нет.", + + // task-based routing + "task.chitchat": "Светская беседа", + "task.code": "Код", + "task.creative-writing": "Творческие тексты", + "task.extraction": "Извлечение данных", + "task.math": "Математика", + "task.qa": "Вопросы и ответы", + "task.summarization": "Суммаризация", + "task.translation": "Перевод", + "routing.tasks.title": "Распределение по задачам", + "routing.tasks.hint": "Модели правила идут первыми для своей задачи; цепочка по сложности остаётся резервом. Пусто = только по сложности.", + "routing.tasks.inherit": "по сложности (без правила)", + "routing.tasks.share": "{p}% запросов (7д)", + "routing.tasks.fromRouter": "метки получены с роутера", + "routing.tasks.builtin": "роутер недоступен — встроенный набор меток", + "routing.tasks.addCustom": "Добавить задачу", + "routing.tasks.addPlaceholder": "своя метка…", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 6f10bd1..8f76ba1 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -545,4 +545,22 @@ export default { "bench.running": "ölçülüyor…", "bench.failed": "başarısız", "bench.none": "Henüz yerel .cmf sohbet modeli yok.", + + // task-based routing + "task.chitchat": "Sohbet", + "task.code": "Kod", + "task.creative-writing": "Yaratıcı yazarlık", + "task.extraction": "Veri çıkarma", + "task.math": "Matematik", + "task.qa": "Soru-cevap", + "task.summarization": "Özetleme", + "task.translation": "Çeviri", + "routing.tasks.title": "Göreve göre yönlendirme", + "routing.tasks.hint": "Kuralın modelleri kendi görevi için önce gelir; karmaşıklık zinciri yedek olarak kalır. Boş = yalnızca karmaşıklığa göre.", + "routing.tasks.inherit": "karmaşıklığa göre (kural yok)", + "routing.tasks.share": "isteklerin %{p}'i (7 g)", + "routing.tasks.fromRouter": "etiketler yönlendiriciden canlı", + "routing.tasks.builtin": "yönlendiriciye ulaşılamıyor — yerleşik etiket seti", + "routing.tasks.addCustom": "Görev ekle", + "routing.tasks.addPlaceholder": "özel etiket…", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index f6200e4..0a47ecf 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -545,4 +545,22 @@ export default { "bench.running": "测速中……", "bench.failed": "失败", "bench.none": "还没有本地 .cmf 聊天模型。", + + // task-based routing + "task.chitchat": "闲聊", + "task.code": "代码", + "task.creative-writing": "创意写作", + "task.extraction": "数据提取", + "task.math": "数学", + "task.qa": "问答", + "task.summarization": "摘要", + "task.translation": "翻译", + "routing.tasks.title": "按任务路由", + "routing.tasks.hint": "规则中的模型对该任务优先;下方的复杂度链仍作为回退。留空 = 仅按复杂度。", + "routing.tasks.inherit": "按复杂度(无规则)", + "routing.tasks.share": "{p}% 的请求(7 天)", + "routing.tasks.fromRouter": "标签实时来自路由器", + "routing.tasks.builtin": "路由器不可达 — 使用内置标签集", + "routing.tasks.addCustom": "添加任务", + "routing.tasks.addPlaceholder": "自定义标签……", }; diff --git a/web/js/views/routing.js b/web/js/views/routing.js index 8dd2a0e..4de26ac 100644 --- a/web/js/views/routing.js +++ b/web/js/views/routing.js @@ -4,71 +4,155 @@ import { t } from "../i18n.js"; import { api } from "../api.js"; import { appState } from "../app.js"; +// Known labels of the hosted taxonomy — used for localized names/descriptions +// and as the offline fallback; the live list comes from /routing/labels. +const TASK_META = { + chitchat: { icon: "💬" }, + code: { icon: "⌨" }, + "creative-writing": { icon: "✍" }, + extraction: { icon: "📑" }, + math: { icon: "∑" }, + qa: { icon: "❓" }, + summarization: { icon: "📋" }, + translation: { icon: "🌍" }, +}; + export async function renderRouting() { const meta = appState.meta || (await api.meta()); const [routing, modelsData] = await Promise.all([api.getRouting(), api.listModels()]); const ids = (modelsData.models || []).map((m) => m.id); + // Task labels: live from the router when reachable, built-in set otherwise; + // labels that already have a rule are always shown (custom taxonomies). + let taskLabels = Object.keys(TASK_META); + let labelsSource = "builtin"; + try { + const r = await api.routingLabels(); + if (r.labels && r.labels.length) { + taskLabels = r.labels; + labelsSource = "router"; + } + } catch { + /* offline — builtin list */ + } + for (const k of Object.keys(routing.tasks || {})) { + if (!taskLabels.includes(k)) taskLabels.push(k); + } + + // Share of recent traffic per task — shows which rules actually matter. + let taskShare = {}; + try { + const stat = await api.stats("7d", "label"); + const total = (stat.breakdown || []).reduce((a, b) => a + (b.requests || 0), 0); + if (total > 0) { + for (const b of stat.breakdown || []) taskShare[b.key] = (b.requests || 0) / total; + } + } catch { + /* no stats yet */ + } + // local editable state — ensure standard tiers exist for a friendly editor const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {}); + const tasks = {}; + for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || []; const st = { tiers, + tasks, default: routing.default || ids[0] || "", policy: routing.policy || { mode: "fixed_table", min_class: {} }, }; const root = h("div"); - function moveChip(tier, i, dir) { - const arr = st.tiers[tier]; + function moveChip(map, key, i, dir) { + const arr = map[key]; const ni = i + dir; if (ni < 0 || ni >= arr.length) return; [arr[i], arr[ni]] = [arr[ni], arr[i]]; render(); } - function removeChip(tier, i) { - st.tiers[tier].splice(i, 1); + function removeChip(map, key, i) { + map[key].splice(i, 1); render(); } - function addChip(tier, id) { - if (id && !st.tiers[tier].includes(id)) st.tiers[tier].push(id); + function addChip(map, key, id) { + if (id && !map[key].includes(id)) map[key].push(id); render(); } - - function tierRow(tier) { - const arr = st.tiers[tier]; + function chipEditor(map, key, emptyLabel) { + const arr = map[key]; const avail = ids.filter((id) => !arr.includes(id)); const addSel = h( "select", - { - style: "width:auto", - onChange: (e) => { - addChip(tier, e.target.value); - }, - }, + { style: "width:auto", onChange: (e) => addChip(map, key, e.target.value) }, h("option", { value: "" }, t("routing.addModel")), ...avail.map((id) => h("option", { value: id }, id)) ); + return h( + "div", + { class: "chips" }, + arr.length === 0 && emptyLabel ? h("span", { class: "muted small chips-empty" }, emptyLabel) : null, + ...arr.map((id, i) => + h( + "div", + { class: "chip" }, + h("span", { class: "ord" }, i + 1), + id, + h("span", { class: "arrow", title: "←", onClick: () => moveChip(map, key, i, -1) }, "◄"), + h("span", { class: "arrow", title: "→", onClick: () => moveChip(map, key, i, 1) }, "►"), + h("span", { class: "x", onClick: () => removeChip(map, key, i) }, "×") + ) + ), + avail.length ? addSel : null + ); + } + + function tierRow(tier) { return h( "div", { class: "tierrow" }, h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, tier)), + chipEditor(st.tiers, tier) + ); + } + + function taskRow(label) { + const known = TASK_META[label]; + const name = known ? t("task." + label) : label; + const share = taskShare[label]; + return h( + "div", + { class: "tierrow taskrow" }, h( "div", - { class: "chips" }, - ...arr.map((id, i) => - h( - "div", - { class: "chip" }, - h("span", { class: "ord" }, i + 1), - id, - h("span", { class: "arrow", title: "←", onClick: () => moveChip(tier, i, -1) }, "◄"), - h("span", { class: "arrow", title: "→", onClick: () => moveChip(tier, i, 1) }, "►"), - h("span", { class: "x", onClick: () => removeChip(tier, i) }, "×") - ) - ), - avail.length ? addSel : null - ) + { class: "tname taskname" }, + h("div", {}, (known ? known.icon + " " : "") + name), + h("div", { class: "muted small mono" }, label), + share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null + ), + chipEditor(st.tasks, label, t("routing.tasks.inherit")) + ); + } + + function tasksCard() { + const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" }); + const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom")); + addBtn.addEventListener("click", () => { + const v = addIn.value.trim().toLowerCase(); + if (!v || st.tasks[v]) return; + st.tasks[v] = []; + render(); + }); + return h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, + h("h3", {}, "🎯 " + t("routing.tasks.title")), + h("span", { class: "card-head sub" }, + labelsSource === "router" ? t("routing.tasks.fromRouter") : t("routing.tasks.builtin"))), + h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.tasks.hint")), + ...Object.keys(st.tasks).map(taskRow), + h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn) ); } @@ -116,7 +200,9 @@ export async function renderRouting() { saveBtn.addEventListener("click", async () => { saveBtn.disabled = true; try { - await api.putRouting({ tiers: st.tiers, default: st.default, policy: st.policy }); + const tasks = {}; + for (const [k, v] of Object.entries(st.tasks)) if (v.length) tasks[k] = v; + await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy }); toast(t("toast.saved"), "good"); } catch (e) { toast(String(e.message || e), "bad"); @@ -143,6 +229,7 @@ export async function renderRouting() { h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))), ...Object.keys(st.tiers).map(tierRow) ), + tasksCard(), h( "div", { class: "card" },