/* =========================================================
   figures3.jsx — lecture figures v21–v29, then <Figure> and
   the window exports. figures4.jsx (loaded after) appends the
   module architecture diagrams to the same FIGN object.
   ========================================================= */

/* ---------------- v21 · the cost waterfall ---------------- */
FIGN["v21-waterfall"] = function ({ idx }) {
  const L = useL();
  const parts = [
    { l: L("电话线路", "telephony"), v: 0.24, tone: "warn" },
    { l: L("大模型输入 token", "LLM input tokens"), v: 0.052, tone: "a" },
    { l: L("语音识别", "recognition"), v: 0.12, tone: "p" },
    { l: L("语音合成", "synthesis"), v: 0.113, tone: "ok" },
  ];
  const total = parts.reduce((s, x) => s + x.v, 0);
  let x = 26;
  return (
    <FigFrame idx={idx} h={214} cap={L("把一通三分钟的电话拆开,账单分四段:识别按分钟、线路按分钟、大模型按 token、合成按字符。画成瀑布之后会看到一个反直觉的结果——在多数门店配置下最贵的一段不是 AI,是电话线路;第二贵的是大模型的输入 token,而这恰恰是最容易优化的一项:把检索从 top-8 降到 top-3、把对话历史做摘要,这一段能直接腰斩。最后别忘了把成本放回收益里看:在客单价两百多的生意里,一通把顾客约到店的电话,花两块还是两毛,差别没有想象中大。", "Break a three-minute call apart and the bill has four lines: recognition per minute, telephony per minute, the LLM per token, synthesis per character. Drawn as a waterfall, a counter-intuitive result appears — under most shop configurations the most expensive line is not the AI but the phone line, and the second is LLM input tokens, which happens to be the most optimisable: dropping retrieval from top-8 to top-3 and summarising dialogue history halves it. And keep cost next to revenue: in a business with a 200-plus yuan ticket, whether a booking call costs two yuan or twenty cents matters less than it feels.")}>
      {parts.map((p, i) => {
        const w = (p.v / total) * 600;
        const el = (
          <g key={i}>
            <rect x={x} y={40} width={Math.max(3, w)} height={44} rx="5" fill={`color-mix(in srgb, ${FTONE[p.tone]} 84%, transparent)`} stroke={FTONE[p.tone]} />
            <text x={x + w / 2} y={68} textAnchor="middle" style={{ font: "700 12px var(--f-mono)", fill: "#fff" }}>{`${Math.round((p.v / total) * 100)}%`}</text>
            <text x={x + w / 2} y={102} textAnchor="middle" style={{ font: "500 10px var(--f-body)", fill: "var(--ink)" }}>{p.l}</text>
            <text x={x + w / 2} y={118} textAnchor="middle" style={{ font: "600 10px var(--f-mono)", fill: "var(--muted)" }}>{`¥${p.v.toFixed(3)}`}</text>
          </g>
        );
        x += Math.max(3, w) + 4;
        return el;
      })}
      <FT x={340} y={26} cls="tk">{L(`一通 3 分钟电话 ≈ ¥${total.toFixed(2)}`, `one 3-minute call ≈ ¥${total.toFixed(2)}`)}</FT>
      <FBox x={26} y={140} w={300} h={32} label={L("人工:¥6–9 / 通", "a human: ¥6–9 per call")} tone="m" />
      <FBox x={338} y={140} w={300} h={32} label={L(`AI:¥${total.toFixed(2)} / 通`, `AI: ¥${total.toFixed(2)} per call`)} tone="ok" />
      <FT x={340} y={196} cls="tn">{L("最容易拿到的优化是压缩上下文,不是换更便宜的模型", "the easiest win is compressing context, not switching to a cheaper model")}</FT>
    </FigFrame>
  );
};

