File size: 11,593 Bytes
532cbb3
8f21465
 
 
 
73caa3e
532cbb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be53b32
 
 
 
8f21465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532cbb3
 
 
 
96242ce
 
 
 
 
 
 
 
 
 
 
532cbb3
 
 
 
 
 
 
 
 
 
 
 
 
 
be53b32
96242ce
 
 
 
532cbb3
 
96242ce
532cbb3
 
 
 
 
 
 
 
 
 
 
 
 
96242ce
532cbb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96242ce
532cbb3
 
 
 
96242ce
 
 
 
 
 
532cbb3
 
 
 
 
 
 
 
 
 
 
 
73caa3e
532cbb3
 
 
 
 
 
 
 
 
 
 
 
be53b32
 
532cbb3
 
 
 
 
 
 
 
96242ce
 
 
532cbb3
 
 
 
 
 
 
 
 
 
 
 
 
73caa3e
 
be53b32
 
 
 
 
73caa3e
 
 
be53b32
73caa3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/* Nawah-Router demo — editable categories, live routing, connectors weighted by probability. */
/* Three backbones on the same head: a 6M bidirectional BERT encoder (Arabic), the 52M
   Llama decoder (Arabic), and a 6M BERT encoder pretrained bilingually (Arabic+English).
   Switching re-routes immediately so the comparison lands on the same text and the same
   categories. Declared here because run() references it. */
var currentModel = "6M-BERT";
const $ = id => document.getElementById(id);

const PRESETS = [
  { name: "توجيه مساعد ذكي",
    text: "عندي كود بايثون بيطلع خطأ عند قراءة ملف CSV كبير، ممكن تشوف السبب وتظبطه؟",
    cats: ["مهمة برمجية بسيطة", "مهمة برمجية معقدة", "ترجمة", "كتابة إبداعية",
           "بحث عن معلومة مباشرة", "محادثة عابرة"] },
  { name: "فرز خدمة العملاء",
    text: "الطلب تأخر ساعة ونصف عن الموعد والسائق ما رد على الاتصال، وأبغى تعويض عن التأخير.",
    cats: ["شكوى غاضبة تحتاج تصعيدًا فوريًا", "استفسار عادي غير مستعجل",
           "طلب استرجاع مبلغ", "رسالة شكر وثناء", "رسالة مزعجة إعلانية"] },
  { name: "تصنيف المحتوى",
    text: "بعد شهرين من الاستخدام اليومي، جودة الصوت ممتازة لكن البطارية لا تدوم أكثر من خمس ساعات.",
    cats: ["مراجعة منتج", "خبر اقتصادي", "إعلان تجاري", "مقال رأي", "محتوى تقني متقدم"] },
  { name: "تعقيد الطلب",
    text: "أحتاج خطة كاملة لترحيل قاعدة بيانات من MySQL إلى PostgreSQL مع الحفاظ على البيانات وبدون توقف الخدمة.",
    cats: ["طلب بسيط ومباشر", "يحتاج خطوتين أو ثلاثًا", "طلب معقّد متعدد الخطوات",
           "يحتاج نموذجًا أكبر"] },
  { name: "أي أداة نستدعي",
    text: "كم صار سعر صرف الدولار مقابل الجنيه اليوم؟",
    cats: ["بحث في الويب", "حاسبة", "تقويم ومواعيد", "قاعدة بيانات", "لا يحتاج أداة"] },
  { name: "دعم فني",
    text: "ما أقدر أسجل دخول للحساب، يطلع لي أن كلمة المرور غلط مع إني متأكد منها.",
    cats: ["مشكلة في الدخول إلى الحساب", "استفسار عن الفاتورة", "طلب ميزة جديدة",
           "بلاغ عطل حرج يوقف الخدمة", "أخرى / لا ينطبق"] },
  { name: "English: assistant routing",
    text: "My Python script throws an error reading a large CSV file, can you find the bug and fix it?",
    cats: ["simple coding task", "complex coding task", "translation", "creative writing",
           "direct factual lookup", "casual conversation"] },
  { name: "English: support triage",
    text: "The order is an hour and a half late and the driver isn't answering my calls. I want a refund for the delay.",
    cats: ["angry complaint needing immediate escalation", "routine non-urgent enquiry",
           "refund request", "thank-you message", "spam / promotional message"] },
  { name: "English: content classification",
    text: "After two months of daily use, the sound quality is excellent but the battery doesn't last more than five hours.",
    cats: ["product review", "economic news", "advertisement", "opinion piece", "advanced technical content"] },
  { name: "English: task complexity",
    text: "I need a full plan to migrate a database from MySQL to PostgreSQL, keeping the data intact with no service downtime.",
    cats: ["simple, direct request", "needs two or three steps", "complex multi-step request",
           "needs a larger model"] },
  { name: "English: tool routing",
    text: "What's today's exchange rate between the dollar and the pound?",
    cats: ["web search", "calculator", "calendar and scheduling", "database", "no tool needed"] },
  { name: "English: technical support",
    text: "I can't log into my account, it says my password is wrong even though I'm sure it's correct.",
    cats: ["account login issue", "billing enquiry", "feature request",
           "critical outage report", "other / not applicable"] },
];

