/* =========================================================
   viz.jsx — interactive benches ("声控台") + shared prelude
   ---------------------------------------------------------
   Dependency-free. Each chapter sets `viz: "<name>"` in
   data.jsx; the chapter page renders <Viz name={...} />.
   Every bench computes its numbers live — real availability
   products, real queueing, real bin-packing. No canned art.
   This file: the shared helpers + Module I–II (v1–v7),
   exported as window.__VO_VIZ_1. Module III–IV live in
   viz2.jsx, V–VII in viz3.jsx, VII–IX + the registry +
   <Viz> in viz4.jsx; index.html loads them in that order.
   ========================================================= */

/* ---------------- shared math helpers ---------------- */
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const nf = (n, d = 2) => {
  if (!isFinite(n)) return "∞";
  const r = Math.abs(n) >= 1000 ? Math.round(n) : Math.round(n * 10 ** d) / 10 ** d;
  return r.toLocaleString("en-US", { maximumFractionDigits: d });
};
const pct = (x) => `${Math.round(x * 100)}%`;
const pct1 = (x) => `${nf(x * 100, 1)}%`;
const pct2 = (x) => `${nf(x * 100, 2)}%`;
const pct3 = (x) => `${nf(x * 100, 3)}%`;
const big = (n) => {
  if (Math.abs(n) >= 1e9) return `${nf(n / 1e9, 2)}G`;
  if (Math.abs(n) >= 1e6) return `${nf(n / 1e6, 2)}M`;
  if (Math.abs(n) >= 1e3) return `${nf(n / 1e3, 1)}K`;
  return nf(n, 0);
};
// number of "nines": 0.999 -> 3
const nines = (a) => (a >= 1 ? 9 : Math.max(0, -Math.log10(1 - a)));
// minutes of downtime per year for an availability fraction
const downMin = (a) => Math.max(0, (1 - a) * 525600);
// Deterministic PRNG so every run is reproducible across renders.
function rng(seed) {
  let s = seed >>> 0 || 1;
  return () => {
    s ^= s << 13; s >>>= 0;
    s ^= s >> 17;
    s ^= s << 5; s >>>= 0;
    return s / 4294967296;
  };
}
function gauss(r) {
  const u = Math.max(1e-9, r()), v = r();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}

/* ---------------- shared controls ---------------- */
function Slider({ label, min, max, step, value, onChange, unit, fmt }) {
  return (
    <label>
      <span>{label}</span>
      <input type="range" min={min} max={max} step={step || 1} value={value}
        onChange={(e) => onChange(parseFloat(e.target.value))} />
      <span className="val">{fmt ? fmt(value) : value}{unit || ""}</span>
    </label>
  );
}
function Choice({ label, value, onChange, options }) {
  return (
    <label>
      <span>{label}</span>
      <select className="vo-select" value={value} onChange={(e) => onChange(e.target.value)}>
        {options.map((o) => {
          const v = typeof o === "object" ? o.v : o;
          const l = typeof o === "object" ? o.l : o;
          return <option key={v} value={v}>{l}</option>;
        })}
      </select>
    </label>
  );
}
function Seg({ value, onChange, options }) {
  return (
    <div className="vo-seg">
      {options.map((o) => (
        <button key={o.v} className={value === o.v ? "on" : ""} onClick={() => onChange(o.v)}>{o.l}</button>
      ))}
    </div>
  );
}
function Toggle({ label, value, onChange }) {
  return (
    <label style={{ cursor: "pointer" }} onClick={() => onChange(!value)}>
      <span>{label}</span>
      <span className={`vo-pill click ${value ? "on" : ""}`} style={{ justifySelf: "start" }}>{value ? "ON" : "OFF"}</span>
    </label>
  );
}
function Kpi({ label, value, unit, hint, tone, sel, onClick }) {
  return (
    <div className={`vo-kpi ${tone || ""} ${sel ? "sel" : ""}`} onClick={onClick}>
      <div className="k-label">{label}</div>
      <div className="k-val">{value}{unit ? <span className="k-unit">{unit}</span> : null}</div>
      {hint ? <div className="k-hint">{hint}</div> : null}
    </div>
  );
}
function Bar({ label, value, max, tone, valText }) {
  const w = clamp((value / (max || 1)) * 100, 0, 100);
  return (
    <div className="vo-bar-row">
      <span>{label}</span>
      <div className="b-track"><div className={`b-fill ${tone || ""}`} style={{ width: `${w}%` }} /></div>
      <span className="b-val">{valText !== undefined ? valText : nf(value, 1)}</span>
    </div>
  );
}
function VizHead({ idx, title }) {
  return <div className="viz-title"><span className="viz-title-idx">{idx}</span><span>{title}</span></div>;
}
function Note({ mark, children, tone }) {
  return <div className={`vo-step ${tone || ""}`}><span className="sn">{mark}</span><div>{children}</div></div>;
}
function Label({ children }) { return <span className="vo-label">{children}</span>; }

// site language → inline bilingual label helper
function useL() {
  const lang = useLang();
  return (zh, en) => (lang === "zh" ? zh : en);
}

/* ---------------- shared viz primitives ---------------- */
// A tiny SVG line plot: data = [{x,y}], marks an index, optional target line.
function MiniPlot({ data, w = 300, h = 110, stroke = "var(--primary)", markIndex, yMax, yMin, pad = 8, fmtY }) {
  if (!data || !data.length) return null;
  const xs = data.map((d) => d.x), ys = data.map((d) => d.y);
  const x0 = Math.min(...xs), x1 = Math.max(...xs);
  const lo = yMin !== undefined ? yMin : Math.min(...ys, 0);
  const hi = yMax !== undefined ? yMax : Math.max(...ys) * 1.08 || 1;
  const px = (x) => pad + ((x - x0) / (x1 - x0 || 1)) * (w - 2 * pad);
  const py = (y) => h - pad - ((y - lo) / (hi - lo || 1)) * (h - 2 * pad);
  const path = data.map((d, i) => `${i ? "L" : "M"}${px(d.x).toFixed(1)},${py(d.y).toFixed(1)}`).join(" ");
  const mk = markIndex != null ? data[clamp(markIndex, 0, data.length - 1)] : null;
  return (
    <svg viewBox={`0 0 ${w} ${h}`} width="100%" style={{ display: "block" }}>
      <line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="var(--hairline-strong)" strokeWidth="1" />
      <path d={path} fill="none" stroke={stroke} strokeWidth="2" />
      {mk && <line x1={px(mk.x)} y1={pad} x2={px(mk.x)} y2={h - pad} stroke="var(--accent)" strokeWidth="1.5" strokeDasharray="3 3" />}
      {mk && <circle cx={px(mk.x)} cy={py(mk.y)} r="3.5" fill="var(--accent)" />}
    </svg>
  );
}