/* ---------------- v22 · SIP and outbound ---------------- */
FIGN["v22-sip"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={222} cap={L("呼入是一个工程问题:运营商通过 SIP 中继把呼叫送到你的服务器(自建常用 FreeSWITCH 或 Asterisk),音频以 RTP 流进出,并发线路数是硬约束——买了几路就只能同时通几路,超了就是忙音;链路编码通常是 8 kHz 的 G.711 或 G.729,又回到窄带问题。呼出则是一个合规问题:商业性语音呼叫必须事先取得同意,用户拒绝后不得再拨,时段与频次都有约束,并要提供便捷的退订方式。做过头的代价不只是投诉——号码可能被限制甚至停用,而门店的号码是资产。", "Inbound is an engineering problem: the carrier delivers calls over a SIP trunk to your server (self-hosted setups usually run FreeSWITCH or Asterisk), audio flows as RTP, and concurrent channels are a hard limit — buy four and the fifth caller hears busy. The codec is usually 8 kHz G.711 or G.729, returning you to narrowband. Outbound is a compliance problem: commercial voice calls need prior consent, must never repeat after refusal, are bounded in time of day and frequency, and must offer easy opt-out. Overdoing it costs more than complaints — the number can be restricted or cut off, and a shop's number is an asset.")}>
      <FT x={168} y={20} cls="tk">{L("呼入:工程问题", "inbound: an engineering problem")}</FT>
      <FBox x={18} y={34} w={74} h={36} label={L("顾客", "caller")} tone="n" />
      <FArrow x1={92} y1={52} x2={116} y2={52} />
      <FBox x={118} y={34} w={78} h={36} label={L("运营商", "carrier")} tone="m" />
      <FArrow x1={196} y1={52} x2={220} y2={52} />
      <FBox x={222} y={28} w={96} h={48} label="SIP trunk" sub={L("4 路并发", "4 channels")} tone="p" />
      <FArrow x1={318} y1={52} x2={342} y2={52} />
      <FBox x={344} y={34} w={104} h={36} label="FreeSWITCH" tone="a" />
      <FArrow x1={448} y1={52} x2={472} y2={52} />
      <FBox x={474} y={34} w={96} h={36} label={L("AI 语音链路", "AI pipeline")} tone="ok" />
      <FT x={270} y={92} cls="tn">{L("第 5 个顾客 → 忙音", "caller #5 → busy tone")}</FT>
      <FT x={512} y={92} cls="tn">{L("RTP 音频 · 8 kHz G.711", "RTP audio · 8 kHz G.711")}</FT>

      <line x1={12} y1={112} x2={668} y2={112} stroke="var(--hairline-strong)" />
      <FT x={168} y={132} cls="tk">{L("呼出:合规问题", "outbound: a compliance problem")}</FT>
      {[
        { l: L("事先取得同意", "prior consent"), tone: "ok" },
        { l: L("拒绝后不再拨", "never after refusal"), tone: "ok" },
        { l: L("时段与频次限制", "time and frequency limits"), tone: "warn" },
        { l: L("便捷退订", "easy opt-out"), tone: "ok" },
      ].map((r, i) => (
        <FBox key={i} x={18 + i * 164} y={144} w={152} h={32} label={r.l} tone={r.tone} />
      ))}
      <FT x={340} y={198} cls="ta">{L("踩线的代价不是投诉,是号码被限制 —— 门店的号码是资产", "crossing the line costs more than complaints: the number itself, which is an asset")}</FT>
      <FT x={340} y={214} cls="tn">{L("名单质量比拨打频次值钱得多", "list quality is worth far more than dialling frequency")}</FT>
    </FigFrame>
  );
};