let rows = [];            // [{el, input, fill, pct}]
let timer = null, seq = 0;
/* A half-typed category name is not a category, so edits to the lane list do not auto-route the
   way prompt edits do. Any change here sets `dirty`, which suspends auto-routing until the user
   submits — otherwise every keystroke would score a lane that does not exist yet. */
let dirty = false;

function setDirty(on) {
  dirty = on;
  $("go").classList.toggle("dirty", on);
  $("go").disabled = !on && !$("text").value.trim();
  $("dirtynote").textContent = on ? "تغيّرت الفئات — اضغط للتوجيه" : "";
}

function cats() {
  return rows.map(r => r.input.value.trim());
}

function addRow(value = "", focus = false) {
  const el = document.createElement("div");
  el.className = "row";
  el.innerHTML = `<div class="fill" style="width:0%"></div>
    <input type="text" value="" placeholder="اسم الفئة…">
    <span class="pct">—</span>
    <button class="del" type="button" title="حذف">×</button>`;
  const input = el.querySelector("input");
  input.value = value;
  input.dir = "auto";           // mixed Arabic/English category text, now that both route
  input.addEventListener("input", () => setDirty(true));
  input.addEventListener("keydown", ev => {
    if (ev.key === "Enter") { ev.preventDefault(); submit(); }
  });
  el.querySelector(".del").addEventListener("click", () => {
    rows = rows.filter(r => r.el !== el);
    el.remove(); drawWires(); setDirty(true);
  });
  $("rows").appendChild(el);
  rows.push({ el, input, fill: el.querySelector(".fill"), pct: el.querySelector(".pct") });
  if (focus) input.focus();
  drawWires();
}

function setPreset(p, btn) {
  document.querySelectorAll(".preset").forEach(b => b.classList.remove("on"));
  if (btn) btn.classList.add("on");
  $("text").value = p.text;
  $("rows").innerHTML = ""; rows = [];
  p.cats.forEach(c => addRow(c));
  updateCount(); setDirty(false); schedule(0);
}

/* Connectors: prompt -> model -> each category, thickness and opacity carrying the score.
   Redrawn on resize and whenever rows change, since row heights move. */
function drawWires(scores) {
  const svg = $("wires"), stage = svg.parentElement;
  if (window.innerWidth <= 900) { svg.innerHTML = ""; return; }
  const S = stage.getBoundingClientRect();
  const P = $("promptcard").getBoundingClientRect();
  const N = $("node").getBoundingClientRect();
  const rtl = getComputedStyle(document.body).direction === "rtl";
  // in RTL the prompt sits on the right, so wires run right-to-left
  const px = (rtl ? P.left : P.right) - S.left, py = P.top + P.height / 2 - S.top;
  const nIn = (rtl ? N.right : N.left) - S.left, nOut = (rtl ? N.left : N.right) - S.left;
  const ny = N.top + N.height / 2 - S.top;
  let d = `<path d="M ${px} ${py} C ${(px + nIn) / 2} ${py}, ${(px + nIn) / 2} ${ny}, ${nIn} ${ny}"
             fill="none" stroke="var(--acc-line)" stroke-width="2" opacity=".7"/>`;
  rows.forEach((r, i) => {
    const R = r.el.getBoundingClientRect();
    const cx = (rtl ? R.right : R.left) - S.left, cy = R.top + R.height / 2 - S.top;
    const s = scores ? scores[i] : null;
    const w = s == null ? 1.2 : 1 + s * 4.5;
    const o = s == null ? .3 : .18 + s * .72;
    d += `<path d="M ${nOut} ${ny} C ${(nOut + cx) / 2} ${ny}, ${(nOut + cx) / 2} ${cy}, ${cx} ${cy}"
            fill="none" stroke="var(--acc-line)" stroke-width="${w.toFixed(2)}"
            opacity="${o.toFixed(2)}" stroke-linecap="round"/>`;
  });
  svg.setAttribute("viewBox", `0 0 ${S.width} ${S.height}`);
  svg.innerHTML = d;
}

