File size: 4,588 Bytes
dad80ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* Classify page: submit a complaint and render the result. */

document.addEventListener("DOMContentLoaded", () => {
  const input = $("#complaint");
  const btn = $("#btnClassify");
  const spin = $("#spin");
  const thr = $("#optThreshold");

  // r=37 in the ring SVG -> circumference 2*pi*37
  const RING_C = 2 * Math.PI * 37;

  thr.addEventListener("input", () => { $("#thrOut").value = (+thr.value).toFixed(2); });

  $$(".chip").forEach((chip) =>
    chip.addEventListener("click", () => {
      input.value = chip.dataset.sample;
      input.focus();
    })
  );

  $("#btnClear").addEventListener("click", () => {
    input.value = "";
    $("#result").classList.add("hidden");
    $("#resultEmpty").classList.remove("hidden");
    input.focus();
  });

  input.addEventListener("keydown", (e) => {
    if ((e.ctrlKey || e.metaKey) && e.key === "Enter") classify();
  });
  btn.addEventListener("click", classify);

  async function classify() {
    const text = input.value.trim();
    if (!text) { toast("Type a complaint first.", true); input.focus(); return; }

    btn.disabled = true;
    spin.classList.remove("hidden");
    try {
      const data = await api("/api/classify", {
        method: "POST",
        body: {
          text,
          translate: $("#optTranslate").checked,
          multi_label: $("#optMulti").checked,
          save: $("#optSave").checked,
          threshold: parseFloat(thr.value),
        },
      });
      render(data);
      toast(`Classified as "${data.predicted_label}" in ${data.took_ms} ms`);
    } catch (err) {
      toast(err.message, true);
    } finally {
      btn.disabled = false;
      spin.classList.add("hidden");
    }
  }

  function render(d) {
    $("#resultEmpty").classList.add("hidden");
    $("#result").classList.remove("hidden");

    $("#rLabel").textContent = d.predicted_label;
    $("#rTime").textContent = d.took_ms + " ms";
    $("#rEngine").textContent = d.engine;
    $("#rEngine").title = d.engine;

    // Confidence ring -- reset to empty first so the sweep animates every time.
    const ring = $("#rRing");
    const arc = $(".ring-fg", ring);
    ring.classList.toggle("low", !d.confident);
    $("#rConf").textContent = Math.round(d.confidence * 100) + "%";
    arc.style.strokeDasharray = RING_C;
    arc.style.strokeDashoffset = RING_C;
    requestAnimationFrame(() =>
      requestAnimationFrame(() => {
        arc.style.strokeDashoffset = RING_C * (1 - Math.min(d.confidence, 1));
      })
    );

    const t = d.translation;
    // Romanised text (Hindi typed in Latin letters, say) often detects with low
    // confidence even when the translation itself is fine -- say so rather than
    // presenting a shaky guess as fact.
    const shaky = t.detection_confidence > 0 && t.detection_confidence < 0.6 ? " · uncertain" : "";
    const lang = `${t.source_lang_name} (${t.source_lang})${shaky}`;
    $("#rLang").textContent = lang + (t.was_translated ? " → EN" : "");
    $("#rLang").title = t.was_translated ? `${lang}, translated to English` : lang;

    const warn = $("#rWarn");
    if (!d.confident) {
      $("#rWarnText").textContent =
        `Low confidence — the top score of ${(d.confidence * 100).toFixed(1)}% is below the ` +
        `${(d.threshold * 100).toFixed(0)}% threshold. Treat this as unclassified, or add a ` +
        `label that covers this kind of complaint.`;
      warn.classList.remove("hidden");
    } else {
      warn.classList.add("hidden");
    }

    $("#rOriginal").textContent = t.original_text;
    $("#rTranslated").textContent = t.translated_text;
    const note = $("#rNote");
    note.textContent = t.note || "";
    note.classList.toggle("hidden", !t.note);
    $("#rTransBox").open = t.was_translated;

    // Bars are scaled to the top score so small differences stay readable.
    const max = Math.max(...d.scores.map((s) => s.score), 0.0001);
    $("#rScores").innerHTML = d.scores
      .map((s, i) => `
        <div class="score-row ${i === 0 ? "top" : ""}" style="--h: ${hueOf(s.label)}">
          <span class="n" title="${escapeHtml(s.label)}">${escapeHtml(s.label)}</span>
          <span class="bar"><span data-w="${(s.score / max * 100).toFixed(1)}%"></span></span>
          <span class="v">${(s.score * 100).toFixed(1)}%</span>
        </div>`)
      .join("");

    // Stagger the bar fills so the ranking reads left-to-right, top-down.
    requestAnimationFrame(() =>
      $$("#rScores .bar > span").forEach((el, i) => {
        setTimeout(() => { el.style.width = el.dataset.w; }, 40 + i * 35);
      })
    );
  }
});