/* ---------------- v23 · private domain ---------------- */
FIGN["v23-wechat"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={216} cap={L("电话接住的是新客,微信留住的是熟客,而按摩养生这门生意的利润几乎全在熟客身上。三个入口的规则不同:公众号客服消息只能在用户互动后的 48 小时窗口内主动发送;小程序有独立的客服会话;企业微信把顾客留在员工的企业身份下、会话可存档,是门店最该经营的资产。AI 在这里的价值不是群发得更快,而是按每个人自己的到店周期挑时机。而决定成败的变量是频次:发多了换来的是拉黑,那是用长期资产换短期数字。", "The phone catches new customers; WeChat keeps the regulars — and nearly all the profit in this business sits with regulars. The three doorways have different rules: an official account can push service messages only inside the 48-hour window after an interaction; a mini-program has its own service session; WeCom keeps the customer attached to a staff member's corporate identity with archivable conversations, which is the asset a shop should build. The AI's value here is not broadcasting faster but picking the moment from each person's own visit cycle. The decisive variable is frequency: over-messaging earns a block, trading a long-term asset for a short-term number.")}>
      <FBox x={20} y={30} w={192} h={40} label={L("公众号客服消息", "official-account service message")} sub={L("48 小时窗口", "48-hour window")} tone="p" />
      <FBox x={228} y={30} w={192} h={40} label={L("小程序客服会话", "mini-program service session")} sub={L("下单场景内", "inside the transaction")} tone="p" />
      <FBox x={436} y={30} w={216} h={40} label={L("企业微信 1v1", "WeCom one-to-one")} sub={L("长期关系 · 会话存档", "durable relationship · archived")} tone="a" />

      <FT x={170} y={96} cls="tk">{L("AI 在私域做什么", "what the AI does here")}</FT>
      {[
        L("到期提醒:上次肩颈是 23 天前", "due: last neck session 23 days ago"),
        L("空档促销:今天下午三点有位", "empty slot: three o'clock today"),
        L("会员权益到期提醒", "membership expiry notice"),
      ].map((t, i) => (
        <g key={i}>
          <rect x={20} y={108 + i * 30} width={320} height={24} rx="5" fill="var(--surface-2)" stroke="var(--hairline-strong)" />
          <text x={180} y={124 + i * 30} textAnchor="middle" style={{ font: "500 9.5px var(--f-body)", fill: "var(--ink)" }}>{t}</text>
        </g>
      ))}

      <line x1={356} y1={88} x2={356} y2={200} stroke="var(--hairline-strong)" strokeDasharray="4 3" />
      <FT x={510} y={96} cls="ta">{L("频次的两端", "the two ends of frequency")}</FT>
      <rect x={378} y={108} width={274} height={38} rx="6" fill="color-mix(in srgb, #2e9e6b 16%, transparent)" stroke="#2e9e6b" />
      <FT x={515} y={132} cls="tp">{L("每月 1–2 次 · 打开率高 · 池子不缩", "1–2 a month · high open rate · pool intact")}</FT>
      <rect x={378} y={152} width={274} height={38} rx="6" fill="color-mix(in srgb, #c0453f 14%, transparent)" stroke="#c0453f" />
      <FT x={515} y={176} cls="ta">{L("每周一次 · GMV 涨 · 半年后池子缩水", "weekly · GMV up · pool shrinks in six months")}</FT>
      <FT x={340} y={210} cls="tn">{L("私域最贵的东西是「还愿意看你消息的人数」", "the expensive thing in private-domain marketing is the count of people still willing to read you")}</FT>
    </FigFrame>
  );
};