// A row of small server/instance boxes with a live/dead/degraded state.
function Boxes({ items, onClick }) {
  const C = { live: "var(--primary)", ok: "#2e9e6b", dead: "#c0453f", warn: "#d98a1f", idle: "var(--muted)" };
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 5, marginTop: 6 }}>
      {items.map((it, i) => (
        <div key={i} onClick={onClick ? () => onClick(i) : undefined}
          title={it.title || ""}
          style={{
            minWidth: 30, padding: "5px 7px", textAlign: "center", cursor: onClick ? "pointer" : "default",
            font: "600 11px var(--f-mono)", borderRadius: 4, color: "#fff",
            background: `color-mix(in srgb, ${C[it.state] || C.idle} 82%, transparent)`,
            border: `1px solid color-mix(in srgb, ${C[it.state] || C.idle} 60%, var(--bg))`,
          }}>{it.label}</div>
      ))}
    </div>
  );
}


/* ---------------- domain helpers shared by every bench ---------------- */
// Erlang-B blocking probability for A erlangs offered to N servers (no queue).
function erlangB(n, a) {
  let b = 1;
  for (let i = 1; i <= n; i++) b = (a * b) / (i + a * b);
  return clamp(b, 0, 1);
}
// Erlang-C (calls queue instead of being lost) — used by the handoff bench.
function erlangC(n, a) {
  if (a >= n) return 1;
  const eb = erlangB(n, a);
  return eb / (1 - (a / n) * (1 - eb));
}
const yuan = (v) => `¥${nf(v, 0)}`;
const ms = (v) => `${nf(v, 0)} ms`;

/* =========================================================
   v1 · shopLab — what missed calls cost a shop each month
   ========================================================= */
