/* =========================================================
   viz4.jsx — benches for the rest of Module VII, VIII and the
   case studies (v23–v29), plus the VIZ registry and <Viz>.
   Loaded last so it can spread __VO_VIZ_1/2/3.
   ========================================================= */

/* =========================================================
   v23 · wechatLab — private-domain reach and the cost of frequency
   ========================================================= */
function WechatViz() {
  const L = useL();
  const [members, setMembers] = React.useState(6800);
  const [dormant, setDormant] = React.useState(0.35);
  const [freq, setFreq] = React.useState(2);       // messages per member per month
  const [ai, setAi] = React.useState(true);
  const [ticket, setTicket] = React.useState(238);
  const [months, setMonths] = React.useState(6);

  // opening falls with frequency; blocking accumulates and shrinks the reachable pool
  const open = clamp(0.42 * Math.pow(0.82, Math.max(0, freq - 1)), 0.03, 1) * (ai ? 1.12 : 1);
  const convert = clamp(0.085 * (ai ? 1.25 : 1) * Math.pow(0.93, Math.max(0, freq - 1)), 0, 1);
  const blockRate = clamp(0.004 * Math.pow(freq, 1.9), 0, 0.2);       // per message
  const poolAfter = members * Math.pow(1 - blockRate, freq * months);
  const dormantPool = members * dormant;

  const ordersMonth = dormantPool * freq * open * convert;
  const gmv = ordersMonth * ticket;
  const lostPool = members - poolAfter;
  const lostValue = lostPool * 0.22 * ticket;                         // annualised value of a lost contact

  const curve = [];
  for (let f = 1; f <= 12; f++) {
    const o = clamp(0.42 * Math.pow(0.82, f - 1), 0.03, 1) * (ai ? 1.12 : 1);
    const c = clamp(0.085 * (ai ? 1.25 : 1) * Math.pow(0.93, f - 1), 0, 1);
    curve.push({ x: f, y: dormantPool * f * o * c * ticket });
  }

  return (
    <div>
      <VizHead idx="CH2" title={L("私域召回:短期 GMV 和长期可触达池,是同一个旋钮的两端", "Private-domain win-back: short-term GMV and the long-term reachable pool are two ends of one dial")} />
      <div className="viz-ctrl">
        <Slider label={L("会员总数", "Members")} min={500} max={30000} step={100} value={members} onChange={setMembers} fmt={(v) => big(v)} />
        <Slider label={L("其中沉睡(3 个月未到店)", "Dormant (no visit in 3 months)")} min={0.1} max={0.7} step={0.05} value={dormant} onChange={setDormant} fmt={pct} />
        <Slider label={L("每人每月触达次数", "Messages per member per month")} min={1} max={12} value={freq} onChange={setFreq} />
        <Slider label={L("客单价", "Average ticket")} min={88} max={498} step={10} value={ticket} onChange={setTicket} fmt={(v) => yuan(v)} />
        <Slider label={L("持续几个月", "Run for how many months")} min={1} max={24} value={months} onChange={setMonths} unit={L(" 个月", " mo")} />
        <Toggle label={L("AI 写个性化话术(到期提醒 / 空档促销)", "AI writes personalised scripts")} value={ai} onChange={setAi} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("打开率", "Open rate")} value={pct1(open)} tone={open > 0.25 ? "ok" : "warn"} />
        <Kpi label={L("月召回订单", "Recovered orders per month")} value={nf(ordersMonth, 0)} tone="ok" hint={yuan(gmv)} />
        <Kpi label={L(`${months} 个月后可触达池`, `Reachable pool after ${months} mo`)} value={big(poolAfter)} tone={poolAfter / members < 0.8 ? "warn" : "ok"} hint={L(`流失 ${pct1(1 - poolAfter / members)}`, `${pct1(1 - poolAfter / members)} lost`)} />
        <Kpi label={L("被换掉的长期资产", "Long-term asset traded away")} value={yuan(lostValue)} tone={lostValue > gmv * 3 ? "warn" : "acc"} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("本月 GMV", "GMV this month")} value={gmv} max={Math.max(gmv, lostValue) * 1.15} tone="ok" valText={yuan(gmv)} />
        <Bar label={L(`${months} 个月里被拉黑 / 退关的会员价值`, `Value of members lost over ${months} months`)} value={lostValue} max={Math.max(gmv, lostValue) * 1.15} tone="warn" valText={yuan(lostValue)} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("月 GMV 随触达频次的变化:早就不是线性的了(虚线为当前频次)", "Monthly GMV against contact frequency — long past linear (dashed = current)")}</div>
        <MiniPlot data={curve} markIndex={freq - 1} yMin={0} />
      </div>

      <Note mark="→" tone={lostValue > gmv * 3 ? "bad" : "on"}>
        {lostValue > gmv * 3
          ? L(`每月 ${freq} 次触达:短期 GMV ${yuan(gmv)},但 ${months} 个月里有 ${pct1(1 - poolAfter / members)} 的会员把你拉黑或退关了。这是把长期资产换短期数字——私域最贵的东西不是内容,是「还愿意看你消息的人数」。`,
              `${freq} messages a month yields ${yuan(gmv)} now, and over ${months} months ${pct1(1 - poolAfter / members)} of members block or unfollow you. That is trading a long-term asset for a short-term number — the expensive thing in private-domain marketing is not content, it is the count of people still willing to read you.`)
          : L("记住三个触达边界:公众号客服消息只能在用户互动后 48 小时内主动发,超窗要用模板/订阅消息且受限;小程序有独立客服会话;企业微信把顾客留在员工的企业身份下、会话可存档,是门店最该经营的资产。AI 在这里的价值是「按每个人的到店周期挑时机」,而不是群发得更快。",
              "Three reach boundaries to remember: an official account can push service messages only inside the 48-hour window after an interaction, with template or subscription messages restricted beyond it; a mini-program has its own service session; and WeCom keeps the customer attached to a staff member's corporate identity with archivable conversations — the asset a shop should build. The AI's value here is picking the moment from each person's own visit cycle, not broadcasting faster.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v24 · handoffLab — confidence threshold and the agent queue
   ========================================================= */
function HandoffViz() {
  const L = useL();
  const [thr, setThr] = React.useState(0.72);
  const [agents, setAgents] = React.useState(2);
  const [calls, setCalls] = React.useState(120);
  const [aht, setAht] = React.useState(210);
  const [emotion, setEmotion] = React.useState(true);
  const [instant, setInstant] = React.useState(true);

  // AI confidence is roughly beta-shaped; a higher threshold transfers more
  const handoff = clamp(Math.pow(thr, 1.7) * 0.55 + (emotion ? 0.04 : 0) + (instant ? 0.03 : 0), 0, 1);
  const wrongPass = clamp(0.22 * Math.pow(1 - thr, 1.6), 0, 1);      // should have transferred, did not
  const transferred = calls * handoff;
  const a = (transferred / 9) * (aht / 3600);                         // erlangs over a 9-hour day
  const pWait = erlangC(agents, a);
  const waitSec = a >= agents ? 999 : (pWait * (aht / Math.max(0.01, agents - a))) ;
  const csat = clamp(4.7 - wrongPass * 6 - Math.max(0, (waitSec - 25) / 45) * 0.5, 2, 5);

  const curve = [], curve2 = [];
  for (let t = 0.3; t <= 0.95; t += 0.025) {
    curve.push({ x: t, y: clamp(Math.pow(t, 1.7) * 0.55, 0, 1) });
    curve2.push({ x: t, y: clamp(0.22 * Math.pow(1 - t, 1.6), 0, 1) });
  }

  return (
    <div>
      <VizHead idx="CH3" title={L("阈值定高转得多、人工排队;定低省人力、把该转的砸在手里", "Set it high and the queue grows; set it low and the AI ruins the calls it should have passed on")} />
      <div className="viz-ctrl">
        <Slider label={L("转人工置信度阈值", "Handoff confidence threshold")} min={0.3} max={0.95} step={0.01} value={thr} onChange={setThr} fmt={pct} />
        <Slider label={L("在线坐席数", "Agents on duty")} min={1} max={8} value={agents} onChange={setAgents} />
        <Slider label={L("日来电量", "Calls per day")} min={40} max={400} step={10} value={calls} onChange={setCalls} />
        <Slider label={L("人工处理时长", "Human handle time")} min={90} max={420} step={10} value={aht} onChange={setAht} unit=" s" />
        <Toggle label={L("情绪信号触发", "Emotion signals trigger a transfer")} value={emotion} onChange={setEmotion} />
        <Toggle label={L("说「转人工」立即无条件生效", "Saying 'get me a person' works immediately")} value={instant} onChange={setInstant} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("转人工率", "Handoff rate")} value={pct1(handoff)} tone={handoff > 0.45 ? "warn" : "ok"} hint={L(`${nf(transferred, 0)} 通/天`, `${nf(transferred, 0)} calls/day`)} />
        <Kpi label={L("错误放行率", "Wrongly kept by the AI")} value={pct1(wrongPass)} tone={wrongPass > 0.05 ? "warn" : "ok"} hint={L("该转却没转", "should have transferred")} />
        <Kpi label={L("坐席平均等待", "Average wait for an agent")} value={waitSec > 600 ? L("排爆了", "saturated") : `${nf(waitSec, 0)} s`} tone={waitSec > 30 ? "warn" : "ok"} />
        <Kpi label={L("综合满意度", "Overall satisfaction")} value={nf(csat, 2)} unit="/5" tone={csat > 4.1 ? "ok" : "warn"} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("转给人工", "Transferred")} value={handoff} max={1} tone="acc" valText={pct1(handoff)} />
        <Bar label={L("该转却被 AI 扛住了", "Kept by the AI but should not have been")} value={wrongPass} max={0.3} tone="warn" valText={pct1(wrongPass)} />
        <Bar label={L("坐席占用率", "Agent utilisation")} value={Math.min(1, a / agents)} max={1} tone={a / agents > 0.85 ? "warn" : "ok"} valText={pct(Math.min(1, a / agents))} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("阈值越高转得越多(蓝),错误放行越少(下图)——这是一条典型的分类权衡", "Higher threshold transfers more (blue) and passes fewer errors (below) — a classic classification trade-off")}</div>
        <MiniPlot data={curve} markIndex={Math.round((thr - 0.3) / 0.025)} yMin={0} yMax={1} />
        <MiniPlot data={curve2} markIndex={Math.round((thr - 0.3) / 0.025)} stroke="var(--accent)" yMin={0} yMax={0.3} />
      </div>

      <Note mark="→" tone={waitSec > 40 || wrongPass > 0.06 ? "bad" : "on"}>
        {waitSec > 40
          ? L(`转人工率 ${pct1(handoff)} 已经超出 ${agents} 个坐席的处理能力,平均等待 ${nf(waitSec, 0)} 秒。等待超过三十秒造成的满意度损失,往往比 AI 答错还大——要么加人,要么把阈值调低一点,让 AI 多扛一些真正简单的。`,
              `A ${pct1(handoff)} handoff rate exceeds what ${agents} agents can absorb, and the average wait is ${nf(waitSec, 0)} seconds. Waiting beyond thirty seconds usually costs more satisfaction than a wrong AI answer — either add people or lower the threshold so the AI keeps more of the genuinely easy work.`)
          : wrongPass > 0.06
            ? L(`阈值太低:${pct1(wrongPass)} 的对话本该转人工却被 AI 扛住了。四类信号必须无条件转——连续两轮听不清、投诉与退款、身体不适相关、以及顾客直接说要找人。最后一条尤其重要:任何形式的挽留都会让人更恼火。`,
                `The threshold is too low: ${pct1(wrongPass)} of conversations should have been transferred and were not. Four signals must transfer unconditionally — two consecutive unclear turns, complaints and refunds, anything touching physical discomfort, and the customer asking for a person. The last one especially: any attempt to retain them makes it worse.`)
            : L("阈值、坐席和信号规则配平了。最后别忘了上下文交接:接手的人必须立刻看到之前说了什么、顾客要约什么、AI 卡在哪一步——否则顾客要从头讲一遍,那一刻之前所有的效率提升都被抵消掉了。",
                "Threshold, agents and signal rules are balanced. One thing remains: context transfer. Whoever picks up must immediately see what was said, what the customer wants and where the AI got stuck — otherwise the customer repeats everything and every efficiency gain up to that moment is cancelled.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v25 · qaLab — sampling, confidence intervals and the loop
   ========================================================= */
function QaViz() {
  const L = useL();
  const [calls, setCalls] = React.useState(3600);   // calls per month
  const [sample, setSample] = React.useState(0.05);
  const [autoQa, setAutoQa] = React.useState(false);
  const [trueBad, setTrueBad] = React.useState(0.09);
  const [fixRate, setFixRate] = React.useState(0.45);
  const [weeks, setWeeks] = React.useState(12);

  const reviewed = autoQa ? calls : Math.round(calls * sample);
  const found = Math.round(reviewed * trueBad);
  const se = Math.sqrt(Math.max(1e-9, trueBad * (1 - trueBad) / Math.max(1, reviewed)));
  const lo = clamp(trueBad - 1.96 * se, 0, 1), hi = clamp(trueBad + 1.96 * se, 0, 1);
  const width = hi - lo;
  const humanCost = autoQa ? 0 : reviewed * 4.5;           // yuan per call listened to by a person
  const modelCost = autoQa ? calls * 0.06 : 0;

  // self-service climbing week by week when the loop runs
  const series = [];
  let s = 0.52;
  for (let w2 = 0; w2 <= weeks; w2++) {
    series.push({ x: w2, y: s });
    const detect = autoQa ? 1 : sample;
    s = clamp(s + (0.86 - s) * fixRate * (0.35 + 0.65 * detect), 0, 0.95);
  }
  const flat = series.map((d) => ({ x: d.x, y: 0.52 }));

  return (
    <div>
      <VizHead idx="OP1" title={L("抽检 200 通发现 3 个坏案例,真实坏案例率的区间宽到没法决策", "Three bad cases in 200 sampled calls leaves an interval too wide to act on")} />
      <div className="viz-ctrl">
        <Slider label={L("月通话量", "Calls per month")} min={300} max={20000} step={100} value={calls} onChange={setCalls} fmt={(v) => big(v)} />
        <Slider label={L("人工抽检比例", "Human sampling rate")} min={0.01} max={0.3} step={0.01} value={sample} onChange={setSample} fmt={pct} />
        <Slider label={L("真实坏案例率", "True bad-case rate")} min={0.01} max={0.3} step={0.01} value={trueBad} onChange={setTrueBad} fmt={pct1} />
        <Slider label={L("每轮迭代的修复效率", "Fix effectiveness per iteration")} min={0.1} max={0.8} step={0.05} value={fixRate} onChange={setFixRate} fmt={pct} />
        <Slider label={L("观察周数", "Weeks observed")} min={4} max={26} value={weeks} onChange={setWeeks} unit={L(" 周", " wk")} />
        <Toggle label={L("全量模型质检(转写后逐条打分)", "Full-coverage model QA")} value={autoQa} onChange={setAutoQa} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("实际检查", "Calls reviewed")} value={big(reviewed)} tone={autoQa ? "ok" : "acc"} hint={autoQa ? L("100% 覆盖", "100% coverage") : pct(sample)} />
        <Kpi label={L("发现坏案例", "Bad cases found")} value={big(found)} tone="acc" />
        <Kpi label={L("95% 置信区间", "95% confidence interval")} value={`${pct1(lo)}–${pct1(hi)}`} tone={width > 0.05 ? "warn" : "ok"} hint={L(`宽度 ${pct1(width)}`, `width ${pct1(width)}`)} />
        <Kpi label={L("质检月成本", "QA cost per month")} value={yuan(humanCost + modelCost)} tone="mut" hint={autoQa ? L("模型打分", "model scoring") : L("人耳抽检", "human listening")} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("人工抽检覆盖", "Human sampling coverage")} value={autoQa ? 0 : sample} max={1} tone="acc" valText={autoQa ? "0%" : pct(sample)} />
        <Bar label={L("全量模型质检覆盖", "Model QA coverage")} value={autoQa ? 1 : 0} max={1} tone="ok" valText={autoQa ? "100%" : "0%"} />
        <Bar label={L("结论的可信宽度(越窄越好)", "Interval width (narrower is better)")} value={width} max={0.2} tone={width > 0.05 ? "warn" : "ok"} valText={pct1(width)} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("自助解决率随周次爬升(蓝);没有闭环时它就是那条平线", "Self-service rate climbing week by week (blue); without the loop it is the flat line")}</div>
        <MiniPlot data={series} markIndex={weeks} yMin={0.4} yMax={1} />
        <MiniPlot data={flat} stroke="var(--muted)" yMin={0.4} yMax={1} h={40} />
      </div>

      <Note mark="→" tone={width > 0.05 ? "bad" : "on"}>
        {width > 0.05
          ? L(`抽了 ${big(reviewed)} 通、发现 ${big(found)} 个坏案例,真实坏案例率的 95% 区间是 ${pct1(lo)}–${pct1(hi)}——这个区间宽到你没法判断上周的改动到底有没有效果。要么加大样本,要么打开全量模型质检:转写加逐条打分大约每通几分钱,覆盖率从 ${pct(sample)} 变成 100%。`,
              `${big(reviewed)} calls reviewed, ${big(found)} bad cases found, and the 95% interval on the true rate runs ${pct1(lo)}–${pct1(hi)} — too wide to tell whether last week's change did anything. Either enlarge the sample or switch on full-coverage model QA: transcribe and score every call for a few cents apiece and coverage goes from ${pct(sample)} to 100%.`)
          : L("覆盖够了,接下来是闭环本身:坏案例要分类(识别错 / 知识缺 / 话术差 / 工具失败),归因到具体组件,修完之后灰度验证,并把这一条冻进回归集——否则下次改提示词会把这次的修复顶掉。上线第一周难看是正常的,原地打转才是失败。",
              "Coverage is sufficient; what remains is the loop itself. Classify each bad case (misrecognition, missing knowledge, weak script, tool failure), attribute it to a component, validate the fix on a canary, and freeze the case into a regression set — otherwise the next prompt rewrite undoes this fix. A bad first week is normal; going in circles is the failure.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v26 · complyLab — the compliance gate
   ========================================================= */
const COMPLY = [
  { k: "notice", w: 5, zh: "通话开始时明确告知可能录音", en: "Notice at call start that the call may be recorded", why: { zh: "录音是个人信息处理,需告知并取得同意", en: "recording is personal-information processing; notice and consent required" } },
  { k: "consent", w: 5, zh: "声纹等生物识别信息取得单独同意", en: "Separate consent for voiceprints and biometrics", why: { zh: "属于敏感个人信息,要求单独同意", en: "sensitive personal information requires separate consent" } },
  { k: "minimal", w: 4, zh: "最小必要:不为「以后训练」留全量录音", en: "Minimal necessary: no keep-everything-for-training", why: { zh: "收集范围应与目的相称", en: "collection must match the stated purpose" } },
  { k: "retain", w: 4, zh: "明示存储期限并到期删除", en: "Disclose retention period and delete on expiry", why: { zh: "期限届满应删除或匿名化", en: "delete or anonymise once the period ends" } },
  { k: "access", w: 3, zh: "提供查询、更正与删除通道", en: "Provide access, correction and deletion", why: { zh: "个人信息主体的基本权利", en: "basic rights of the data subject" } },
  { k: "label", w: 5, zh: "合成语音按规定标识", en: "Label synthetic speech as required", why: { zh: "生成合成内容标识要求", en: "AI-generated content labelling rules" } },
  { k: "voice", w: 5, zh: "使用真人音色取得本人书面授权", en: "Written authorisation to use a real person's voice", why: { zh: "《民法典》第 1023 条参照肖像权保护声音", en: "Civil Code art. 1023 protects voice by reference to portrait rights" } },
  { k: "optin", w: 5, zh: "外呼:事先同意 + 拒绝后不再拨", en: "Outbound: prior consent, never after refusal", why: { zh: "商业性语音呼叫的强制要求", en: "mandatory for commercial voice calls" } },
  { k: "hours", w: 3, zh: "外呼时段与频次限制", en: "Outbound time-of-day and frequency limits", why: { zh: "避免骚扰与投诉", en: "avoids nuisance and complaints" } },
  { k: "optout", w: 4, zh: "提供便捷退订方式", en: "Easy opt-out on every contact", why: { zh: "退订必须简单可达", en: "opting out must be simple and reachable" } },
  { k: "medical", w: 5, zh: "禁止治疗 / 疗效 / 根治类表述", en: "No treat / cure / therapeutic-effect claims", why: { zh: "养生服务不是医疗服务", en: "wellness service is not medical service" } },
  { k: "symptom", w: 4, zh: "症状类咨询走安全模板,不作诊断", en: "Symptom questions use the safe template, no diagnosis", why: { zh: "邀请到店评估或建议就医", en: "invite an in-person assessment or suggest a doctor" } },
  { k: "oob", w: 5, zh: "越界请求:拒绝、结束、留痕", en: "Out-of-bounds requests: refuse, end, log", why: { zh: "正规门店的底线与护城河", en: "the line a legitimate shop does not cross" } },
  { k: "minor", w: 3, zh: "未成年人相关的特别处理", en: "Special handling where minors are involved", why: { zh: "未成年人信息按敏感信息处理", en: "minors' information is treated as sensitive" } },
];
function ComplyViz() {
  const L = useL();
  const lang = useLang();
  const [on, setOn] = React.useState({});
  const [outbound, setOutbound] = React.useState(true);
  const [clone, setClone] = React.useState(false);

  const applicable = COMPLY.filter((c) => {
    if (!outbound && ["optin", "hours", "optout"].includes(c.k)) return false;
    if (!clone && c.k === "voice") return false;
    return true;
  });
  const done = applicable.filter((c) => on[c.k]);
  const missing = applicable.filter((c) => !on[c.k]);
  const risk = missing.reduce((s, c) => s + c.w, 0);
  const maxRisk = applicable.reduce((s, c) => s + c.w, 0);
  const ready = missing.length === 0;
  const worst = missing.slice().sort((a, b) => b.w - a.w)[0];

  return (
    <div>
      <VizHead idx="OP2" title={L("十四项义务,按你的场景过滤;任何一项没做到就不该上线", "Fourteen duties filtered to your scenario — any one missing means it should not ship")} />
      <div className="viz-ctrl">
        <Toggle label={L("有外呼 / 主动触达", "Doing outbound or proactive contact")} value={outbound} onChange={setOutbound} />
        <Toggle label={L("使用真人音色复刻", "Using a cloned real voice")} value={clone} onChange={setClone} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("适用条目", "Applicable duties")} value={applicable.length} tone="acc" />
        <Kpi label={L("已做到", "Satisfied")} value={done.length} tone={ready ? "ok" : "warn"} />
        <Kpi label={L("风险分", "Risk score")} value={`${risk}/${maxRisk}`} tone={risk > 12 ? "warn" : risk ? "acc" : "ok"} />
        <Kpi label={L("上线判定", "Verdict")} value={ready ? L("可以上线", "may ship") : L("不可上线", "do not ship")} tone={ready ? "ok" : "warn"} hint={worst ? (lang === "zh" ? `最急:${worst.zh}` : `first: ${worst.en}`) : ""} />
      </div>

      <div style={{ marginTop: 10, display: "grid", gap: 4 }}>
        {applicable.map((c) => (
          <div key={c.k} onClick={() => setOn({ ...on, [c.k]: !on[c.k] })}
            style={{ cursor: "pointer", display: "grid", gridTemplateColumns: "20px 1fr auto", gap: 8, alignItems: "center", padding: "6px 8px", borderRadius: 5, background: on[c.k] ? "var(--surface-2)" : "color-mix(in srgb, #c0453f 10%, transparent)" }}>
            <span style={{ font: "700 12px var(--f-mono)", color: on[c.k] ? "#2e9e6b" : "#c0453f" }}>{on[c.k] ? "✓" : "○"}</span>
            <span>
              <span style={{ font: "600 12px var(--f-sans)" }}>{lang === "zh" ? c.zh : c.en}</span>
              <span style={{ display: "block", font: "500 10.5px var(--f-sans)", color: "var(--muted)" }}>{pick(lang, c.why)}</span>
            </span>
            <span className="vo-pill mini">{L(`权重 ${c.w}`, `weight ${c.w}`)}</span>
          </div>
        ))}
      </div>

      <Note mark={ready ? "→" : "⚠"} tone={ready ? "on" : "bad"}>
        {ready
          ? L("全部做到了。把这张表做成上线检查单、每季度复核一次,并且在每次新增能力(开外呼、换音色、加渠道)时重新过一遍。本章为工程与管理提示,不构成法律意见,正式上线前请咨询专业律师。",
              "All satisfied. Turn this into a launch checklist, review it quarterly, and run it again whenever you add a capability — outbound, a new voice, a new channel. This chapter is engineering and management guidance, not legal advice; consult a qualified lawyer before going live.")
          : L(`还有 ${missing.length} 项没做到,风险分 ${risk}。这些不是「以后补」的事项:录音没告知、外呼没同意、宣称疗效、对越界请求不拒绝,任何一条被投诉或被检查到,后果都是整个项目下线,甚至波及营业执照和平台账号。本章为工程与管理提示,不构成法律意见。`,
              `${missing.length} duties unsatisfied, risk score ${risk}. None of these are do-it-later items: recording without notice, outbound without consent, efficacy claims, or failing to refuse an out-of-bounds request — any one of them, once complained about or inspected, takes the whole project offline and can reach your licence and platform accounts. This chapter is engineering and management guidance, not legal advice.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v27 · maturityLab — eight dimensions, 24 questions
   ========================================================= */
const MAT_DIMS = [
  { k: "biz",   zh: "生意与账", en: "Business ledger", qs: [
    { zh: "算过漏接电话每月值多少钱", en: "Quantified what missed calls cost per month" },
    { zh: "把咨询按能否自动化分过级", en: "Triaged enquiries by automatability" },
    { zh: "有明确的自助解决率目标", en: "Have an explicit self-service target" } ] },
  { k: "asr",   zh: "听清", en: "Hearing", qs: [
    { zh: "用自己的录音测过字错率", en: "Measured CER on your own recordings" },
    { zh: "配了技师名与项目名的热词", en: "Configured hotwords for therapists and services" },
    { zh: "尾点阈值是调过的,不是默认值", en: "Endpoint threshold tuned, not left default" } ] },
  { k: "tts",   zh: "说好", en: "Speaking", qs: [
    { zh: "金额、时间、电话都做了文本规范化", en: "Normalised amounts, times and phone numbers" },
    { zh: "用了流式合成,首包在 300 ms 内", en: "Streaming synthesis under 300 ms first packet" },
    { zh: "如果用真人音色,授权与标识齐备", en: "If cloning a real voice, authorisation and labelling in place" } ] },
  { k: "brain", zh: "会想", en: "Thinking", qs: [
    { zh: "价格与空档来自工具调用而非模型记忆", en: "Prices and slots come from tools, not memory" },
    { zh: "知识库有人负责维护和更新", en: "Someone owns and updates the knowledge base" },
    { zh: "查不到时明确说不知道并转人工", en: "Says so and hands off when retrieval fails" } ] },
  { k: "rt",    zh: "接得住", en: "Realtime", qs: [
    { zh: "测过端到端延迟的 P95", en: "Measured end-to-end P95 latency" },
    { zh: "支持打断且误打断可控", en: "Barge-in works with false interrupts under control" },
    { zh: "有降级预案(模型挂了怎么办)", en: "Have a degradation plan when a model fails" } ] },
  { k: "plat",  zh: "平台与成本", en: "Platform and cost", qs: [
    { zh: "做过多厂商盲测而不是看宣传页", en: "Ran a blind vendor test rather than reading brochures" },
    { zh: "知道单通电话的成本构成", en: "Know the cost structure of one call" },
    { zh: "有用量预警,不会月底才发现超支", en: "Usage alerts exist; no end-of-month surprises" } ] },
  { k: "chan",  zh: "渠道与协同", en: "Channels and handoff", qs: [
    { zh: "电话并发线路按呼损目标算过", en: "Sized trunk channels from a blocking target" },
    { zh: "转人工有明确触发规则", en: "Handoff triggers are explicit rules" },
    { zh: "转人工时上下文自动交接", en: "Context transfers automatically on handoff" } ] },
  { k: "ops",   zh: "运营与合规", en: "Operations and compliance", qs: [
    { zh: "有坏案例的分类、归因与修复闭环", en: "A classify-attribute-fix loop for bad cases" },
    { zh: "有回归测试集,改动前会跑", en: "A regression set that runs before every change" },
    { zh: "合规清单逐条过过且有留痕", en: "The compliance checklist has been walked and logged" } ] },
];
const MAT_KEY = "voice_book_assess";
function MaturityViz() {
  const L = useL();
  const lang = useLang();
  const [scores, setScores] = React.useState(() => {
    try { const s = JSON.parse(localStorage.getItem(MAT_KEY) || "null"); if (Array.isArray(s) && s.length === 24) return s; } catch (e) {}
    return new Array(24).fill(0);
  });
  const save = (next) => { setScores(next); try { localStorage.setItem(MAT_KEY, JSON.stringify(next)); } catch (e) {} };
  const setQ = (i, v) => { const n = scores.slice(); n[i] = v; save(n); };

  const dimScore = MAT_DIMS.map((d, di) => {
    const vals = [0, 1, 2].map((j) => scores[di * 3 + j]);
    return vals.reduce((a, b) => a + b, 0) / 6;                   // each question is 0..2
  });
  const overall = dimScore.reduce((a, b) => a + b, 0) / MAT_DIMS.length;
  const phase = overall < 0.3 ? 1 : overall < 0.55 ? 2 : overall < 0.8 ? 3 : 4;
  const phaseName = [
    { zh: "第一阶段:先把文字渠道的问答做起来", en: "Phase 1: answer questions on text channels first" },
    { zh: "第二阶段:在 IM 上跑通预约写操作", en: "Phase 2: get booking writes working on IM" },
    { zh: "第三阶段:上电话,实时链路与打断", en: "Phase 3: take the phone, realtime and barge-in" },
    { zh: "第四阶段:外呼、主动召回与全量质检", en: "Phase 4: outbound, win-back and full-coverage QA" },
  ][phase - 1];
  const weakest = MAT_DIMS.map((d, i) => ({ d, s: dimScore[i] })).sort((a, b) => a.s - b.s).slice(0, 3);

  const cx = 150, cy = 122, R = 96;
  const pt = (i, r) => {
    const ang = -Math.PI / 2 + (i / MAT_DIMS.length) * Math.PI * 2;
    return [cx + Math.cos(ang) * r, cy + Math.sin(ang) * r];
  };
  const poly = dimScore.map((s, i) => pt(i, 12 + s * (R - 12)).join(",")).join(" ");

  return (
    <div>
      <VizHead idx="OP3" title={L("八个维度、二十四题:给自己的系统打个分,看下一步该补哪里", "Eight dimensions, twenty-four questions: score your own system and see what to fix next")} />
      <div className="vo-kpi-grid">
        <Kpi label={L("总分", "Overall")} value={pct(overall)} tone={overall > 0.7 ? "ok" : "acc"} />
        <Kpi label={L("当前阶段", "Current phase")} value={`${phase}/4`} tone="acc" hint={pick(lang, phaseName)} />
        <Kpi label={L("最弱维度", "Weakest dimension")} value={lang === "zh" ? weakest[0].d.zh : weakest[0].d.en} tone="warn" />
        <Kpi label={L("已回答", "Answered")} value={`${scores.filter((s) => s > 0).length}/24`} tone="mut" hint={L("保存在你的浏览器里", "saved in your browser")} />
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "300px 1fr", gap: 12, marginTop: 12, alignItems: "start" }}>
        <svg viewBox="0 0 300 244" width="100%" style={{ display: "block" }}>
          {[0.25, 0.5, 0.75, 1].map((f, i) => (
            <polygon key={i} points={MAT_DIMS.map((_, j) => pt(j, 12 + f * (R - 12)).join(",")).join(" ")}
              fill="none" stroke="var(--hairline-strong)" strokeWidth="0.8" />
          ))}
          {MAT_DIMS.map((d, i) => {
            const [x, y] = pt(i, R + 4);
            return <text key={i} x={x} y={y} textAnchor={x < cx - 12 ? "end" : x > cx + 12 ? "start" : "middle"}
              style={{ font: "600 9px var(--f-sans)", fill: "var(--muted)" }}>{lang === "zh" ? d.zh : d.en}</text>;
          })}
          <polygon points={poly} fill="color-mix(in srgb, var(--primary) 26%, transparent)" stroke="var(--primary)" strokeWidth="1.6" />
          {dimScore.map((s, i) => { const [x, y] = pt(i, 12 + s * (R - 12)); return <circle key={i} cx={x} cy={y} r="2.6" fill="var(--accent)" />; })}
        </svg>

        <div style={{ display: "grid", gap: 6 }}>
          {MAT_DIMS.map((d, di) => (
            <div key={d.k}>
              <div className="vo-cap" style={{ marginBottom: 2 }}>{lang === "zh" ? d.zh : d.en}</div>
              {d.qs.map((q, j) => {
                const i = di * 3 + j;
                return (
                  <div key={j} style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 6, alignItems: "center", marginBottom: 2 }}>
                    <span style={{ font: "500 11px var(--f-sans)" }}>{lang === "zh" ? q.zh : q.en}</span>
                    <span className="vo-seg" style={{ transform: "scale(.86)", transformOrigin: "right center" }}>
                      {[0, 1, 2].map((v) => (
                        <button key={v} className={scores[i] === v ? "on" : ""} onClick={() => setQ(i, v)}>
                          {v === 0 ? L("没有", "no") : v === 1 ? L("部分", "part") : L("做到", "yes")}
                        </button>
                      ))}
                    </span>
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      </div>

      <Note mark="→" tone="on">
        {L(`你现在处于${pick(lang, phaseName)}。接下来最该补的三件事:${weakest.map((w) => w.d.zh).join("、")}。落地顺序之所以是「先文字后语音、先读后写」,是因为门店没有试错的预算:文字渠道可以撤回、可以慢慢答,电话是实时的、不可撤回的,而且合规压力最大。`,
              `You are at ${pick(lang, phaseName).toLowerCase()}. The three things most worth fixing next: ${weakest.map((w) => w.d.en).join(", ")}. The rollout order — text before voice, read before write — exists because a shop has no budget for trial and error: text channels can be retracted and answered slowly, while the phone is realtime, irreversible and carries the heaviest compliance load.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v28 · chainLab — the three-shop chain's ledger
   ========================================================= */
function ChainViz() {
  const L = useL();
  const [shops, setShops] = React.useState(3);
  const [callsShop, setCallsShop] = React.useState(70);
  const [ticket, setTicket] = React.useState(268);
  const [coverage, setCoverage] = React.useState(0.6);
  const [members, setMembers] = React.useState(6800);
  const [setup, setSetup] = React.useState(58000);
  const [platMin, setPlatMin] = React.useState(0.19);

  const callsMonth = shops * callsShop * 30;
  const minutes = callsMonth * 2.6;
  const platform = minutes * platMin;
  const ops = 2600;                                   // part-time operations allocation
  const monthlyCost = platform + ops;

  // benefits
  const baselineBlock = 0.28;                          // missed before the AI
  const bookShare = 0.45;                              // share of calls carrying booking intent
  const comeBack = 0.35;                               // blocked callers who do ring again
  // this month's revenue, not lifetime value - a monthly P&L counts what landed this month
  const recovered = callsMonth * baselineBlock * coverage * bookShare * (1 - comeBack) * 0.55 * ticket;
  const deskHours = (callsMonth * coverage * 0.72 * 150) / 3600;
  const deskSaved = deskHours * 32;
  const dormant = members * 0.35;
  const wakeOrders = dormant * 0.055 * coverage;
  const wakeGmv = wakeOrders * ticket;
  // the honest negative: customers deflected by a machine they did not want
  const deflect = callsMonth * coverage * Math.pow(coverage, 2.2) * 0.055 * ticket * 0.9;

  const benefit = recovered + deskSaved + wakeGmv - deflect;
  const net = benefit - monthlyCost;
  const payback = net > 0 ? setup / net : Infinity;
  const y3 = net * 36 - setup;

  const curve = [];
  for (let c = 0.2; c <= 0.95; c += 0.05) {
    const rec = callsMonth * baselineBlock * c * bookShare * (1 - comeBack) * 0.55 * ticket;
    const dh = (callsMonth * c * 0.72 * 150) / 3600 * 32;
    const wk = dormant * 0.055 * c * ticket;
    const df = callsMonth * c * Math.pow(c, 2.2) * 0.055 * ticket * 0.9;
    curve.push({ x: c, y: rec + dh + wk - df - monthlyCost });
  }

  const rows = [
    { l: L("挽回的漏接预约", "Recovered missed bookings"), v: recovered, good: true },
    { l: L("释放的前台工时", "Front-desk hours freed"), v: deskSaved, good: true },
    { l: L("唤醒的沉睡会员", "Dormant members reactivated"), v: wakeGmv, good: true },
    { l: L("被 AI 劝退的顾客", "Customers deflected by the AI"), v: -deflect, good: false },
    { l: L("平台与线路费", "Platform and telephony"), v: -platform, good: false },
    { l: L("运维人力分摊", "Operations allocation"), v: -ops, good: false },
  ];

  return (
    <div>
      <VizHead idx="CS1" title={L("三店连锁的一本账:每个数字都可以换成你自己门店的", "A three-shop chain's ledger — every number can be replaced with your own")} />
      <div className="viz-ctrl">
        <Slider label={L("门店数", "Shops")} min={1} max={20} value={shops} onChange={setShops} />
        <Slider label={L("每店日来电", "Calls per shop per day")} min={20} max={200} step={5} value={callsShop} onChange={setCallsShop} />
        <Slider label={L("客单价", "Average ticket")} min={88} max={498} step={10} value={ticket} onChange={setTicket} fmt={(v) => yuan(v)} />
        <Slider label={L("AI 覆盖的咨询比例", "Share of enquiries the AI takes")} min={0.2} max={0.95} step={0.05} value={coverage} onChange={setCoverage} fmt={pct} />
        <Slider label={L("会员总数", "Members")} min={1000} max={40000} step={200} value={members} onChange={setMembers} fmt={(v) => big(v)} />
        <Slider label={L("一次性集成投入", "One-off integration")} min={8000} max={200000} step={2000} value={setup} onChange={setSetup} fmt={(v) => yuan(v)} />
        <Slider label={L("平台综合单价", "All-in platform price")} min={0.05} max={0.6} step={0.01} value={platMin} onChange={setPlatMin} fmt={(v) => `¥${nf(v, 2)}/min`} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("月度总收益", "Monthly benefit")} value={yuan(benefit)} tone="ok" />
        <Kpi label={L("月度总支出", "Monthly cost")} value={yuan(monthlyCost)} tone="acc" />
        <Kpi label={L("月净收益", "Monthly net")} value={yuan(net)} tone={net > 0 ? "ok" : "warn"} />
        <Kpi label={L("回收期", "Payback")} value={isFinite(payback) ? `${nf(payback, 1)} ${L("个月", "mo")}` : L("收不回", "never")} tone={payback < 6 ? "ok" : "warn"} hint={L(`三年累计 ${yuan(y3)}`, `3-year total ${yuan(y3)}`)} />
      </div>

      <div style={{ marginTop: 12 }}>
        {rows.map((r) => (
          <Bar key={r.l} label={r.l} value={Math.abs(r.v)} max={Math.max(...rows.map((x) => Math.abs(x.v))) * 1.05}
            tone={r.good ? "ok" : "warn"} valText={`${r.v < 0 ? "−" : "+"}${yuan(Math.abs(r.v))}`} />
        ))}
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("月净收益随 AI 覆盖率的变化——注意它并不是一直往上(虚线为当前覆盖率)", "Monthly net against AI coverage — note it does not rise forever (dashed = current)")}</div>
        <MiniPlot data={curve} markIndex={Math.round((coverage - 0.2) / 0.05)} />
      </div>

      <Note mark="→" tone={net > 0 ? "on" : "bad"}>
        {L(`三个结论会自己浮出来。第一,收益的最大头往往不是省人力,而是挽回漏接——前台省下的工时值 ${yuan(deskSaved)},挽回的预约值 ${yuan(recovered)}。第二,覆盖率拉到九成时净收益可能反而下降,因为被劝退的损失是超线性的:把该给人的活硬塞给 AI,省下的那点工时补不上流失的顾客。第三,一次性集成成本被门店数摊薄,${shops} 家店时每店分摊 ${yuan(setup / shops)}——连锁天然比单店更适合上这套东西。`,
              `Three conclusions surface on their own. First, the largest benefit is usually not saved labour but recovered calls: freed front-desk hours are worth ${yuan(deskSaved)} while recovered bookings are worth ${yuan(recovered)}. Second, pushing coverage to ninety percent can reduce net benefit, because deflection loss grows super-linearly — forcing work that belongs to a person onto the machine loses more customers than the saved hours are worth. Third, the one-off integration divides across shops: at ${shops} shops it is ${yuan(setup / shops)} each, which is why a chain fits this better than a single shop.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v29 · traceLab — one call, hop by hop, with fault injection
   ========================================================= */
const TRACE = [
  { t: 0, who: "sys", ms: 15000, zh: "振铃 → 接起 → 播放录音告知与问候", en: "Ring, answer, recording notice and greeting", note: { zh: "录音告知必须在最前面，且要留痕", en: "the recording notice comes first and is logged" } },
  { t: 1, who: "user", ms: 7000, zh: "「你好，我想约一下明天下午的肩颈」", en: "Hello, I would like to book neck and shoulders tomorrow afternoon", note: { zh: "一句话填了三个槽位：日期、时段、项目", en: "one sentence fills three slots: date, part of day, service" } },
  { t: 2, who: "sys", ms: 14000, zh: "识别 + 理解 + 回问：「明天下午有 14:00 和 16:30，您方便哪个？」", en: "Recognise, understand, ask back: 14:00 or 16:30 tomorrow afternoon?", note: { zh: "多槽抽取省掉了两轮追问", en: "multi-slot extraction saved two follow-up turns" } },
  { t: 3, who: "user", ms: 6000, zh: "「16:30 吧，还是王师傅」", en: "16:30 please, and the same therapist as before", note: { zh: "引入了第四个槽位：指定技师", en: "introduces a fourth slot: a named therapist" } },
  { t: 4, who: "sys", ms: 13000, zh: "查空档 query_slots，并把「王师傅」听成了「黄师傅」", en: "query_slots, and the therapist's surname is misrecognised", note: { zh: "热词表里没有这个名字 — 本次通话的第一个坑", en: "the name was never added to the hotword list - trap one" }, bad: "hot" },
  { t: 5, who: "sys", ms: 18000, zh: "复述确认：「您是要王师傅对吗？」→ 顾客确认，救回来了", en: "Read-back: you mean this therapist, correct? The customer confirms, rescued", note: { zh: "关键槽位复述确认的价值就在这一轮", en: "this is exactly what read-back confirmation buys you" } },
  { t: 6, who: "user", ms: 9000, zh: "「对。我上次买的团购券能和会员卡一起用吗？」", en: "Yes. Can the voucher I bought be combined with my membership card?", note: { zh: "一个知识库里没有的问题", en: "a question the knowledge base does not answer" } },
  { t: 7, who: "sys", ms: 16000, zh: "检索失败 → 兜底：「这个规则我帮您问一下前台，先把时间定下来好吗？」", en: "Retrieval fails, fallback: let me check that with the front desk; shall we fix the time first?", note: { zh: "没有编造 — 兜底策略生效，这是第二个坑被挡住", en: "no invention - the fallback held, trap two blocked" }, bad: "kb" },
  { t: 8, who: "user", ms: 4000, zh: "「好的。」", en: "All right.", note: { zh: "顾客接受了兜底，对话没有崩", en: "the customer accepted the fallback and the call survived" } },
  { t: 9, who: "sys", ms: 11000, zh: "占位 + 落单 confirm_booking（幂等键 = 会话ID + 意图）", en: "hold + confirm_booking (idempotency key = session + intent)", note: { zh: "写操作，带幂等键，重试安全", en: "a write, keyed for idempotency, safe to retry" } },
  { t: 10, who: "sys", ms: 24000, zh: "复述订单 + 短信确认 + 收尾，挂机", en: "Read back the booking, send the SMS, close and hang up", note: { zh: "总时长 2 分 17 秒，11 个轮次", en: "2m17s across 11 turns" } },
];
function TraceViz() {
  const L = useL();
  const lang = useLang();
  const [sel, setSel] = React.useState(5);
  const [noHot, setNoHot] = React.useState(false);
  const [shortVad, setShortVad] = React.useState(false);
  const [noKb, setNoKb] = React.useState(false);

  const faults = (noHot ? 1 : 0) + (shortVad ? 1 : 0) + (noKb ? 1 : 0);
  const total = TRACE.reduce((s, x) => s + x.ms, 0) + (shortVad ? 2 * 9000 : 0) + (noKb ? 12000 : 0);
  const outcome = noKb && noHot ? "hangup" : faults >= 2 ? "handoff" : faults === 1 ? (noHot ? "handoff" : "ok_slow") : "ok";
  const outcomeText = {
    ok: { zh: "预约成功", en: "booking confirmed" },
    ok_slow: { zh: "预约成功,但通话拖长", en: "confirmed, but the call ran long" },
    handoff: { zh: "转人工", en: "handed to a human" },
    hangup: { zh: "顾客挂断", en: "customer hung up" },
  }[outcome];
  const cur = TRACE[sel];

  return (
    <div>
      <VizHead idx="CS2" title={L("一通 2 分 17 秒的电话:点开每一跳,再往里注入故障", "One call of 2m17s: open any hop, then inject a fault")} />
      <div className="viz-ctrl">
        <Toggle label={L("注入:热词表里没有技师名", "Inject: therapist name missing from hotwords")} value={noHot} onChange={setNoHot} />
        <Toggle label={L("注入:尾点阈值调到 300 ms", "Inject: endpoint threshold at 300 ms")} value={shortVad} onChange={setShortVad} />
        <Toggle label={L("注入:知识库缺条目且关掉兜底", "Inject: knowledge entry missing and fallback off")} value={noKb} onChange={setNoKb} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("通话总时长", "Call duration")} value={`${Math.floor(total / 60000)}:${String(Math.round((total % 60000) / 1000)).padStart(2, "0")}`} tone={total > 180000 ? "warn" : "ok"} />
        <Kpi label={L("轮次", "Turns")} value={TRACE.length} tone="acc" />
        <Kpi label={L("注入故障", "Faults injected")} value={faults} tone={faults ? "warn" : "ok"} />
        <Kpi label={L("这通电话的结局", "How this call ends")} value={pick(lang, outcomeText)} tone={outcome === "ok" ? "ok" : outcome === "hangup" ? "warn" : "acc"} />
      </div>

      <div style={{ marginTop: 12, display: "grid", gap: 3 }}>
        {TRACE.map((x, i) => {
          const broken = (x.bad === "hot" && noHot) || (x.bad === "kb" && noKb);
          return (
            <div key={i} onClick={() => setSel(i)}
              style={{ cursor: "pointer", display: "grid", gridTemplateColumns: "26px 58px 1fr", gap: 8, alignItems: "center", padding: "5px 8px", borderRadius: 5,
                background: i === sel ? "color-mix(in srgb, var(--primary) 14%, transparent)" : broken ? "color-mix(in srgb, #c0453f 12%, transparent)" : "transparent",
                borderLeft: `3px solid ${x.who === "user" ? "var(--accent)" : broken ? "#c0453f" : "var(--primary)"}` }}>
              <span style={{ font: "600 10px var(--f-mono)", color: "var(--muted)" }}>{i + 1}</span>
              <span style={{ font: "600 10px var(--f-mono)", color: x.who === "user" ? "var(--accent)" : "var(--muted)" }}>{x.who === "user" ? L("顾客", "caller") : L("机器", "agent")}</span>
              <span style={{ font: "500 11.5px var(--f-sans)" }}>{lang === "zh" ? x.zh : x.en} <span style={{ font: "500 10px var(--f-mono)", color: "var(--muted)" }}>{` ${nf(x.ms / 1000, 1)}s`}</span></span>
            </div>
          );
        })}
      </div>

      <div style={{ marginTop: 10, padding: "8px 10px", background: "var(--surface-2)", borderRadius: 6 }}>
        <div className="vo-cap">{L(`第 ${sel + 1} 跳发生了什么`, `What happened at hop ${sel + 1}`)}</div>
        <div style={{ font: "500 12.5px var(--f-sans)", marginTop: 3 }}>{pick(lang, cur.note)}</div>
      </div>

      <Note mark={faults ? "⚠" : "→"} tone={faults ? "bad" : "on"}>
        {faults === 0
          ? L("零故障时这通电话是成功的,但注意它成功得很惊险:第 5 跳把技师名听错了,是第 6 跳的复述确认救回来的;第 8 跳知识库没有团购券叠加规则,是兜底策略挡住了一次幻觉。两个救命机制都不是模型能力,是你设计出来的流程。逐个打开上面的故障开关,看这通电话怎么从成功滑向转人工、再滑向挂断。",
              "With no faults the call succeeds — narrowly. Hop 5 misheard the therapist's name and hop 6's read-back rescued it; hop 8 had no knowledge entry for stacking a voucher and only the fallback prevented an invention. Neither rescue is a model capability; both are process you designed. Turn the fault switches on one at a time and watch the call slide from success to handoff to a hang-up.")
          : L(`注入了 ${faults} 个故障,这通电话的结局变成了「${pick(lang, outcomeText)}」。这正是前面二十七章每个参数的意义:热词表少一个名字、尾点阈值少三百毫秒、知识库少一条目——单看都是小事,叠在一起就是一个挂断的顾客和一条差评。`,
              `With ${faults} faults injected the call ends as ${pick(lang, outcomeText)}. That is the point of every parameter in the previous twenty-seven chapters: one name missing from a hotword list, three hundred milliseconds off an endpoint threshold, one entry absent from a knowledge base — each trivial alone, and together a hung-up customer and a bad review.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v30 · twilioLab — three integration tiers, latency and cost
   ========================================================= */
const TW_PATHS = [
  { k: "twiml",  zh: "TwiML 内置",        en: "TwiML built-in",      days: 2,  control: 1, audit: 3, zhFit: 2, extraMin: 0.00 },
  { k: "relay",  zh: "ConversationRelay", en: "ConversationRelay",   days: 5,  control: 3, audit: 4, zhFit: 3, extraMin: 0.45 },
  { k: "stream", zh: "Media Streams 自建", en: "Media Streams (own)", days: 15, control: 5, audit: 5, zhFit: 5, extraMin: 0.11 },
];
const TW_EDGES = [
  { k: "singapore", zh: "新加坡", en: "Singapore", rtt: 70 },
  { k: "tokyo",     zh: "东京",   en: "Tokyo",     rtt: 90 },
  { k: "sydney",    zh: "悉尼",   en: "Sydney",    rtt: 140 },
  { k: "dublin",    zh: "都柏林", en: "Dublin",    rtt: 220 },
  { k: "ashburn",   zh: "美东",   en: "US East",   rtt: 250 },
];
function TwilioViz() {
  const L = useL();
  const lang = useLang();
  const [path, setPath] = React.useState("stream");
  const [edge, setEdge] = React.useState("singapore");
  const [minutes, setMinutes] = React.useState(16000);
  const [numbers, setNumbers] = React.useState(3);
  const [colocated, setColocated] = React.useState(true);
  const [voiceMin, setVoiceMin] = React.useState(0.062);
  const [numMonth, setNumMonth] = React.useState(8.4);

  const p = TW_PATHS.find((x) => x.k === path);
  const e = TW_EDGES.find((x) => x.k === edge);

  const perMin = voiceMin + p.extraMin;
  const monthly = minutes * perMin + numbers * numMonth;
  // audio crosses to the edge, and again to your own stack unless it sits beside it
  const extraMs = e.rtt + (path === "stream" && !colocated ? e.rtt : 0);
  const budget = 750 + extraMs;                      // chapter RT1's optimised P50
  const feel = budget < 900 ? L("自然", "natural") : budget < 1300 ? L("能忍", "tolerable") : L("尴尬", "awkward");

  const rows = TW_PATHS.map((x) => ({
    ...x,
    cost: minutes * (voiceMin + x.extraMin) + numbers * numMonth,
  }));

  return (
    <div>
      <VizHead idx="CH4" title={L("三档接入:上手天数、可控性与每分钟成本,只能挑两样", "Three tiers: days to launch, control and cost per minute — pick two")} />
      <div className="vo-seg">
        {TW_PATHS.map((x) => (
          <button key={x.k} className={path === x.k ? "on" : ""} onClick={() => setPath(x.k)}>{lang === "zh" ? x.zh : x.en}</button>
        ))}
      </div>
      <div className="viz-ctrl" style={{ marginTop: 8 }}>
        <Choice label={L("区域 edge", "Region edge")} value={edge} onChange={setEdge}
          options={TW_EDGES.map((x) => ({ v: x.k, l: `${lang === "zh" ? x.zh : x.en} · ${x.rtt} ms` }))} />
        <Slider label={L("每月通话分钟", "Minutes per month")} min={1000} max={100000} step={1000} value={minutes} onChange={setMinutes} fmt={(v) => big(v)} />
        <Slider label={L("号码数", "Phone numbers")} min={1} max={30} value={numbers} onChange={setNumbers} />
        <Slider label={L("语音分钟单价", "Voice per minute")} min={0.02} max={0.3} step={0.002} value={voiceMin} onChange={setVoiceMin} fmt={(v) => `¥${nf(v, 3)}`} />
        <Slider label={L("号码月租", "Number per month")} min={2} max={60} step={0.5} value={numMonth} onChange={setNumMonth} fmt={(v) => yuan(v)} />
        <Toggle label={L("自建服务与 edge 同区", "Own stack colocated with the edge")} value={colocated} onChange={setColocated} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("每分钟成本", "Cost per minute")} value={`¥${nf(perMin, 3)}`} tone={perMin > 0.3 ? "warn" : "ok"} hint={L(`语音 ¥${nf(voiceMin, 3)} + 链路 ¥${nf(p.extraMin, 3)}`, `voice ¥${nf(voiceMin, 3)} + stack ¥${nf(p.extraMin, 3)}`)} />
        <Kpi label={L("每月成本", "Monthly cost")} value={yuan(monthly)} tone="acc" />
        <Kpi label={L("额外延迟", "Added latency")} value={ms(extraMs)} tone={extraMs > 200 ? "warn" : "ok"} hint={L(`端到端 P50 ≈ ${ms(budget)}`, `end-to-end P50 ≈ ${ms(budget)}`)} />
        <Kpi label={L("上手天数", "Days to launch")} value={p.days} unit={L(" 天", " d")} tone={p.days > 10 ? "warn" : "ok"} hint={feel} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("可控性(能不能在合成前拦下来)", "Control (can you intercept before synthesis)")} value={p.control} max={5} tone={p.control >= 4 ? "ok" : "warn"} valText={`${p.control}/5`} />
        <Bar label={L("可审计(纠纷时有没有文本)", "Auditability (text in a dispute)")} value={p.audit} max={5} tone={p.audit >= 4 ? "ok" : "acc"} valText={`${p.audit}/5`} />
        <Bar label={L("中文电话适配", "Fit for Mandarin telephony")} value={p.zhFit} max={5} tone={p.zhFit >= 4 ? "ok" : "warn"} valText={`${p.zhFit}/5`} />
      </div>

      <div style={{ marginTop: 10 }} className="vo-cap">{L("三档的月成本对比(当前话量与号码数):", "Monthly cost of all three at this volume:")}</div>
      <div style={{ marginTop: 4 }}>
        {rows.map((x) => (
          <Bar key={x.k} label={lang === "zh" ? x.zh : x.en} value={x.cost} max={Math.max(...rows.map((r) => r.cost)) * 1.1}
            tone={x.k === path ? "acc" : "mut"} valText={yuan(x.cost)} />
        ))}
      </div>

      <Note mark={extraMs > 200 ? "⚠" : "→"} tone={extraMs > 200 ? "bad" : "on"}>
        {extraMs > 200
          ? L(`选了 ${lang === "zh" ? e.zh : e.en} edge,音频每一程多走 ${ms(e.rtt)},端到端 P50 变成 ${ms(budget)}——第十六章辛苦砍下来的 800 毫秒,在这里一次还回去了。edge 要选离顾客最近的那个,自建的 ASR/TTS 也要和它同区。`,
              `With the ${e.en} edge each leg costs another ${ms(e.rtt)} and end-to-end P50 becomes ${ms(budget)} — the 800 ms chapter RT1 worked to save is handed straight back. Pick the edge nearest your callers, and colocate your own ASR and TTS with it.`)
          : path === "twiml"
            ? L("TwiML 两天就能上线,但识别与合成都是 Twilio 的:中文电话效果一般,而且模型输出直接变成音频,你没有在合成前拦下来的机会。适合做 IVR 和公告,不适合做会报价的客服。",
                "TwiML ships in two days, but recognition and synthesis are Twilio's: Mandarin telephony quality is mediocre and the output becomes audio with no chance to intercept before synthesis. Good for IVR and announcements, wrong for an agent that quotes prices.")
            : path === "relay"
              ? L("ConversationRelay 是中间档:Twilio 做识别与合成,只把文本推给你,你只写大脑。上手快、文本层仍可审,但音色、热词、流式切块这些第二三模块的旋钮都不在你手里了。",
                  "ConversationRelay is the middle tier: Twilio recognises and synthesises and pushes you text, so you write only the brain. Fast to stand up and still auditable at the text layer, but the voice, the hotwords and the chunking — every dial from modules II and III — are no longer yours.")
              : L(`Media Streams 把音频原样交给你:8 kHz μ-law、每 20 毫秒一帧 160 字节、base64 编码,双向。本书前五个模块讲的一切在这条路上都能用——代价是 ${p.days} 天的工程量,以及你要自己扛住延迟预算。`,
                  `Media Streams hands you the audio as it is: 8 kHz mu-law, one 160-byte frame every 20 ms, base64, bidirectional. Everything in this book's first five modules applies here — at a cost of about ${p.days} days of engineering, and the latency budget becomes yours to defend.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v31 · tierLab — how traffic splits across the three tiers
   ========================================================= */
const TIER_PRESETS = {
  one:  { faq: 40,  kb: 0,   thr: 0.62, fb: true,  zh: "设置一", en: "Config 1" },
  two:  { faq: 60,  kb: 0,   thr: 0.40, fb: false, zh: "设置二", en: "Config 2" },
  opt:  { faq: 120, kb: 200, thr: 0.62, fb: true,  zh: "优化版", en: "Optimised" },
};
function TierViz() {
  const L = useL();
  const lang = useLang();
  const [preset, setPreset] = React.useState("opt");
  const [faq, setFaq] = React.useState(120);
  const [kb, setKb] = React.useState(200);
  const [thr, setThr] = React.useState(0.62);
  const [fb, setFb] = React.useState(true);
  const [vol, setVol] = React.useState(3000);

  const apply = (k) => {
    const p = TIER_PRESETS[k];
    setPreset(k); setFaq(p.faq); setKb(p.kb); setThr(p.thr); setFb(p.fb);
  };

  // tier 1 — exact answers, saturating in the number of curated entries
  const faqHit = 0.82 * (1 - Math.exp(-faq / 70));
  // tier 2 — retrieval on what is left; a higher threshold passes fewer but cleaner
  const rest1 = 1 - faqHit;
  const kbCover = 1 - Math.exp(-kb / 110);
  const passRate = clamp(kbCover * (1 - (thr - 0.4) * 0.9), 0, 1);
  const ragHit = rest1 * passRate;
  // tier 3 — whatever neither tier could answer
  const rest2 = rest1 - ragHit;
  const fallback = fb ? rest2 : 0;
  const ungrounded = fb ? 0 : rest2;              // answered with no evidence at all
  // Not every ungrounded answer is wrong: general knowledge is often right.
  // Only the shop-specific half (price, slots, rules) gets invented.
  const SHOP_SPECIFIC = 0.5, FABRICATE = 0.58;
  const halluc = ungrounded * SHOP_SPECIFIC * FABRICATE + fallback * 0.02;

  const latency = faqHit * 3 + ragHit * 600 + fallback * 250 + ungrounded * 430;
  const costPerK = (ragHit * 0.0032 + ungrounded * 0.0021) * 1000;
  const auditable = faqHit + ragHit;             // human-written, or citing evidence

  const bars = [
    { k: "faq", l: L("FAQ 精确层 · 秒回", "FAQ tier · instant"), v: faqHit, tone: "ok" },
    { k: "rag", l: L("RAG 检索层 · 带证据", "Retrieval tier · with evidence"), v: ragHit, tone: "acc" },
    { k: "fb",  l: L("兜底层 · 说不知道并转人工", "Fallback · says so, hands off"), v: fallback, tone: "mut" },
    { k: "un",  l: L("无据直答 · 幻觉来源", "Ungrounded answer · the hallucination source"), v: ungrounded, tone: "warn" },
  ];

  return (
    <div>
      <VizHead idx="BR5" title={L("一次提问在三层之间怎么降级,以及每一层各自花掉什么", "How one question degrades through three tiers, and what each tier spends")} />
      <div className="vo-seg">
        {Object.keys(TIER_PRESETS).map((k) => (
          <button key={k} className={preset === k ? "on" : ""} onClick={() => apply(k)}>
            {lang === "zh" ? TIER_PRESETS[k].zh : TIER_PRESETS[k].en}
          </button>
        ))}
      </div>
      <div className="viz-ctrl" style={{ marginTop: 8 }}>
        <Slider label={L("FAQ 条目数", "FAQ entries")} min={0} max={400} step={10} value={faq} onChange={(v) => { setFaq(v); setPreset(""); }} />
        <Slider label={L("知识库条目数", "Knowledge entries")} min={0} max={600} step={10} value={kb} onChange={(v) => { setKb(v); setPreset(""); }} />
        <Slider label={L("RAG 分数阈值", "Retrieval score threshold")} min={0.4} max={0.85} step={0.01} value={thr} onChange={(v) => { setThr(v); setPreset(""); }} fmt={(v) => nf(v, 2)} />
        <Slider label={L("日提问量", "Questions per day")} min={200} max={10000} step={100} value={vol} onChange={setVol} fmt={(v) => big(v)} />
        <Toggle label={L("开兜底(查不到就说不知道)", "Fallback on (say so when nothing is found)")} value={fb} onChange={(v) => { setFb(v); setPreset(""); }} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("FAQ 命中", "Caught by FAQ")} value={pct1(faqHit)} tone={faqHit > 0.5 ? "ok" : "acc"} hint={L("人写答案 · 3 ms · 零 token", "human-written · 3 ms · no tokens")} />
        <Kpi label={L("有据作答", "Answered with evidence")} value={pct1(ragHit)} tone="acc" />
        <Kpi label={L("幻觉率", "Hallucination rate")} value={pct1(halluc)} tone={halluc > 0.03 ? "warn" : "ok"} hint={fb ? L("兜底挡住了", "the fallback held") : L("没开兜底", "no fallback")} />
        <Kpi label={L("平均延迟", "Average latency")} value={ms(latency)} tone={latency > 400 ? "warn" : "ok"} hint={L(`每千次 ${yuan(costPerK)}`, `${yuan(costPerK)} per 1,000`)} />
      </div>

      <div style={{ marginTop: 10 }}>
        {bars.map((b) => <Bar key={b.k} label={b.l} value={b.v} max={1} tone={b.tone} valText={pct1(b.v)} />)}
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("可追责的回答(人写的 + 引了证据的)", "Attributable answers (human-written or evidence-citing)")} value={auditable} max={1} tone={auditable > 0.85 ? "ok" : "warn"} valText={pct1(auditable)} />
        <Bar label={L("每天要人接手的问题", "Questions reaching a human each day")} value={fallback * vol} max={vol} tone="mut" valText={nf(fallback * vol, 0)} />
      </div>

      <Note mark={halluc > 0.03 ? "⚠" : "→"} tone={halluc > 0.03 ? "bad" : "on"}>
        {!fb
          ? L(`没开兜底:有 ${pct1(rest2)} 的问题既没被 FAQ 接住、也没检索到证据,却仍然被要求回答——其中涉及价格、空档、规则这些门店专属信息的部分会被编出一个听起来很合理的答案,幻觉率 ${pct1(halluc)}。检索和兜底是一件事,不是两件事:只加检索不加阈值兜底,等于把「无据可依」换成「据可能错」。`,
              `With no fallback, ${pct1(rest2)} of questions are neither caught by FAQ nor supported by retrieved evidence, and are answered anyway — the shop-specific half of them — price, slots, rules — becomes a plausible invention, giving ${pct1(halluc)} hallucination. Retrieval and fallback are one thing, not two: adding retrieval without a threshold and a fallback merely swaps no-evidence for possibly-wrong-evidence.`)
          : faq === 0
            ? L(`FAQ 层是空的,所有问题都去走检索:平均延迟 ${ms(latency)}、每千次 ${yuan(costPerK)}。但高频问题的答案本来就是确定的——把它们送去检索多花了三百毫秒、一份 token,还多了一次检索错块的机会。把 FAQ 条目加上去,看这三个数字一起掉下来。`,
                `The FAQ tier is empty, so everything goes through retrieval: ${ms(latency)} average latency at ${yuan(costPerK)} per thousand. But high-frequency answers were already determinate — routing them through retrieval bought three hundred milliseconds, a token bill and one more chance to fetch the wrong chunk. Add FAQ entries and watch all three numbers fall together.`)
            : L(`三层各司其职:${pct1(faqHit)} 被 FAQ 秒回,${pct1(ragHit)} 带着证据回答,剩下 ${pct1(fallback)} 明确说不知道并转人工。可追责的回答占 ${pct1(auditable)},幻觉率 ${pct1(halluc)}。注意兜底那一层不是失败——它是唯一能让你回答「我们到底答不上哪些问题」的东西。`,
                `Each tier does its own job: ${pct1(faqHit)} answered instantly by FAQ, ${pct1(ragHit)} answered with evidence, and the remaining ${pct1(fallback)} told plainly and handed off. Attributable answers run ${pct1(auditable)} and hallucination ${pct1(halluc)}. Note the fallback tier is not a failure — it is the only thing that can tell you which questions you cannot answer.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   VIZ registry + <Viz>
   ========================================================= */
const VIZ = Object.assign({}, window.__VO_VIZ_1, window.__VO_VIZ_2, window.__VO_VIZ_3, {
  wechatLab: WechatViz,
  handoffLab: HandoffViz,
  qaLab: QaViz,
  complyLab: ComplyViz,
  maturityLab: MaturityViz,
  chainLab: ChainViz,
  traceLab: TraceViz,
  twilioLab: TwilioViz,
  tierLab: TierViz,
});


function Viz({ name }) {
  const C = VIZ[name];
  if (!C) return null;
  return <C />;
}

window.VIZ = VIZ;
window.Viz = Viz;