/* ---------------- v24 · handoff ---------------- */
FIGN["v24-handoff"] = function ({ idx }) {
  const L = useL();
  const sigs = [
    { l: L("连续两轮听不清", "two unclear turns"), tone: "warn" },
    { l: L("投诉 / 退款 / 身体不适", "complaint / refund / discomfort"), tone: "bad" },
    { l: L("情绪信号(语速、音量、负面词)", "emotion (rate, volume, negatives)"), tone: "warn" },
    { l: L("顾客直接说「转人工」", "the caller asks for a person"), tone: "bad" },
  ];
  return (
    <FigFrame idx={idx} h={218} cap={L("衡量一套成熟 AI 客服的指标,不是「多少不用转人工」,而是「该转的时候转得多干脆」。四类信号必须触发转人工,其中最后一条——顾客直接要求找人——必须无条件立刻生效,任何形式的挽留都会让人更恼火。阈值本身是一个分类权衡:定高转得多、人工排队;定低省人力、把本该转的对话砸在自己手里。最后一个常被忽略的细节是上下文交接:接手的人要立刻看到之前说了什么、顾客要约什么、AI 卡在哪一步,否则顾客要从头讲一遍。", "A mature AI service is measured not by how rarely it hands off but by how cleanly it hands off when it should. Four signals must trigger a transfer, and the last — the caller asking for a person — must take effect immediately and unconditionally, because any attempt to retain them makes it worse. The threshold itself is a classification trade-off: high transfers more and queues people, low saves labour and ruins the conversations that should have been passed on. The neglected detail is context transfer: whoever picks up must see what was said, what is wanted and where the AI stuck — otherwise the customer starts again from the beginning.")}>
      {sigs.map((s, i) => (
        <g key={i}>
          <rect x={18} y={28 + i * 34} width={268} height={26} rx="5" fill={`color-mix(in srgb, ${FTONE[s.tone]} 14%, transparent)`} stroke={FTONE[s.tone]} />
          <text x={152} y={45 + i * 34} textAnchor="middle" style={{ font: "500 10px var(--f-body)", fill: "var(--ink)" }}>{s.l}</text>
          <FArrow x1={286} y1={41 + i * 34} x2={318} y2={92} c="var(--muted)" />
        </g>
      ))}
      <FBox x={320} y={72} w={110} h={44} label={L("转人工", "hand off")} tone="a" />
      <FArrow x1={430} y1={94} x2={462} y2={94} />
      <FBox x={464} y={60} w={190} h={32} label={L("上下文交接卡", "the context card")} tone="ok" />
      <FBox x={464} y={98} w={190} h={32} label={L("坐席排队 < 30 秒", "agent queue under 30 s")} tone="ok" />
      <FT x={559} y={148} cls="tn">{L("说了什么 · 要约什么 · 卡在哪", "said · wanted · stuck")}</FT>
      <FT x={152} y={174} cls="tk">{L("阈值定高 → 转得多 → 排队", "threshold high → more transfers → queue")}</FT>
      <FT x={152} y={192} cls="tn">{L("阈值定低 → 省人力 → 砸在手里", "threshold low → cheaper → ruined calls")}</FT>
      <FT x={470} y={188} cls="ta">{L("等待超过 30 秒的损失,常常大过 AI 答错", "waiting past 30 s often costs more than a wrong answer")}</FT>
    </FigFrame>
  );
};

/* ---------------- v25 · the QA loop ---------------- */
FIGN["v25-loop"] = function ({ idx }) {
  const L = useL();
  const steps = [
    L("全量转写", "transcribe everything"),
    L("模型逐条打分", "score every call"),
    L("坏案例分类", "classify bad cases"),
    L("归因到组件", "attribute to a component"),
    L("修:热词 / 知识 / 话术 / 重试", "fix: hotwords / knowledge / script / retry"),
    L("灰度 + 回归集", "canary + regression set"),
  ];
  const cx = 340, cy = 112, R = 76;
  return (
    <FigFrame idx={idx} h={218} cap={L("上线第一周的数据一定难看,这不是失败,是信息——问题在于你有没有一条把难看的地方捞出来并修掉的闭环。人耳抽检百分之几的时代已经过去:把全部通话转写后交给模型逐条打分,成本大约每通几分钱,覆盖率从 5% 变成 100%。但抽样统计仍然要懂:抽 200 通发现 3 个坏案例,真实坏案例率的 95% 区间大约是 0.3% 到 4.3%,宽得没法做决策。修完一定要进回归集,否则下次改提示词会把这次的修复顶掉。", "Week one's numbers always look bad, and that is information rather than failure — provided you have a loop that catches and fixes what is bad. The era of listening to a few percent by ear is over: transcribe everything and have a model score each call for a few cents apiece, taking coverage from 5% to 100%. Sampling statistics still matter, though: three bad cases in two hundred sampled calls puts the true rate somewhere around 0.3% to 4.3%, far too wide to act on. And every fix must enter the regression set, or the next prompt rewrite will undo it.")}>
      {steps.map((s, i) => {
        const ang = -Math.PI / 2 + (i / steps.length) * Math.PI * 2;
        const x = cx + Math.cos(ang) * R * 2.5, y = cy + Math.sin(ang) * R;
        const ang2 = -Math.PI / 2 + ((i + 1) / steps.length) * Math.PI * 2;
        const x2 = cx + Math.cos(ang2) * R * 2.5, y2 = cy + Math.sin(ang2) * R;
        return (
          <g key={i}>
            <FArrow x1={x + Math.cos(ang + 1.1) * 46} y1={y + Math.sin(ang + 1.1) * 22} x2={x2 - Math.cos(ang2 - 1.1) * 46} y2={y2 - Math.sin(ang2 - 1.1) * 22} c="var(--muted)" />
            <rect x={x - 82} y={y - 15} width={164} height={30} rx="6" fill="var(--surface-2)" stroke={i >= 4 ? "var(--primary)" : "var(--hairline-strong)"} strokeWidth={i >= 4 ? 1.4 : 1} />
            <text x={x} y={y + 4} textAnchor="middle" style={{ font: "500 9.5px var(--f-body)", fill: "var(--ink)" }}>{s}</text>
          </g>
        );
      })}
      <FT x={cx} y={cy - 6} cls="tk">{L("数据闭环", "the loop")}</FT>
      <FT x={cx} y={cy + 12} cls="tn">{L("没有闭环,指标就在原地打转", "without it, metrics go in circles")}</FT>
      <FT x={340} y={212} cls="tn">{L("抽 200 通发现 3 个 → 真实率区间 0.3%–4.3%,宽得没法决策", "3 bad cases in 200 sampled → a 0.3%–4.3% interval, too wide to act on")}</FT>
    </FigFrame>
  );
};

