From 788c87c565678bdfcc8046b1f46bdc97b4d7f944 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 8 Aug 2026 09:48:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(routing):=20linked=20complexity-boundary?= =?UTF-8?q?=20sliders=20=E2=80=94=20your=20own=20low/medium/high?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier boundaries lived on the router and were global for every customer of a hosted router; per-gateway tuning meant hand-editing score ranges. The Routing page now has one track with two draggable handles (the low|medium and medium|high boundaries) over three colored zones: handles push each other (low can't cross medium — it shoves it along, and vice versa), the numbers ride on the handles and the legend updates live, so nobody types ranges by hand. Unchecked = the router's own bands, untouched behavior. Under the hood the gateway re-maps the router's CONTINUOUS complexity score with the user's boundaries ([routing.bands] low/medium in gateway.toml, validated 0 < low < medium < 1) before tier chains, escalation and stats — verified live: with bands at 0.70/0.72 a score-0.27 request that used to be medium routed low, and back after restoring the defaults. 2 new strings in all 7 languages. --- src/admin/mod.rs | 4 ++ src/config.rs | 35 ++++++++++++++++ src/pipeline.rs | 13 ++++++ web/css/styles.css | 17 ++++++++ web/js/i18n/de.js | 2 + web/js/i18n/en.js | 2 + web/js/i18n/es.js | 2 + web/js/i18n/fr.js | 2 + web/js/i18n/ru.js | 2 + web/js/i18n/tr.js | 2 + web/js/i18n/zh.js | 2 + web/js/views/routing.js | 90 ++++++++++++++++++++++++++++++++++++++++- 12 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 550593c..f4d8b22 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -931,6 +931,7 @@ async fn get_routing(State(state): State) -> ApiResult { .collect(); ok(json!({ "tiers": tiers_to_map(&live.cfg.routing), + "bands": live.cfg.routing.bands, "tasks": tasks, "default": live.cfg.routing.default, "policy": live.cfg.routing.policy, @@ -978,6 +979,8 @@ struct RoutingBody { tiers: HashMap>, #[serde(default)] tasks: HashMap, + #[serde(default)] + bands: Option, default: String, #[serde(default)] policy: RoutingPolicy, @@ -997,6 +1000,7 @@ async fn put_routing(State(state): State, Json(body): Json, + /// Local complexity bands: re-map the router's continuous score into + /// low/medium/high with YOUR OWN boundaries. The hosted router's bands + /// are global for all customers; these are per-gateway. `None` = keep + /// the router's tier verdict as delivered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bands: Option, +} + +/// Score boundaries: `score ≤ low` → low, `≤ medium` → medium, else high. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct TierBands { + pub low: f32, + pub medium: f32, +} + +impl TierBands { + pub fn tier_of(&self, score: f32) -> &'static str { + if score <= self.low { + "low" + } else if score <= self.medium { + "medium" + } else { + "high" + } + } } /// A routing tier is either a list of model_ids or the string `default`. @@ -861,6 +886,7 @@ impl Default for Config { default: String::new(), policy: RoutingPolicy::default(), tasks: HashMap::new(), + bands: None, }, breaker: BreakerCfg::default(), protocols: ProtocolsCfg { @@ -988,6 +1014,15 @@ impl Config { } } } + if let Some(b) = &self.routing.bands { + if !(b.low > 0.0 && b.low < b.medium && b.medium < 1.0) { + anyhow::bail!( + "config: routing.bands must satisfy 0 < low < medium < 1 (got {} / {})", + b.low, + b.medium + ); + } + } for (task, targets) in &self.routing.tasks { for id in &targets.models() { if !ids.contains(id.as_str()) { diff --git a/src/pipeline.rs b/src/pipeline.rs index 4819c5f..741a0c0 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -231,6 +231,19 @@ impl Pipeline { let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile); match live.router.route(&text, prof).await { Ok(Some(dec)) => { + let mut dec = dec; + // per-gateway complexity bands: re-map the + // router's continuous score with the user's + // own boundaries (sliders on the Routing page) + if let Some(b) = &live.cfg.routing.bands { + if matches!( + dec.complexity_tier.as_str(), + "low" | "medium" | "high" + ) { + dec.complexity_tier = + b.tier_of(dec.complexity_score).to_string(); + } + } let src = dec.source.clone(); let (cands, task_rule) = live .routing diff --git a/web/css/styles.css b/web/css/styles.css index 88b7b2d..c6ae455 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -597,6 +597,23 @@ body.modal-open { overflow: hidden; } .pair-model { min-width: 220px; max-width: 340px; padding: 4px 8px; font-size: 12.5px; } .pair-tier { max-width: 150px; padding: 4px 8px; font-size: 12.5px; } +/* Complexity bands slider */ +.bands-wrap { margin: 4px 0 16px; } +.bands-track { position: relative; height: 14px; border-radius: 8px; background: var(--panel-2, #23252c); margin: 26px 8px 10px; } +.bands-track.disabled { opacity: .45; pointer-events: none; } +.bands-zone { position: absolute; top: 0; bottom: 0; } +.bands-zone.z-low { left: 0; background: color-mix(in srgb, var(--good) 35%, transparent); border-radius: 8px 0 0 8px; } +.bands-zone.z-med { background: color-mix(in srgb, var(--warn, #d9a514) 35%, transparent); } +.bands-zone.z-high { background: color-mix(in srgb, var(--bad, #e5484d) 35%, transparent); border-radius: 0 8px 8px 0; } +.bands-handle { position: absolute; top: -5px; width: 24px; height: 24px; margin-left: -12px; border-radius: 50%; + background: var(--fg, #fff); border: 3px solid var(--accent, #6c6cf5); cursor: grab; touch-action: none; z-index: 2; } +.bands-handle:active { cursor: grabbing; } +.bands-val { position: absolute; top: -24px; left: 50%; transform: translateX(-50%); font-size: 11.5px; + font-family: var(--mono, monospace); color: var(--fg); background: var(--panel, #1b1d23); padding: 1px 6px; + border-radius: 6px; border: 1px solid var(--border, #333); white-space: nowrap; } +.bands-val.raised { top: -46px; } +.bands-legend { display: flex; gap: 8px; margin: 6px 8px 0; flex-wrap: wrap; } + /* Benchmark view */ @keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } tbody tr:last-child { animation: bm-in .25s ease; } diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index 2996d93..cc8c5fd 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -629,4 +629,6 @@ export default { "routing.tasks.tierHint": "Welches Modell diese Aufgabe bei welcher Komplexität bedient; \"jede\" führt auf allen Stufen. Reihenfolge = Fallback-Reihenfolge. High eskaliert weiterhin über \"jede\"-Einträge.", "routing.tasks.tierAny": "jede Komplexität", "routing.tasks.addModel": "Modell", + "routing.bands.enable": "Eigene Komplexitätsgrenzen", + "routing.bands.hint": "Ziehen Sie die Griffe: wo low endet und high beginnt — die Griffe schieben einander, die Zahlen aktualisieren sich live. Der kontinuierliche Router-Score wird mit IHREN Grenzen neu eingeteilt; ohne Häkchen gelten die Grenzen des Routers.", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 2dfb76d..e31d584 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -622,4 +622,6 @@ export default { "routing.tasks.tierHint": "Which model serves this task at which complexity; \"any\" leads at every tier. Order = fallback order. High verdicts still escalate over any-tier pins.", "routing.tasks.tierAny": "any complexity", "routing.tasks.addModel": "model", + "routing.bands.enable": "My complexity boundaries", + "routing.bands.hint": "Drag the handles to set where low ends and where high begins — the handles push each other, the numbers update live. The router's continuous score is re-mapped with YOUR boundaries; unchecked = the router's own bands.", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index a889016..a8dbeb9 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -629,4 +629,6 @@ export default { "routing.tasks.tierHint": "Qué modelo atiende esta tarea en qué complejidad; «cualquiera» lidera en todos los niveles. El orden = orden de respaldo. Un veredicto high sigue escalando sobre las entradas «cualquiera».", "routing.tasks.tierAny": "cualquier complejidad", "routing.tasks.addModel": "modelo", + "routing.bands.enable": "Mis límites de complejidad", + "routing.bands.hint": "Arrastra los tiradores: dónde termina low y dónde empieza high — se empujan entre sí y las cifras se actualizan en vivo. La puntuación continua del router se re-mapea con TUS límites; sin marcar = las bandas del propio router.", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index 767bd12..6d32b89 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -629,4 +629,6 @@ export default { "routing.tasks.tierHint": "Quel modèle sert cette tâche à quelle complexité ; « toute » mène à tous les niveaux. L'ordre = ordre de repli. Un verdict high escalade toujours au-dessus des entrées « toute ».", "routing.tasks.tierAny": "toute complexité", "routing.tasks.addModel": "modèle", + "routing.bands.enable": "Mes limites de complexité", + "routing.bands.hint": "Faites glisser les poignées : où finit low et où commence high — elles se poussent l'une l'autre, les chiffres se mettent à jour en direct. Le score continu du routeur est re-découpé avec VOS limites ; décoché = les bandes du routeur.", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 2aef6e9..9fe231b 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -621,4 +621,6 @@ export default { "routing.tasks.tierHint": "Какая модель обслуживает задачу на какой сложности; «любая» ведёт на всех уровнях. Порядок = порядок фолбэка. Вердикт high по-прежнему эскалирует поверх «любых».", "routing.tasks.tierAny": "любая сложность", "routing.tasks.addModel": "модель", + "routing.bands.enable": "Мои границы сложности", + "routing.bands.hint": "Тяните ручки: где кончается low и где начинается high — ручки толкают друг друга, цифры обновляются на лету. Непрерывный score роутера пересчитывается ВАШИМИ границами; галочка снята — действуют пороги самого роутера.", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index a57473f..6492030 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -629,4 +629,6 @@ export default { "routing.tasks.tierHint": "Bu görevi hangi karmaşıklıkta hangi model karşılar; \"her\" tüm katmanlarda önde gider. Sıra = yedekleme sırası. High kararı \"her\" girdilerinin üzerine yine tırmanır.", "routing.tasks.tierAny": "her karmaşıklık", "routing.tasks.addModel": "model", + "routing.bands.enable": "Kendi karmaşıklık sınırlarım", + "routing.bands.hint": "Tutamaçları sürükleyin: low nerede biter, high nerede başlar — tutamaçlar birbirini iter, sayılar anlık güncellenir. Yönlendiricinin sürekli puanı SİZİN sınırlarınızla yeniden bölümlenir; işaret kaldırılırsa yönlendiricinin kendi bantları geçerli.", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index 8da8e5f..a86e412 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -629,4 +629,6 @@ export default { "routing.tasks.tierHint": "哪个模型在哪个复杂度上处理该任务;「任意」在所有层级领先。顺序即回退顺序。high 判定仍会越过「任意」条目升级。", "routing.tasks.tierAny": "任意复杂度", "routing.tasks.addModel": "模型", + "routing.bands.enable": "自定义复杂度边界", + "routing.bands.hint": "拖动手柄设定 low 到哪里结束、high 从哪里开始 — 手柄会相互推动,数字实时更新。路由器的连续得分将按你的边界重新划分;取消勾选则使用路由器自身的边界。", }; diff --git a/web/js/views/routing.js b/web/js/views/routing.js index 98a0be2..37f97b2 100644 --- a/web/js/views/routing.js +++ b/web/js/views/routing.js @@ -71,6 +71,7 @@ export async function renderRouting() { const st = { tiers, taskPairs, + bands: routing.bands || null, // null = пороги роутера как есть default: routing.default || ids[0] || "", policy: routing.policy || { mode: "fixed_table", min_class: {} }, }; @@ -120,6 +121,92 @@ export async function renderRouting() { ); } + // Linked boundary sliders: one track, two handles (low|medium boundaries), + // three colored zones. Handles push each other, numbers update live — + // nobody types score ranges by hand. + function bandsCard() { + const DEF = { low: 0.33, medium: 0.48 }; + const enabled = !!st.bands; + const b = st.bands || { ...DEF }; + const MIN_GAP = 0.02; + + const track = h("div", { class: "bands-track" }); + const zoneLow = h("div", { class: "bands-zone z-low" }); + const zoneMed = h("div", { class: "bands-zone z-med" }); + const zoneHigh = h("div", { class: "bands-zone z-high" }); + const hLow = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" })); + const hMed = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" })); + const legend = h("div", { class: "bands-legend" }); + track.append(zoneLow, zoneMed, zoneHigh, hLow, hMed); + + function paint() { + const lo = b.low, me = b.medium; + zoneLow.style.width = lo * 100 + "%"; + zoneMed.style.left = lo * 100 + "%"; + zoneMed.style.width = (me - lo) * 100 + "%"; + zoneHigh.style.left = me * 100 + "%"; + zoneHigh.style.width = (1 - me) * 100 + "%"; + hLow.style.left = lo * 100 + "%"; + hMed.style.left = me * 100 + "%"; + hLow.querySelector(".bands-val").textContent = lo.toFixed(2); + hMed.querySelector(".bands-val").textContent = me.toFixed(2); + // подписи не наезжают, когда ручки рядом + hMed.querySelector(".bands-val").classList.toggle("raised", me - lo < 0.09); + mount(legend, + h("span", { class: "badge tier-low" }, `low ≤ ${lo.toFixed(2)}`), + h("span", { class: "badge tier-medium" }, `medium ≤ ${me.toFixed(2)}`), + h("span", { class: "badge tier-high" }, `high > ${me.toFixed(2)}`)); + track.classList.toggle("disabled", !st.bands); + } + + function drag(handle, which) { + handle.addEventListener("pointerdown", (e) => { + if (!st.bands) return; + handle.setPointerCapture(e.pointerId); + const rect = track.getBoundingClientRect(); + const move = (ev) => { + let v = (ev.clientX - rect.left) / rect.width; + v = Math.min(0.98, Math.max(0.02, Math.round(v * 100) / 100)); + if (which === "low") { + b.low = v; + // связанность: low толкает medium перед собой + if (b.medium < b.low + MIN_GAP) b.medium = Math.min(0.99, b.low + MIN_GAP); + } else { + b.medium = v; + // и medium толкает low назад + if (b.low > b.medium - MIN_GAP) b.low = Math.max(0.01, b.medium - MIN_GAP); + } + st.bands = { low: b.low, medium: b.medium }; + paint(); + }; + const up = () => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + }); + } + drag(hLow, "low"); + drag(hMed, "medium"); + + const toggle = h("input", { type: "checkbox", checked: enabled ? "" : null }); + toggle.checked = enabled; + toggle.addEventListener("change", () => { + st.bands = toggle.checked ? { low: b.low, medium: b.medium } : null; + paint(); + }); + paint(); + return h( + "div", + { class: "bands-wrap" }, + h("label", { class: "check", style: "margin-bottom:8px" }, toggle, t("routing.bands.enable")), + h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.bands.hint")), + track, + legend + ); + } + function tierRow(tier) { return h( "div", @@ -257,7 +344,7 @@ export async function renderRouting() { } if (Object.keys(rule).length) tasks[k] = rule; } - await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy }); + await api.putRouting({ tiers: st.tiers, tasks, bands: st.bands, default: st.default, policy: st.policy }); toast(t("toast.saved"), "good"); } catch (e) { toast(String(e.message || e), "bad"); @@ -282,6 +369,7 @@ export async function renderRouting() { "div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))), + bandsCard(), ...Object.keys(st.tiers).map(tierRow) ), tasksCard(),