function updateCount() { $("charcount").textContent = $("text").value.length; }

function schedule(delay = 260) {
  updateCount();
  if (dirty) return;                 // lane list is mid-edit; wait for an explicit submit
  clearTimeout(timer);
  timer = setTimeout(run, delay);
}

function submit() {
  clearTimeout(timer);
  setDirty(false);
  run();
}

async function run() {
  const text = $("text").value.trim(), cs = cats().filter(Boolean);
  if (!text || !cs.length) {
    rows.forEach(r => { r.fill.style.width = "0%"; r.pct.textContent = "—";
                        r.el.classList.remove("top"); });
    $("stats").textContent = ""; drawWires(); return;
  }
  const my = ++seq;
  $("orb").classList.add("busy");
  try {
    const res = await fetch("/api/route", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text, cats: cats(), model: currentModel })
    });
    const data = await res.json();
    if (my !== seq) return;                      // a newer keystroke already superseded this
    const byName = new Map(data.results.map(r => [r.route, r.score]));
    const scores = rows.map(r => byName.get(r.input.value.trim()) ?? 0);
    const top = scores.indexOf(Math.max(...scores));
    rows.forEach((r, i) => {
      const s = scores[i];
      r.fill.style.width = (s * 100).toFixed(1) + "%";
      r.pct.textContent = (s * 100).toFixed(1) + "%";
      r.el.classList.toggle("top", i === top && s > 0);
    });
    const langLbl = data.lang === "ar" ? "عربي" : "English";
    $("stats").textContent = `${data.ms} ms · ${data.tokens} توكن · ${cs.length} فئة · ${langLbl}`;
    drawWires(scores);
  } catch (e) {
    $("stats").textContent = "تعذّر الاتصال";
  } finally {
    if (my === seq) $("orb").classList.remove("busy");
  }
}

$("text").addEventListener("input", () => { schedule(); if (dirty) setDirty(true); });
$("add").addEventListener("click", () => { addRow("", true); setDirty(true); });
$("go").addEventListener("click", submit);
window.addEventListener("resize", () => drawWires());

PRESETS.forEach((p, i) => {
  const b = document.createElement("button");
  b.className = "preset"; b.type = "button"; b.textContent = p.name;
  b.addEventListener("click", () => setPreset(p, b));
  $("presets").appendChild(b);
  if (i === 0) setTimeout(() => setPreset(p, b), 0);
});

fetch("/api/ready").then(r => r.json()).then(d => {
  $("dot").classList.add("on");
  $("nodemeta").textContent = `CPU · مسار واحد · حتى ${d.max_routes} فئات`;
  currentModel = d.default || currentModel;
  const pick = $("modelpick");
  const ICON_LABEL = {
    "6M-BERT": "🔵 6M encoder (AR)",
    "52M": "🧱 52M decoder (AR)",
    "6M-BILINGUAL": "🌐 6M encoder (AR+EN)",
  };
  (d.models || []).forEach(m => {
    const o = document.createElement("option");
    o.value = m.key;
    o.textContent = ICON_LABEL[m.key] || m.key;
    o.title = m.label;
    if (m.key === currentModel) o.selected = true;
    pick.appendChild(o);
  });
  const sizeOf = k => {
    const m = (d.models || []).find(x => x.key === k);
    return m ? (m.params / 1e6).toFixed(1) + "M" : "";
  };
  $("modelsize").textContent = sizeOf(currentModel);
  pick.addEventListener("change", () => {
    currentModel = pick.value;
    $("modelsize").textContent = sizeOf(currentModel);
    run();                       // same text, same categories, other backbone
  });
}).catch(() => { $("modelsize").textContent = "غير متصل"; });