/* ---------------- v26 · the compliance gate ---------------- */
FIGN["v26-gate"] = function ({ idx }) {
  const L = useL();
  const cols = [
    { t: L("个人信息", "personal information"), tone: "p", items: [L("录音告知与同意", "notice and consent"), L("声纹单独同意", "separate consent for voiceprints"), L("最小必要", "minimal necessary"), L("期限与删除", "retention and deletion")] },
    { t: L("生成内容", "generated content"), tone: "a", items: [L("合成语音标识", "label synthetic speech"), L("真人音色书面授权", "written voice authorisation"), L("可撤回与下线", "revocable and retirable"), L("留痕可查", "auditable records")] },
    { t: L("营销与外呼", "marketing and outbound"), tone: "warn", items: [L("事先同意", "prior consent"), L("拒绝后不再拨", "never after refusal"), L("时段与频次", "time and frequency"), L("便捷退订", "easy opt-out")] },
    { t: L("行业红线", "industry red lines"), tone: "bad", items: [L("不得宣称疗效", "no efficacy claims"), L("不作症状诊断", "no symptom diagnosis"), L("越界请求:拒绝 + 留痕", "out-of-bounds: refuse and log"), L("未成年人特别处理", "special handling for minors")] },
  ];
  return (
    <FigFrame idx={idx} h={224} cap={L("门店语音 AI 的合规义务大致四组,任何一组里的任何一条踩了,后果都可能是整个项目下线,甚至波及营业执照和平台账号。把它做成一份上线检查单、每季度复核一次,并且在每次新增能力(开外呼、换音色、加渠道)时重新过一遍。特别提醒最后一列:养生按摩不是医疗服务,不得作治疗、疗效、根治类表述;遇到越界暗示必须中性拒绝、结束话题并留痕。本图为工程与管理提示,不构成法律意见。", "The compliance duties around a shop's voice AI fall into four groups, and missing any single item in any group can take the whole project offline and reach your licence and platform accounts. Turn it into a launch checklist, review it quarterly, and walk it again whenever you add a capability — outbound, a new voice, a new channel. Note the last column in particular: wellness massage is not medical service, so no claims of treating or curing, and any out-of-bounds suggestion must be refused neutrally, ended and logged. This figure is engineering and management guidance, not legal advice.")}>
      {cols.map((c, i) => {
        const x = 16 + i * 168;
        return (
          <g key={i}>
            <FBox x={x} y={20} w={152} h={30} label={c.t} tone={c.tone} />
            {c.items.map((it, j) => (
              <g key={j}>
                <rect x={x} y={58 + j * 32} width={152} height={26} rx="4" fill="var(--surface-2)" stroke="var(--hairline-strong)" />
                <text x={x + 76} y={75 + j * 32} textAnchor="middle" style={{ font: "500 9px var(--f-body)", fill: "var(--ink)" }}>{it}</text>
              </g>
            ))}
          </g>
        );
      })}
      <FT x={340} y={204} cls="ta">{L("十四项全过才能上线;任何一项没做到就不该上线", "all fourteen or it does not ship")}</FT>
      <FT x={340} y={220} cls="tn">{L("工程与管理提示,不构成法律意见", "engineering and management guidance, not legal advice")}</FT>
    </FigFrame>
  );
};