function ShopViz() {
  const L = useL();
  const [calls, setCalls] = React.useState(120);   // calls per business day
  const [peak, setPeak] = React.useState(0.42);    // share of calls inside the 3 peak hours
  const [desk, setDesk] = React.useState(1);       // front-desk people who can hold a call
  const [aht, setAht] = React.useState(150);       // average handle time, seconds
  const [ticket, setTicket] = React.useState(238); // average ticket, yuan
  const [ai, setAi] = React.useState(false);

  const conv = 0.55;        // a caught booking call becomes a visit
  const bookShare = 0.45;   // only this share of calls carry booking intent at all
  const comeBack = 0.35;    // some blocked callers do try again later
  const repeat = 1.8;       // lifetime multiplier of a first visit
  const AI_LINES = 8;       // an AI answers this many calls at once
  const lines = desk + (ai ? AI_LINES : 0);

  const h = aht / 3600;                                  // hours per call
  const peakRate = (calls * peak) / 3;                   // calls/hour in the 3 peak hours
  const offRate = (calls * (1 - peak)) / 9;              // calls/hour across the other 9
  const bPeak = erlangB(lines, peakRate * h);
  const bOff = erlangB(lines, offRate * h);
  const missed = calls * peak * bPeak + calls * (1 - peak) * bOff;
  const lostCalls = missed * 30 * bookShare * (1 - comeBack);   // genuinely lost bookings
  const lostMonth = lostCalls * conv * ticket;                  // this month's revenue
  const lostLifetime = lostMonth * repeat;                      // with repeat business

  // same shop, no AI — for the recovered figure
  const b0p = erlangB(desk, peakRate * h), b0o = erlangB(desk, offRate * h);
  const missed0 = calls * peak * b0p + calls * (1 - peak) * b0o;
  const recovered = (missed0 - missed) * 30 * bookShare * (1 - comeBack) * conv * ticket;

  const curve = [];
  for (let c = 40; c <= 300; c += 10) {
    const pr = (c * peak) / 3;
    curve.push({ x: c, y: erlangB(lines, pr * h) });
  }

  return (
    <div>
      <VizHead idx="BZ1" title={L("漏接账:高峰时段接不住的电话,每月值多少钱", "The missed-call bill: what the calls you cannot answer cost per month")} />
      <div className="viz-ctrl">
        <Slider label={L("日来电量", "Calls per day")} min={40} max={300} step={5} value={calls} onChange={setCalls} />
        <Slider label={L("高峰 3 小时占比", "Share in the 3 peak hours")} min={0.25} max={0.65} step={0.01} value={peak} onChange={setPeak} fmt={pct} />
        <Slider label={L("前台可同时接听", "Front desk, concurrent calls")} min={1} max={4} value={desk} onChange={setDesk} />
        <Slider label={L("平均通话时长", "Average handle time")} min={60} max={300} step={10} value={aht} onChange={setAht} unit=" s" />
        <Slider label={L("客单价", "Average ticket")} min={88} max={498} step={10} value={ticket} onChange={setTicket} fmt={(v) => yuan(v)} />
        <Toggle label={L("接入 AI 语音客服", "Add the AI voice agent")} value={ai} onChange={setAi} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("高峰呼损率", "Peak blocking")} value={pct1(bPeak)} tone={bPeak > 0.15 ? "warn" : "ok"} hint={L(`话务强度 ${nf(peakRate * h, 1)} 爱尔兰`, `${nf(peakRate * h, 1)} erlangs offered`)} />
        <Kpi label={L("每天漏接", "Missed per day")} value={nf(missed, 0)} unit={L(" 通", " calls")} tone={missed > 10 ? "warn" : "ok"} />
        <Kpi label={L("每月流失", "Lost per month")} value={yuan(lostMonth)} tone={lostMonth > 20000 ? "warn" : "acc"} hint={L(`含复购约 ${yuan(lostLifetime)}`, `${yuan(lostLifetime)} with repeat business`)} />
        <Kpi label={ai ? L("AI 挽回", "Recovered by AI") : L("这就是你的预算上限", "Your budget ceiling")} value={ai ? yuan(recovered) : yuan(lostMonth)} tone={ai ? "ok" : "acc"} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("高峰时段呼损", "Peak blocking")} value={bPeak} max={0.6} tone={bPeak > 0.15 ? "warn" : "ok"} valText={pct1(bPeak)} />
        <Bar label={L("平峰时段呼损", "Off-peak blocking")} value={bOff} max={0.6} tone="acc" valText={pct1(bOff)} />
        <Bar label={L("无 AI 时的高峰呼损", "Peak blocking without AI")} value={b0p} max={0.6} tone="mut" valText={pct1(b0p)} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("高峰呼损率随日来电量的变化(虚线为当前话量)", "Peak blocking against daily call volume (dashed line = current volume)")}</div>
        <MiniPlot data={curve} markIndex={Math.round((calls - 40) / 10)} yMin={0} yMax={Math.max(0.1, Math.max(...curve.map((d) => d.y)) * 1.1)} />
      </div>

      <Note mark="→" tone={bPeak > 0.15 ? "bad" : "on"}>
        {bPeak > 0.15
          ? L(`高峰时段有 ${pct1(bPeak)} 的来电听到忙音或没人接。电话不排队,大多数人不会再打第二次,而是打给隔壁。只算其中带预约意图的 ${pct(bookShare)}、并假设 ${pct(comeBack)} 的人还会再打一次,这仍然是每月 ${yuan(lostMonth)} 的营业额。`,
              `${pct1(bPeak)} of peak calls hear a busy tone or ring out. The phone does not queue — most do not call back, they call the shop next door. Counting only the ${pct(bookShare)} that carry booking intent, and assuming ${pct(comeBack)} do ring again, that is ${yuan(lostMonth)} of revenue a month.`)
          : L(`当前配置下高峰呼损只有 ${pct1(bPeak)},接得住。把日话量拉高或把前台减到 1 人,看这条曲线怎么翻上去。`,
              `At this configuration peak blocking is only ${pct1(bPeak)} — you are catching them. Raise the volume or drop to one front-desk person and watch the curve turn up.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v2 · autoLab — which enquiries should go to the AI
   ========================================================= */
const ENQ_TYPES = [
  { k: "price",   zh: "问价格与项目",   en: "Price and services",       share: 0.22, cx: 1, write: false, empathy: 0 },
  { k: "hours",   zh: "营业时间与地址", en: "Hours, address, parking",  share: 0.14, cx: 1, write: false, empathy: 0 },
  { k: "avail",   zh: "今天还有空位吗", en: "Any slot today",           share: 0.15, cx: 2, write: false, empathy: 0 },
  { k: "book",    zh: "预约",           en: "Make a booking",           share: 0.16, cx: 2, write: true,  empathy: 0 },
  { k: "change",  zh: "改约与取消",     en: "Reschedule or cancel",     share: 0.08, cx: 2, write: true,  empathy: 1 },
  { k: "staff",   zh: "指定技师在不在", en: "Is a therapist available", share: 0.06, cx: 2, write: false, empathy: 0 },
  { k: "member",  zh: "会员卡与余额",   en: "Membership and balance",   share: 0.05, cx: 3, write: false, empathy: 0 },
  { k: "coupon",  zh: "团购券核销规则", en: "Voucher redemption rules", share: 0.05, cx: 3, write: true,  empathy: 1 },
  { k: "gift",    zh: "送礼卡与开发票", en: "Gift cards and invoices",  share: 0.03, cx: 3, write: true,  empathy: 1 },
  { k: "body",    zh: "身体状况咨询",   en: "Health-condition questions", share: 0.04, cx: 4, write: false, empathy: 3 },
  { k: "complain",zh: "投诉与不满",     en: "Complaints",               share: 0.015, cx: 4, write: true,  empathy: 4 },
  { k: "oob",     zh: "越界试探",       en: "Out-of-bounds probes",     share: 0.005, cx: 4, write: false, empathy: 4 },
];
const AI_OK = { 1: 0.96, 2: 0.88, 3: 0.68, 4: 0.42 }; // AI success rate by complexity

function AutoViz() {
  const L = useL();
  const lang = useLang();
  const [on, setOn] = React.useState(() => ({ price: true, hours: true, avail: true, book: true, change: true, staff: true, member: false, coupon: false, gift: false, body: false, complain: false, oob: false }));
  const [calls, setCalls] = React.useState(120);
  const [aht, setAht] = React.useState(150);

  let solved = 0, handoff = 0, csatPenalty = 0, deflected = 0;
  ENQ_TYPES.forEach((t) => {
    if (on[t.k]) {
      const ok = AI_OK[t.cx];
      solved += t.share * ok;
      handoff += t.share * (1 - ok);
      // empathy-heavy work handled by a machine loses the customer even when "solved"
      const bad = t.share * (1 - ok) * (0.25 + 0.18 * t.empathy);
      deflected += bad;
      csatPenalty += t.share * t.empathy * 0.42;
    } else {
      handoff += t.share;
    }
  });
  const hoursSaved = (solved * calls * aht) / 3600;
  const csat = clamp(4.6 - csatPenalty, 2.6, 5);
  const monthlyHours = hoursSaved * 30;
  const staffCost = monthlyHours * 32; // yuan per front-desk hour, fully loaded

  return (
    <div>
      <VizHead idx="BZ2" title={L("任务分级:点选哪些咨询交给 AI,看自助率、工时与满意度一起动", "Task triage: assign enquiries to the AI and watch self-service, hours and satisfaction move together")} />
      <div className="viz-ctrl">
        <Slider label={L("日来电量", "Calls per day")} min={40} max={300} step={5} value={calls} onChange={setCalls} />
        <Slider label={L("平均通话时长", "Average handle time")} min={60} max={300} step={10} value={aht} onChange={setAht} unit=" s" />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("自助解决率", "Self-service rate")} value={pct1(solved)} tone={solved > 0.7 ? "ok" : "acc"} />
        <Kpi label={L("转人工率", "Handoff rate")} value={pct1(handoff)} tone={handoff > 0.4 ? "warn" : "ok"} />
        <Kpi label={L("每月省下前台", "Front-desk hours saved")} value={nf(monthlyHours, 0)} unit={L(" 小时", " h")} hint={yuan(staffCost)} tone="acc" />
        <Kpi label={L("满意度(5 分制)", "Satisfaction (of 5)")} value={nf(csat, 2)} tone={csat < 4.0 ? "warn" : "ok"} hint={L(`被劝退 ${pct1(deflected)}`, `${pct1(deflected)} deflected`)} />
      </div>

      <div style={{ marginTop: 10 }} className="vo-cap">{L("点一下把这类咨询交给 AI 或收回人工:", "Click to hand a type to the AI or take it back:")}</div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 5, marginTop: 5 }}>
        {ENQ_TYPES.map((t) => (
          <button key={t.k} className={`vo-pill mini click ${on[t.k] ? "on" : ""}`}
            onClick={() => setOn({ ...on, [t.k]: !on[t.k] })}
            title={L(`占比 ${pct(t.share)} · 复杂度 ${t.cx} · ${t.write ? "写操作" : "只读"}`, `${pct(t.share)} of volume · complexity ${t.cx} · ${t.write ? "write" : "read-only"}`)}>
            {lang === "zh" ? t.zh : t.en} · {pct(t.share)}{t.empathy >= 3 ? " ⚠" : ""}
          </button>
        ))}
      </div>

      <div style={{ marginTop: 12 }}>
        <Bar label={L("AI 自助解决", "Solved by AI")} value={solved} max={1} tone="ok" valText={pct1(solved)} />
        <Bar label={L("转人工", "Handed to a human")} value={handoff} max={1} tone="acc" valText={pct1(handoff)} />
        <Bar label={L("被劝退(没解决也没转好)", "Deflected (neither solved nor transferred well)")} value={deflected} max={1} tone="warn" valText={pct1(deflected)} />
      </div>

      <Note mark="⚠" tone={csat < 4.0 ? "bad" : "on"}>
        {csat < 4.0
          ? L("你把需要共情或需要授权的类别(带 ⚠ 的三类)也交给了 AI:自助率的数字上去了,满意度和被劝退比例一起恶化——顾客不是被解决了,是被打发了。",
              "You handed the empathy-or-authority categories (the three marked ⚠) to the machine: the self-service number went up while satisfaction and deflection both got worse. Those customers were not served, they were deflected.")
          : L("头部五类占了八成话量,答案确定、不需要判断力——这正是 AI 的区间;把带 ⚠ 的三类留给人,满意度才守得住。",
              "The top five categories are eighty percent of volume with definite answers and no judgement required — exactly the AI's zone. Leave the three marked ⚠ to people and satisfaction holds.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v3 · callLab — turns, slots and the chained success rate
   ========================================================= */
function CallViz() {
  const L = useL();
  const [p, setP] = React.useState(0.95);       // per-turn success
  const [slots, setSlots] = React.useState(6);  // slots to collect
  const [multi, setMulti] = React.useState(false);
  const [defaults, setDefaults] = React.useState(false);
  const [turnSec, setTurnSec] = React.useState(9);

  const slotTurns = multi ? Math.ceil(slots / 2.5) : slots;
  const turns = 2 + slotTurns + 1 + 1 + 1 - (defaults ? 1 : 0); // greet+intent, slots, availability, confirm, close
  const success = Math.pow(p, turns);
  const dur = turns * turnSec;

  const altAcc = Math.pow(clamp(p + 0.01, 0, 0.999), turns);          // a real model upgrade
  const altTurns = Math.pow(p, Math.max(4, turns - 3));               // fewer turns
  const steps = [
    { zh: "问候", en: "greet" }, { zh: "听意图", en: "intent" },
    ...Array.from({ length: slotTurns }, (_, i) => ({ zh: `补槽位 ${i + 1}`, en: `slot ${i + 1}` })),
    { zh: "查空档", en: "availability" }, { zh: "确认", en: "confirm" }, { zh: "收尾", en: "close" },
  ].slice(0, turns);

  const curve = [];
  for (let t = 3; t <= 14; t++) curve.push({ x: t, y: Math.pow(p, t) });

  return (
    <div>
      <VizHead idx="BZ3" title={L("轮次连乘:每轮 95% 听起来很高,八轮之后只剩 66%", "Chained turns: 95% per turn sounds excellent, and eight turns leave 66%")} />
      <div className="viz-ctrl">
        <Slider label={L("每轮成功率", "Per-turn success")} min={0.85} max={0.99} step={0.005} value={p} onChange={setP} fmt={pct1} />
        <Slider label={L("需要收集的槽位", "Slots to collect")} min={3} max={8} value={slots} onChange={setSlots} />
        <Slider label={L("每轮耗时", "Seconds per turn")} min={5} max={18} value={turnSec} onChange={setTurnSec} unit=" s" />
        <Toggle label={L("一句话多槽抽取", "Multi-slot extraction")} value={multi} onChange={setMulti} />
        <Toggle label={L("默认值兜底(不指定技师)", "Default fallback (any therapist)")} value={defaults} onChange={setDefaults} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("总轮次", "Total turns")} value={turns} tone={turns > 9 ? "warn" : "ok"} />
        <Kpi label={L("自助成交率", "Completion rate")} value={pct1(success)} tone={success < 0.6 ? "warn" : success > 0.8 ? "ok" : "acc"} hint={`${pct1(p)}^${turns}`} />
        <Kpi label={L("通话时长", "Call duration")} value={nf(dur, 0)} unit=" s" tone="acc" />
        <Kpi label={L("每轮都要听对", "Every turn must land")} value={nf(1 - success, 2)} hint={L("失败概率", "failure probability")} tone="mut" />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("现在", "Now")} value={success} max={1} tone="acc" valText={pct1(success)} />
        <Bar label={L("每轮准确率 +1 个点(换更贵的模型)", "Per-turn +1 point (a pricier model)")} value={altAcc} max={1} tone="ok" valText={pct1(altAcc)} />
        <Bar label={L("少问三轮(改提示词就行)", "Three fewer turns (a prompt rewrite)")} value={altTurns} max={1} tone="ok" valText={pct1(altTurns)} />
      </div>

      <div style={{ marginTop: 10 }} className="vo-cap">{L("这一通的轮次骨架:", "The turn skeleton of this call:")}</div>
      <Boxes items={steps.map((s, i) => ({ label: L(s.zh, s.en), state: i < 2 ? "ok" : i >= turns - 2 ? "warn" : "live" }))} />

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("成交率随轮次的衰减(虚线为当前轮次)", "Completion decaying with turn count (dashed = current)")}</div>
        <MiniPlot data={curve} markIndex={turns - 3} yMin={0.3} yMax={1} />
      </div>

      <Note mark="→" tone={altTurns > altAcc ? "now" : "on"}>
        {altTurns > altAcc
          ? L(`少问三轮把成交率推到 ${pct1(altTurns)},把每轮准确率提一个点只到 ${pct1(altAcc)}——前者改提示词就行,后者要换更贵的模型甚至换厂商。在对话系统里,减少交互次数几乎总是比提升单次准确率更划算。`,
              `Three fewer turns reaches ${pct1(altTurns)}; one more point of per-turn accuracy only reaches ${pct1(altAcc)} — the first is a prompt rewrite, the second a more expensive model or a different vendor. Removing an interaction almost always beats improving one.`)
          : L("把轮次压到很低之后,单轮准确率才重新成为瓶颈——那时才值得为模型多花钱。",
              "Only once the turn count is already low does per-turn accuracy become the bottleneck again — that is when paying more for the model starts to pay back.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v4 · audioLab — sample rate, codecs and the narrowband phone
   ========================================================= */
const CODECS = [
  { k: "g711",  label: "8k G.711 (PCMU)",  sr: 8000,  kbps: 64,   zh: "电话默认,无压缩失真", en: "telephony default, no compression artefacts" },
  { k: "g729",  label: "8k G.729",         sr: 8000,  kbps: 8,    zh: "省带宽,压缩损伤明显", en: "bandwidth-thrifty, audible damage" },
  { k: "opus8", label: "8k Opus",          sr: 8000,  kbps: 24,   zh: "窄带里最好的压缩", en: "best compression inside narrowband" },
  { k: "pcm16", label: "16k PCM16",        sr: 16000, kbps: 256,  zh: "ASR 模型的标准输入", en: "the standard ASR input" },
  { k: "opus16",label: "16k Opus",         sr: 16000, kbps: 32,   zh: "网页/小程序推荐", en: "recommended for web and mini-programs" },
  { k: "pcm48", label: "48k PCM16",        sr: 48000, kbps: 768,  zh: "录音棚,ASR 用不上", en: "studio; wasted on ASR" },
];
function AudioViz() {
  const L = useL();
  const lang = useLang();
  const [ci, setCi] = React.useState(0);
  const [calls, setCalls] = React.useState(120);
  const [aht, setAht] = React.useState(150);
  const c = CODECS[ci];

  const nyq = c.sr / 2;
  const bytesMin = (c.kbps * 1000 * 60) / 8;
  const monthGB = (bytesMin / 60) * aht * calls * 30 / 1e9;
  // fricative discrimination energy sits roughly 4–8 kHz; what fraction survives
  const fricKept = clamp((nyq - 4000) / 4000, 0, 1);
  // compression damage on top of the band limit
  const codecPenalty = c.kbps <= 8 ? 0.022 : c.kbps <= 24 ? 0.008 : 0;
  const cer = 0.03 + (1 - fricKept) * 0.055 + codecPenalty;
  const digitOk = Math.pow(1 - cer, 11);

  const bands = [];
  for (let f = 0; f < 8000; f += 500) bands.push(f);

  return (
    <div>
      <VizHead idx="AS1" title={L("奈奎斯特:8 kHz 采样的电话,永远听不到 4 kHz 以上", "Nyquist: an 8 kHz phone line can never hear above 4 kHz")} />
      <div className="viz-ctrl">
        <Choice label={L("链路编码", "Link codec")} value={String(ci)} onChange={(v) => setCi(parseInt(v, 10))}
          options={CODECS.map((x, i) => ({ v: String(i), l: x.label }))} />
        <Slider label={L("日来电量", "Calls per day")} min={40} max={300} step={5} value={calls} onChange={setCalls} />
        <Slider label={L("平均时长", "Average duration")} min={60} max={300} step={10} value={aht} onChange={setAht} unit=" s" />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("可记录最高频率", "Highest recordable frequency")} value={nf(nyq / 1000, 1)} unit=" kHz" tone={nyq >= 8000 ? "ok" : "warn"} hint={L("采样率 ÷ 2", "sample rate ÷ 2")} />
        <Kpi label={L("码率", "Bitrate")} value={c.kbps} unit=" kbps" tone="acc" hint={`${nf(bytesMin / 1024, 0)} KB/min`} />
        <Kpi label={L("每月录音存储", "Monthly recording storage")} value={nf(monthGB, 1)} unit=" GB" tone="mut" />
        <Kpi label={L("估计字错率", "Estimated CER")} value={pct1(cer)} tone={cer > 0.07 ? "warn" : "ok"} hint={L(`11 位手机号一次对:${pct(digitOk)}`, `11-digit number right first time: ${pct(digitOk)}`)} />
      </div>

      <div style={{ marginTop: 12 }}>
        <div className="vo-cap">{L("0–8 kHz 频带:深色是被这条链路保留下来的部分,灰色是永远拿不到的", "The 0–8 kHz band: dark is what this link keeps, grey is gone forever")}</div>
        <svg viewBox="0 0 600 90" width="100%" style={{ display: "block", marginTop: 4 }}>
          {bands.map((f, i) => {
            const kept = f < nyq;
            const hgt = 12 + 46 * Math.exp(-Math.pow((f - 900) / 1700, 2)) + 16 * Math.exp(-Math.pow((f - 5200) / 1900, 2));
            return <rect key={i} x={6 + i * 37} y={70 - hgt} width={32} height={hgt} rx="2"
              fill={kept ? "var(--primary)" : "var(--surface-2)"} opacity={kept ? 0.85 : 1}
              stroke={kept ? "var(--primary)" : "var(--hairline-strong)"} />;
          })}
          <line x1={6 + (nyq / 500) * 37 - 3} y1="6" x2={6 + (nyq / 500) * 37 - 3} y2="74" stroke="var(--accent)" strokeDasharray="4 3" strokeWidth="1.6" />
          <text x={6 + (nyq / 500) * 37 + 3} y="16" style={{ font: "600 10px var(--f-mono)", fill: "var(--accent)" }}>{nf(nyq / 1000, 1)} kHz</text>
          <text x="10" y="86" style={{ font: "500 9px var(--f-mono)", fill: "var(--muted)" }}>0</text>
          <text x="200" y="86" style={{ font: "500 9px var(--f-mono)", fill: "var(--muted)" }}>{L("元音 · 共振峰", "vowels · formants")}</text>
          <text x="420" y="86" style={{ font: "500 9px var(--f-mono)", fill: "var(--muted)" }}>{L("擦音 s / sh / f / x", "fricatives s / sh / f / x")}</text>
        </svg>
      </div>

      <div style={{ marginTop: 8 }}>
        <Bar label={L("擦音辨别能量保留", "Fricative energy kept")} value={fricKept} max={1} tone={fricKept > 0.5 ? "ok" : "warn"} valText={pct(fricKept)} />
        <Bar label={L("11 位手机号一次听对", "Phone number right first time")} value={digitOk} max={1} tone={digitOk > 0.6 ? "ok" : "warn"} valText={pct(digitOk)} />
      </div>

      <Note mark="→" tone={nyq <= 4000 ? "bad" : "on"}>
        {nyq <= 4000
          ? L(`${pick(lang, { zh: c.zh, en: c.en })}。4 kHz 以上全部丢失,而「四」和「十」、「十四」和「四十」的区别主要就在那里——这不是模型不够好,是物理上限。对策:关键数字复述确认、给数字加热词、并在 IM 渠道上尽量走 16 kHz。`,
              `${pick(lang, { zh: c.zh, en: c.en })}. Everything above 4 kHz is gone, and that is largely where si and shi, four and ten differ — not a weak model but a physical ceiling. Remedies: read back critical digits, bias the decoder toward digits, and prefer 16 kHz wherever the channel allows.`)
          : L(`宽带链路保住了擦音能量,字错率明显低于电话。能走 16 kHz 的渠道(小程序、网页、App)就别降到 8 kHz。`,
              `A wideband link keeps the fricative energy and the error rate falls well below telephony. Wherever the channel allows 16 kHz — mini-program, web, app — do not drop to 8.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v5 · asrLab — CTC / RNN-T / Paraformer / Whisper
   ========================================================= */
const ASR_ARCH = [
  { k: "ctc",   name: "CTC",        stream: true,  cerBase: 0.062, lookMin: 0,   rtf: 0.04, zh: "帧独立解码,快但读不通", en: "frame-independent, fast, reads oddly" },
  { k: "rnnt",  name: "RNN-T",      stream: true,  cerBase: 0.044, lookMin: 120, rtf: 0.09, zh: "流式主力,带语言建模", en: "the streaming workhorse, models language" },
  { k: "para",  name: "Paraformer", stream: false, cerBase: 0.038, lookMin: 0,   rtf: 0.012,zh: "非自回归,一次并行出整句", en: "non-autoregressive, whole utterance at once" },
  { k: "aed",   name: "Whisper/AED",stream: false, cerBase: 0.035, lookMin: 0,   rtf: 0.16, zh: "全局注意力,准但难流式", en: "global attention, accurate, resists streaming" },
];
function AsrViz() {
  const L = useL();
  const lang = useLang();
  const [ai2, setAi2] = React.useState("rnnt");
  const [chunk, setChunk] = React.useState(320);
  const [look, setLook] = React.useState(160);
  const [narrow, setNarrow] = React.useState(true);

  const a = ASR_ARCH.find((x) => x.k === ai2);
  const ctx = chunk + look;
  // less context → more errors; the penalty saturates as context grows
  const ctxPenalty = a.stream ? 0.9 / (1 + ctx / 220) : 0;
  const cer = a.cerBase * (1 + ctxPenalty) * (narrow ? 1.7 : 1);
  const firstToken = a.stream ? chunk + look + 40 : 0;   // ms until partial text appears
  const finalize = a.stream ? chunk + look + 90 : 1400;  // ms after speech ends
  const digitOk = Math.pow(1 - cer, 11);

  const curve = [];
  for (let ch = 80; ch <= 960; ch += 40) {
    const pen = a.stream ? 0.9 / (1 + (ch + look) / 220) : 0;
    curve.push({ x: ch, y: a.cerBase * (1 + pen) * (narrow ? 1.7 : 1) });
  }

  return (
    <div>
      <VizHead idx="AS2" title={L("四种识别架构:延迟、准确率与流式能力的三角", "Four recognition architectures: the latency, accuracy and streaming triangle")} />
      <div className="viz-ctrl">
        <Slider label={L("分块大小 chunk", "Chunk size")} min={80} max={960} step={40} value={chunk} onChange={setChunk} unit=" ms" />
        <Slider label={L("右侧前瞻 lookahead", "Right lookahead")} min={0} max={480} step={40} value={look} onChange={setLook} unit=" ms" />
        <Toggle label={L("走电话窄带 8 kHz", "Over 8 kHz telephony")} value={narrow} onChange={setNarrow} />
      </div>

      <div className="vo-seg" style={{ marginTop: 8 }}>
        {ASR_ARCH.map((x) => (
          <button key={x.k} className={ai2 === x.k ? "on" : ""} onClick={() => setAi2(x.k)}>{x.name}</button>
        ))}
      </div>

      <div className="vo-kpi-grid" style={{ marginTop: 10 }}>
        <Kpi label={L("能否流式", "Streams?")} value={a.stream ? L("可以", "yes") : L("结构上不行", "not structurally")} tone={a.stream ? "ok" : "warn"} hint={pick(lang, { zh: a.zh, en: a.en })} />
        <Kpi label={L("首个中间结果", "First partial result")} value={a.stream ? ms(firstToken) : L("无", "none")} tone={a.stream && firstToken < 500 ? "ok" : "acc"} />
        <Kpi label={L("说完后出终稿", "Final after speech ends")} value={ms(finalize)} tone={finalize > 900 ? "warn" : "ok"} />
        <Kpi label={L("字错率 CER", "Character error")} value={pct1(cer)} tone={cer > 0.07 ? "warn" : "ok"} hint={L(`手机号一次对 ${pct(digitOk)}`, `number right first time ${pct(digitOk)}`)} />
      </div>

      <div style={{ marginTop: 10 }}>
        {ASR_ARCH.map((x) => {
          const pen = x.stream ? 0.9 / (1 + ctx / 220) : 0;
          const c2 = x.cerBase * (1 + pen) * (narrow ? 1.7 : 1);
          return <Bar key={x.k} label={`${x.name} · CER`} value={c2} max={0.16} tone={x.k === ai2 ? "acc" : "mut"} valText={pct1(c2)} />;
        })}
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("分块越小越跟手,但模型看到的上下文越少、错得越多(虚线为当前 chunk)", "Smaller chunks feel snappier and see less context, so they err more (dashed = current chunk)")}</div>
        <MiniPlot data={curve} markIndex={Math.round((chunk - 80) / 40)} yMin={0} />
      </div>

      <Note mark="→" tone={!a.stream ? "bad" : "on"}>
        {!a.stream
          ? L(`${a.name} 要拿到整段音频才能解码,所以顾客说完之后才开始算,终稿延迟被顶到 1.4 秒左右。它适合事后转写与质检,不适合坐在实时电话链路上。`,
              `${a.name} needs the whole segment before decoding, so work starts only after the customer stops and the final lands around 1.4 s. It belongs in offline transcription and QA, not on a live call.`)
          : L(`流式架构的代价写在这条曲线上:chunk 从 ${chunk} ms 减半,首字延迟少一半,字错率却涨上去。多数电话场景的甜点在 300–500 ms。`,
              `The price of streaming is on this curve: halving the ${chunk} ms chunk halves first-token latency and raises the error rate. Most telephony settles between 300 and 500 ms.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v6 · vadLab — endpointing: interrupting vs dawdling
   ========================================================= */
function vadTrial(r, nPauses) {
  // a speaker's within-turn pauses: mostly short, a fat tail of thinking pauses
  const out = [];
  for (let i = 0; i < nPauses; i++) {
    const u = r();
    // three regimes: breathing pauses, thinking pauses, and a thin long tail
    const mu = u < 0.72 ? Math.log(170) : u < 0.98 ? Math.log(380) : Math.log(820);
    const sd = u < 0.72 ? 0.42 : 0.34;
    out.push(Math.exp(mu + sd * gauss(r)));
  }
  return out;
}
function VadViz() {
  const L = useL();
  const [thr, setThr] = React.useState(600);
  const [np, setNp] = React.useState(3);
  const [sem, setSem] = React.useState(false);
  const [noise, setNoise] = React.useState(0.2);

  const TRIALS = 1500;
  const res = React.useMemo(() => {
    const r = rng(20260912);
    let cut = 0;
    for (let t = 0; t < TRIALS; t++) {
      const ps = vadTrial(r, np);
      let bad = ps.some((p) => p > thr);
      if (bad && sem) bad = r() < 0.22;         // semantic check usually saves it
      if (bad) cut++;
    }
    return cut / TRIALS;
  }, [thr, np, sem]);

  const falseCut = res;
  const wait = thr * (sem ? 0.72 : 1) + 40;                    // added silence before the machine replies
  const lateTrigger = noise * 0.35 * (thr < 400 ? 1.6 : 1);    // background noise restarting the timer
  const score = clamp(5 - falseCut * 7 - Math.max(0, (wait - 550) / 280) - lateTrigger * 1.2, 1, 5);

  const curve = [], curve2 = [];
  for (let T = 200; T <= 1400; T += 50) {
    const r2 = rng(4242);
    let c = 0;
    for (let t = 0; t < 400; t++) {
      const ps = vadTrial(r2, np);
      let bad = ps.some((p) => p > T);
      if (bad && sem) bad = r2() < 0.22;
      if (bad) c++;
    }
    curve.push({ x: T, y: c / 400 });
    curve2.push({ x: T, y: (T * (sem ? 0.72 : 1) + 40) / 1500 });
  }

  return (
    <div>
      <VizHead idx="AS3" title={L("尾点静音阈值:一端是抢话,另一端是呆滞", "The tail-silence threshold: interrupting at one end, dawdling at the other")} />
      <div className="viz-ctrl">
        <Slider label={L("尾点静音阈值", "Tail-silence threshold")} min={200} max={1400} step={50} value={thr} onChange={setThr} unit=" ms" />
        <Slider label={L("一轮里的思考停顿次数", "Thinking pauses per turn")} min={1} max={6} value={np} onChange={setNp} />
        <Slider label={L("门店背景噪声", "Shop background noise")} min={0} max={1} step={0.05} value={noise} onChange={setNoise} fmt={pct} />
        <Toggle label={L("语义端点检测", "Semantic endpointing")} value={sem} onChange={setSem} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("误截断率", "False-cut rate")} value={pct1(falseCut)} tone={falseCut > 0.15 ? "warn" : "ok"} hint={L("顾客没说完就被打断", "customer cut off mid-sentence")} />
        <Kpi label={L("每轮多等", "Added wait per turn")} value={ms(wait)} tone={wait > 800 ? "warn" : "ok"} />
        <Kpi label={L("噪声误触发", "Noise re-triggering")} value={pct1(lateTrigger)} tone={lateTrigger > 0.15 ? "warn" : "ok"} />
        <Kpi label={L("对话体验分", "Conversational feel")} value={nf(score, 2)} unit="/5" tone={score > 3.8 ? "ok" : "warn"} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("蓝线:误截断率;交叉点附近就是可用区间(通常 600–800 ms)", "Blue: false-cut rate. The usable region sits near the crossing, usually 600–800 ms")}</div>
        <MiniPlot data={curve} markIndex={Math.round((thr - 200) / 50)} yMin={0} yMax={Math.max(0.35, curve[0].y)} />
        <div className="vo-cap" style={{ marginTop: 6 }}>{L("下图:每轮多等的时间(越低越跟手)", "Below: added wait per turn (lower feels snappier)")}</div>
        <MiniPlot data={curve2} markIndex={Math.round((thr - 200) / 50)} stroke="var(--accent)" yMin={0} yMax={1} />
      </div>

      <Note mark="→" tone={falseCut > 0.15 ? "bad" : wait > 900 ? "bad" : "on"}>
        {falseCut > 0.15
          ? L(`阈值 ${thr} ms 太短:约 ${pct1(falseCut)} 的轮次会在顾客还没说完时被切断,机器抢话——这是顾客挂电话最常见的原因。`,
              `${thr} ms is too short: about ${pct1(falseCut)} of turns get cut while the customer is still speaking. Being talked over is the single most common reason they hang up.`)
          : wait > 900
            ? L(`阈值 ${thr} ms 太长:每一轮都白等将近一秒,十轮就是十秒沉默,顾客会觉得这机器反应迟钝。`,
                `${thr} ms is too long: nearly a second wasted every turn, ten seconds of silence across ten turns, and the machine feels slow.`)
            : L(`${thr} ms 落在可用区间。打开语义端点检测后,模型会判断这句话在意图上是否完整,两条曲线能同时往下走——这是值得多花那点算力的地方。`,
                `${thr} ms is in the usable region. Switch on semantic endpointing and the model judges whether the utterance is intentionally complete, pulling both curves down at once — worth the extra compute.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v7 · werLab — real edit distance, hotwords and slot accuracy
   ========================================================= */
function editOps(ref, hyp) {
  const n = ref.length, m = hyp.length;
  const d = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
  for (let i = 0; i <= n; i++) d[i][0] = i;
  for (let j = 0; j <= m; j++) d[0][j] = j;
  for (let i = 1; i <= n; i++)
    for (let j = 1; j <= m; j++)
      d[i][j] = ref[i - 1] === hyp[j - 1] ? d[i - 1][j - 1] : 1 + Math.min(d[i - 1][j - 1], d[i - 1][j], d[i][j - 1]);
  let i = n, j = m, S = 0, D = 0, I = 0;
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && ref[i - 1] === hyp[j - 1]) { i--; j--; }
    else if (i > 0 && j > 0 && d[i][j] === d[i - 1][j - 1] + 1) { S++; i--; j--; }
    else if (i > 0 && d[i][j] === d[i - 1][j] + 1) { D++; i--; }
    else { I++; j--; }
  }
  return { S, D, I, dist: d[n][m], N: n };
}
const WER_CASES = [
  {
    k: "name",
    ref: "我想约明天下午三点王师傅的肩颈理疗",
    raw: "我想约明天下午三点黄师傅的肩颈里疗",
    hot: "我想约明天下午三点王师傅的肩颈理疗",
    zh: "技师姓名 + 项目名", en: "therapist name + service name",
  },
  {
    k: "phone",
    ref: "我的手机号是幺三八零零幺三五七六八",
    raw: "我的手机号是一三八零零一三五七六八",
    hot: "我的手机号是幺三八零零幺三五七六八",
    zh: "手机号读法", en: "phone number reading",
  },
  {
    k: "price",
    ref: "泰式古法四十分钟一百三十八元",
    raw: "太师古法四十分钟一百三十八元",
    hot: "泰式古法四十分钟一百三十八元",
    zh: "项目名同音字", en: "homophone in a service name",
  },
];
function WerViz() {
  const L = useL();
  const lang = useLang();
  const [ci2, setCi2] = React.useState(0);
  const [hot, setHot] = React.useState(false);
  const [cerSlide, setCerSlide] = React.useState(0.05);
  const [readback, setReadback] = React.useState(false);

  const cs = WER_CASES[ci2];
  const hyp = hot ? cs.hot : cs.raw;
  const e = editOps(cs.ref, hyp);
  const cer = e.dist / e.N;

  const phoneOk = Math.pow(1 - cerSlide, 11);
  const dateOk = Math.pow(1 - cerSlide, 6);
  const nameOk = Math.pow(1 - cerSlide * (hot ? 0.35 : 1.8), 3);
  const withRb = readback ? 1 - Math.pow(1 - phoneOk, 2) : phoneOk;

  return (
    <div>
      <VizHead idx="AS4" title={L("字错率是编辑距离算出来的,而生意只在乎槽位对不对", "Character error is an edit distance; the business only cares whether the slots are right")} />
      <div className="viz-ctrl">
        <Choice label={L("示例句", "Example utterance")} value={String(ci2)} onChange={(v) => setCi2(parseInt(v, 10))}
          options={WER_CASES.map((x, i) => ({ v: String(i), l: lang === "zh" ? x.zh : x.en }))} />
        <Slider label={L("链路整体字错率", "Link-wide character error")} min={0.01} max={0.15} step={0.005} value={cerSlide} onChange={setCerSlide} fmt={pct1} />
        <Toggle label={L("加热词 + 逆文本规范化", "Hotwords + inverse text normalisation")} value={hot} onChange={setHot} />
        <Toggle label={L("关键槽位复述确认", "Read-back confirmation")} value={readback} onChange={setReadback} />
      </div>

      <div style={{ marginTop: 10, display: "grid", gap: 6 }}>
        <div><Label>{L("参考", "Reference")}</Label> <span style={{ font: "500 13px var(--f-mono)" }}>{cs.ref}</span></div>
        <div><Label>{L("识别", "Hypothesis")}</Label> <span style={{ font: "500 13px var(--f-mono)", color: e.dist ? "#c0453f" : "#2e9e6b" }}>{hyp}</span></div>
      </div>

      <div className="vo-kpi-grid" style={{ marginTop: 10 }}>
        <Kpi label={L("替换 S", "Substitutions")} value={e.S} tone={e.S ? "warn" : "ok"} />
        <Kpi label={L("删除 D / 插入 I", "Deletions / insertions")} value={`${e.D} / ${e.I}`} tone={e.D + e.I ? "warn" : "ok"} />
        <Kpi label={L("本句 CER", "CER of this line")} value={pct1(cer)} tone={cer > 0.05 ? "warn" : "ok"} hint={`(S+D+I)/N = ${e.dist}/${e.N}`} />
        <Kpi label={L("热词命中", "Hotword applied")} value={hot ? L("是", "yes") : L("否", "no")} tone={hot ? "ok" : "mut"} />
      </div>

      <div style={{ marginTop: 12 }} className="vo-cap">{L("整段字错率固定时,各类关键槽位一次全对的概率:", "With link-wide error fixed, the chance each critical slot is completely right:")}</div>
      <div style={{ marginTop: 4 }}>
        <Bar label={L("11 位手机号", "11-digit phone number")} value={phoneOk} max={1} tone={phoneOk > 0.7 ? "ok" : "warn"} valText={pct(phoneOk)} />
        <Bar label={L("日期 + 时间(6 字)", "Date + time (6 chars)")} value={dateOk} max={1} tone={dateOk > 0.8 ? "ok" : "warn"} valText={pct(dateOk)} />
        <Bar label={L("技师姓名(3 字)", "Therapist name (3 chars)")} value={nameOk} max={1} tone={nameOk > 0.8 ? "ok" : "warn"} valText={pct(nameOk)} />
        <Bar label={L("手机号 + 复述确认", "Phone number with read-back")} value={withRb} max={1} tone="acc" valText={pct(withRb)} />
      </div>

      <Note mark="→" tone={phoneOk < 0.7 ? "bad" : "on"}>
        {L(`字错率 ${pct1(cerSlide)} 听起来不高,但 11 位手机号每一位都要对,一次全对的概率只有 ${pct(phoneOk)}——近一半顾客要重复一遍号码。整段 CER 是给模型看的,槽位准确率才是给生意看的。热词把专有名词的先验抬上去,ITN 把「幺三八」还原成数字串,复述确认给你第二次机会:三层加起来才把这个数推回可用区间。`,
              `${pct1(cerSlide)} character error sounds mild, but all eleven digits must land, so a phone number is completely right only ${pct(phoneOk)} of the time — nearly half your customers repeat it. Overall CER is a number for the model; slot accuracy is the number for the business. Hotwords raise the prior on proper nouns, inverse text normalisation turns spoken digits back into a string, and read-back gives you a second attempt: only all three together push this back into usable territory.`)}
      </Note>
    </div>
  );
}

window.__VO_VIZ_1 = { shopLab: ShopViz, autoLab: AutoViz, callLab: CallViz, audioLab: AudioViz, asrLab: AsrViz, vadLab: VadViz, werLab: WerViz };
window.__VO_H = { erlangB, erlangC, yuan, ms, editOps };