/* ---------------- v27 · the flywheel and the roadmap ---------------- */
FIGN["v27-flywheel"] = function ({ idx }) {
  const L = useL();
  const phases = [
    { t: L("1–2 月", "month 1–2"), l: L("文字渠道问答", "text channels, answers only"), tone: "ok" },
    { t: L("3–4 月", "month 3–4"), l: L("IM 上跑通预约写操作", "booking writes on IM"), tone: "p" },
    { t: L("5–8 月", "month 5–8"), l: L("上电话:实时与打断", "take the phone: realtime and barge-in"), tone: "a" },
    { t: L("9–12 月", "month 9–12"), l: L("外呼、召回与全量质检", "outbound, win-back, full QA"), tone: "warn" },
  ];
  return (
    <FigFrame idx={idx} h={218} cap={L("落地顺序之所以保守,原因很实际:门店没有试错的预算。第一阶段只做文字渠道的问答,不碰写操作,风险最低却覆盖了最高频的咨询;第二阶段加预约写操作,先在 IM 上把工具调用、幂等和占位释放跑稳;第三阶段才上电话,因为电话是实时的、不可撤回的、合规压力最大;第四阶段做外呼与主动召回。贯穿其中的是数据飞轮:通话产生转写,转写产生标注,标注改进热词、知识库与提示词,改进带来更高自助率,更高自助率带来更多可用数据。", "The rollout order is conservative for a practical reason: a shop has no budget for trial and error. Phase one answers questions on text channels only, touching no writes — the lowest risk while covering the most frequent enquiries. Phase two adds booking writes, staying on IM until tool calls, idempotency and hold release are stable. Phase three finally takes the phone, which is realtime, irreversible and carries the heaviest compliance load. Phase four adds outbound and win-back. Running through all of it is the flywheel: calls produce transcripts, transcripts produce labels, labels improve hotwords, knowledge and prompts, improvements raise self-service, and higher self-service produces more usable data.")}>
      {phases.map((p, i) => {
        const x = 18 + i * 166;
        return (
          <g key={i}>
            <FBox x={x} y={26} w={150} h={34} label={p.t} tone={p.tone} />
            <rect x={x} y={66} width={150} height={38} rx="5" fill="var(--surface-2)" stroke="var(--hairline-strong)" />
            <text x={x + 75} y={89} textAnchor="middle" style={{ font: "500 9.5px var(--f-body)", fill: "var(--ink)" }}>{p.l}</text>
            {i < phases.length - 1 ? <FArrow x1={x + 150} y1={43} x2={x + 164} y2={43} /> : null}
          </g>
        );
      })}
      <FT x={340} y={124} cls="tk">{L("先文字后语音 · 先读后写", "text before voice · read before write")}</FT>
      {[
        L("通话", "calls"), L("转写与标注", "transcripts and labels"),
        L("热词 / 知识 / 提示词", "hotwords / knowledge / prompts"), L("自助率↑", "self-service ↑"),
      ].map((t, i) => (
        <g key={i}>
          <FBox x={40 + i * 160} y={140} w={140} h={32} label={t} tone={i === 3 ? "ok" : "n"} />
          {i < 3 ? <FArrow x1={180 + i * 160} y1={156} x2={198 + i * 160} y2={156} c="var(--primary)" /> : null}
        </g>
      ))}
      <path d="M110,176 C200,206 480,206 600,176" fill="none" stroke="var(--primary)" strokeWidth="1.3" strokeDasharray="4 3" />
      <FT x={355} y={208} cls="tn">{L("飞轮转起来之后,你的系统会比任何通用厂商更懂你这家店", "once it spins, your system understands your shop better than any general vendor can")}</FT>
    </FigFrame>
  );
};

/* ---------------- v28 · the chain's ledger ---------------- */
FIGN["v28-ledger"] = function ({ idx }) {
  const L = useL();
  const pos = [
    { l: L("挽回的漏接预约", "recovered missed bookings"), v: 45600 },
    { l: L("释放的前台工时", "front-desk hours freed"), v: 3600 },
    { l: L("唤醒的沉睡会员", "dormant members reactivated"), v: 21100 },
  ];
  const neg = [
    { l: L("被 AI 劝退的顾客", "customers deflected"), v: 16200 },
    { l: L("平台与线路费", "platform and telephony"), v: 3100 },
    { l: L("运维人力分摊", "operations allocation"), v: 2600 },
  ];
  const maxV = 46000;
  return (
    <FigFrame idx={idx} h={224} cap={L("一家三店连锁的真实结构:日均 210 通来电、前台 4 人、客单价 268 元、会员 6800 人。三个反直觉的结论会自己浮出来。第一,收益的最大头往往不是省人力,而是挽回漏接——前者是可见的,后者是看不见的,所以老板通常低估它。第二,把 AI 覆盖率拉到九成时净收益可能反而下降,因为劝退损失是超线性的。第三,一次性集成成本被门店数摊薄,连锁天然比单店更适合上这套东西。", "The real structure of a three-shop chain: 210 calls a day, four front-desk staff, an average ticket of 268 yuan, 6,800 members. Three counter-intuitive conclusions surface on their own. First, the largest benefit is usually not saved labour but recovered calls — saved hours are visible and recovered calls are not, so owners routinely underestimate them. Second, pushing AI coverage to ninety percent can reduce net benefit because deflection loss grows super-linearly. Third, the one-off integration divides across shops, which is why a chain fits this better than a single shop.")}>
      <FT x={168} y={20} cls="tp">{L("收益 / month", "benefits / month")}</FT>
      {pos.map((p, i) => (
        <g key={i}>
          <rect x={180} y={30 + i * 30} width={(p.v / maxV) * 140} height={22} rx="4" fill="color-mix(in srgb, #2e9e6b 78%, transparent)" stroke="#2e9e6b" />
          <text x={174} y={46 + i * 30} textAnchor="end" style={{ font: "500 10px var(--f-body)", fill: "var(--ink)" }}>{p.l}</text>
          <text x={186 + (p.v / maxV) * 140} y={46 + i * 30} style={{ font: "600 9.5px var(--f-mono)", fill: "var(--muted)" }}>{`+¥${(p.v / 1000).toFixed(1)}k`}</text>
        </g>
      ))}
      <FT x={168} y={136} cls="ta">{L("支出与损耗 / month", "costs and losses / month")}</FT>
      {neg.map((p, i) => (
        <g key={i}>
          <rect x={180} y={146 + i * 26} width={(p.v / maxV) * 140} height={19} rx="4" fill="color-mix(in srgb, #c0453f 72%, transparent)" stroke="#c0453f" />
          <text x={174} y={160 + i * 26} textAnchor="end" style={{ font: "500 10px var(--f-body)", fill: "var(--ink)" }}>{p.l}</text>
          <text x={186 + (p.v / maxV) * 140} y={160 + i * 26} style={{ font: "600 9.5px var(--f-mono)", fill: "var(--muted)" }}>{`−¥${(p.v / 1000).toFixed(1)}k`}</text>
        </g>
      ))}
      <line x1={392} y1={16} x2={392} y2={206} stroke="var(--hairline-strong)" strokeDasharray="4 3" />
      <FBox x={412} y={34} w={240} h={40} label={L("月净收益 ≈ ¥48.4k", "monthly net ≈ ¥48.4k")} tone="ok" />
      <FBox x={412} y={82} w={240} h={40} label={L("一次性集成 ¥58k", "one-off integration ¥58k")} tone="n" />
      <FBox x={412} y={130} w={240} h={40} label={L("回收期 ≈ 1.2 个月", "payback ≈ 1.2 months")} tone="a" />
      <FT x={532} y={192} cls="tn">{L("三年累计 ≈ ¥1.68M(参数可调)", "three-year total ≈ ¥1.68M")}</FT>
      <FT x={340} y={216} cls="tn">{L("最大的一笔收益是看不见的那笔:没人接的电话", "the largest benefit is the invisible one: the calls nobody answered")}</FT>
    </FigFrame>
  );
};

/* ---------------- v29 · the call trace ---------------- */
FIGN["v29-trace"] = function ({ idx }) {
  const L = useL();
  const hops = [
    { l: L("接起 + 录音告知", "answer + notice"), ms: 15000, who: "s" },
    { l: L("说出意图(3 槽位)", "intent, three slots"), ms: 7000, who: "u" },
    { l: L("回问时间选项", "offer two times"), ms: 14000, who: "s" },
    { l: L("选 16:30 + 技师", "16:30 + therapist"), ms: 6000, who: "u" },
    { l: L("查空档", "check availability"), ms: 5000, who: "s" },
    { l: L("技师名听错(缺热词)", "name misheard (no hotword)"), ms: 8000, who: "s", bad: true },
    { l: L("复述确认 → 救回来", "read-back → rescued"), ms: 18000, who: "s", good: true },
    { l: L("问团购券叠加", "voucher stacking?"), ms: 9000, who: "u" },
    { l: L("知识缺失 → 兜底", "no knowledge → fallback"), ms: 16000, who: "s", good: true },
    { l: L("占位 + 落单", "hold + commit"), ms: 15000, who: "s" },
    { l: L("复述 + 短信 + 收尾", "read back + SMS"), ms: 24000, who: "s" },
  ];
  const total = hops.reduce((s, h) => s + h.ms, 0);
  let acc = 0;
  return (
    <FigFrame idx={idx} h={226} cap={L("一通 2 分 17 秒、11 个轮次的真实通话。它成功了,但成功得很惊险:第 6 跳把技师的姓听错了(热词表里没有这个名字),靠第 7 跳的复述确认救回来;第 9 跳顾客问团购券能不能和会员卡叠加,知识库里没有这一条,是「查不到就说不知道」的兜底挡住了一次幻觉。两个救命机制都不是模型能力,是你设计出来的流程——去掉任意一个,这通电话的结局就是转人工,两个都去掉就是顾客挂断。", "One real call: 2 minutes 17 seconds across eleven turns. It succeeded, narrowly. At hop 6 the therapist's surname was misrecognised because the name was never added to the hotword list, and hop 7's read-back rescued it. At hop 9 the caller asked whether a voucher stacks with a membership card, the knowledge base had no entry, and only the say-so-when-you-cannot-find-it fallback prevented an invention. Neither rescue is a model capability; both are process you designed. Remove either and the call ends in a handoff; remove both and the customer hangs up.")}>
      {hops.map((h, i) => {
        const x = 186 + (acc / total) * 450;
        const w = (h.ms / total) * 450;
        acc += h.ms;
        const c = h.bad ? FTONE.bad : h.good ? FTONE.ok : h.who === "u" ? FTONE.a : FTONE.p;
        return (
          <g key={i}>
            <rect x={x} y={18 + i * 17} width={Math.max(3, w)} height={11} rx="3" fill={`color-mix(in srgb, ${c} 82%, transparent)`} stroke={c} />
            <text x={178} y={28 + i * 17} textAnchor="end" style={{ font: "500 9px var(--f-body)", fill: h.bad ? "#c0453f" : "var(--ink)" }}>{h.l}</text>
            <text x={x + Math.max(3, w) + 4} y={28 + i * 17} style={{ font: "500 8px var(--f-mono)", fill: "var(--muted)" }}>{`${(h.ms / 1000).toFixed(1)}s`}</text>
          </g>
        );
      })}
      <line x1={186} y1={12} x2={636} y2={12} stroke="var(--hairline-strong)" />
      <FT x={366} y={222} cls="tn">{L("蓝=机器 · 橙=顾客 · 红=出错的那一跳 · 绿=救回来的那一跳", "blue = agent · orange = caller · red = the hop that failed · green = the hop that saved it")}</FT>
      <FT x={366} y={208} cls="tm">{L("总时长 2 分 17 秒 · 11 轮 · 结局:预约成功", "2m17s · 11 turns · outcome: booking confirmed")}</FT>
    </FigFrame>
  );
};

/* =========================================================
   <Figure> — resolves a name to a registered figure component
   ========================================================= */
function Figure({ name, idx }) {
  const F = FIGN[name];
  if (!F) return null;
  return <F idx={idx} />;
}

window.FIGN = FIGN;
window.Figure = Figure;
