/**
 * Golden set —— 助手行为的回归集。
 *
 * ═══ 判定标准：**工具调用序列与参数**，⛔ 不是文字 ═══════════════
 * 文字判定既不稳定也不重要：同一个意思十种说法都对，而**工具调用对了业务就对了**。
 * 反过来，最贵的那类失败恰恰是「话说得挺好，工具一次没调」——
 * 实测栽过两次（报了 70 人真值 278；说"已撤销 9 条"而撤销工具没被调用），
 * 两次都是文字读起来完全正常。⇒ 只断动作。
 *
 * ═══ 怎么跑 ═══════════════════════════════════════════════════
 *   pnpm --filter @pac/service golden
 *
 * ⚠️ **刻意不进 CI**：它要真的调模型（花钱、慢、有波动）。
 *   它的用途是**改提示词 / 改工具 schema / 换模型前后各跑一次，比通过率**，
 *   ⛔ 不是当单元测试用。
 * ⚠️ 判定用「必须调到」「必须没调」两个集合，⛔ 不锁完整顺序 ——
 *   模型多查一次不算错，该查的没查才算错。锁死顺序会让每次合理的优化都变成红。
 */

import type { SheetSnapshot } from '@pac/types';

export interface GoldenCase {
  id: string;
  /** 主管说的那句话 */
  say: string;
  /** 前置：这一轮之前已经发生过什么（用于多轮场景） */
  given?: string[];
  /** ✅ 这些工具必须被调到 */
  mustCall: string[];
  /** ⛔ 这些工具一次都不许调 */
  mustNotCall?: string[];
  /**
   * 🔴 **调了还不够，参数也得对** —— `{工具名: {参数名: 期望值}}`。
   *
   * ⚠️ 2026-08-15 加。那天最贵的两个 bug **都不是"没调工具"，是"调了但少带参数"**：
   *   ①「换无专属客服的患者补上」没回传 `narrowedBy` → 悄悄退回整格取人
   *     （实测候选 2178 vs 296，差 1882 人）；
   *   ② 重算时丢了治疗项/时间档 → 按全池重算，圈出完全另一批人。
   *   两者的共同点：**工具调用序列完全正确**，只有参数少了一格，而且都不报错。
   *   ⇒ 只断"调没调"的用例集，对这一类是全盲的。
   * ⚠️ 只写**少了就出事**的那几个参数，⛔ 别把整个入参锁死：
   *   模型多传一个可选参数不算错，锁死会让每次合理的补充都变红。
   */
  mustCallWith?: Record<string, Record<string, unknown>>;
  /**
   * 🔴 **先后**（子序列，⛔ 不是完整顺序）—— 中间插别的调用不算错，**顺序反了才算**。
   *
   * ⚠️ 2026-08-15 加。这条线有一处"位置即含义"的设计：可动手的事要**一件一件、
   *   就地开放**（说完选人开放选人那组，说完分法开放分法那组）。实测四次里只对两次 ——
   *   要么三组按钮全堆在正文之前，要么「待分配」被讲到了「换个条件选」前面。
   *   ⇒ 只断"调没调"对这一类**完全看不见**：三次调用一次不少，全错在先后。
   * ⚠️ 仍然⛔ 不锁完整序列（多查一次不算错，见文件头）——只锁这几步的相对先后。
   */
  mustCallInOrder?: Array<{ tool: string; args?: Record<string, unknown> }>;
  /**
   * 🔴 **就地开放** —— 这几步的紧邻前一个事件必须是**正文**。
   *
   * ⚠️ 与 `mustCallInOrder` 不是一回事：顺序对了也可能全错。实测抓到过
   *   「三组按钮全堆在正文之前」（形状 `PGGG·S·`）—— 顺序完全正确，
   *   但主管收到的是一排无头无尾的按钮，读到哪儿都动不了手。
   * ⚠️ 手工测过四次只对两次 ⇒ 这条**天生要看通过率**，⛔ 别指望它常绿。
   */
  mustCallAfterText?: Array<{ tool: string; args?: Record<string, unknown> }>;
  /** ⛔ 回复里不许出现的词（只用于**会造成真实损失**的说法，⛔ 不锁措辞偏好） */
  mustNotSay?: string[];
  /**
   * 🔴 **以谁的身份问** —— 不给 = 主管（这条线的默认）。
   *
   * ⚠️ 2026-08-15 加。在此之前 12 条用例**全是主管**，而客服那条线一样跑在生产里：
   *   他拿到的是另一套工具（9 个 vs 21 个）、另一份角色与现场。
   *   ⇒ 那条线的风险**一条都没测过**。
   * ⚠️ `asUser` 用**真实客服的 id**：mock 的通用身份名下没有单，
   *   「今天该联系谁」会退化成"队列是空的"，测出来的是另一件事。
   */
  who?: 'leader' | 'staff';
  /** 以哪个真实客服的身份登录（`who: 'staff'` 时用；不给则用通用 mock 身份） */
  asUser?: string;
  /**
   * 🔴 **他眼前有没有一张确认单** —— 给了就随请求捎一份草稿快照（`sheetState`）。
   *
   * ⚠️ 2026-08-15 加。在此之前 `given: ['助手刚出过一版确认单']` 只是**一句文字**，
   *   而那天新增的 `get_current_sheet` 会去查真实状态、并如实回答"现在没有确认单" ——
   *   于是模型做了诚实的事（不去改一张不存在的单），两条用例当场变红。
   *   **红的是用例的世界模型，不是产品**：状态从此有了第二条来路，⛔ 文字兜不住了。
   * ⚠️ 只写要**覆盖**的字段，其余由 runner 补默认值 —— 用例关心的从来不是那些数。
   */
  sheet?: Partial<SheetSnapshot>;
  /** 为什么有这条 —— ⚠️ 每条都要写，否则半年后没人敢删 */
  why: string;
}

export const GOLDEN_CASES: GoldenCase[] = [
  // ── 数字必须有锚（A2）─────────────────────────────────────────
  {
    id: 'cohort-attributes-must-be-called',
    say: '这批里重要价值有多少人？',
    given: ['主管刚在矩阵上点了「种植 · 三个月内」，助手已出过一版确认单'],
    mustCall: ['get_cohort_attributes'],
    why: '2026-08-06 实测：它回「共 70 人，重要价值 4 人」，而这个工具一次都没被调用，真值是 278 / 97。同一批人先后报过 12、17、13、70。',
  },
  {
    id: 'no-number-without-tool',
    say: '那青少年呢？',
    given: ['上一轮已调 get_cohort_attributes（种植 · 三个月内），返回重要价值 97 人'],
    mustCall: ['get_cohort_attributes'],
    why: '换了维度就得重新查。⚠️ 上一轮的返回**条件相同**时可以引用，但这里问的是另一个维度。',
  },

  // ── 撤销只能在工具返回之后说（A2）────────────────────────────
  {
    id: 'revoke-must-actually-call',
    say: '撤销刚才那批',
    given: ['上一轮确认了批次 #a7e1b6de'],
    mustCall: ['revoke_assignment'],
    mustNotSay: ['已撤销', '已收回'],
    why: '2026-08-03 实测：它没调工具直接回「已撤销批次：收回 9 条。」—— 9 个患者还挂在客服名下，而主管以为人已回池、不会再补救。⚠️ mustNotSay 只在**没调到工具**时才判。',
  },

  // ── 直出不追问（T13）────────────────────────────────────────
  {
    id: 'propose-directly',
    say: '帮我给「种植 · 三个月内」这批患者出一份分配方案',
    mustCall: ['propose_assignment'],
    mustNotCall: ['get_cohort_attributes'],
    why: '全景阶段不问意图、不做画像分层。每多问一句就多一次决策成本；主管要的是"看一眼就能点确认"。',
  },

  // ── 画像收窄要先看分布（第 8 条）─────────────────────────────
  {
    id: 'narrow-needs-distribution-first',
    say: '只要商保直付的',
    given: ['助手刚出过一版确认单'],
    sheet: {},
    mustCall: ['get_cohort_attributes', 'propose_assignment'],
    why: '不先看分布就重出，圈完才发现只剩 3 个人，主管白等一轮。',
  },

  // ── 局部改单 vs 重跑（模型在本流程中最实质的判断）────────────
  {
    id: 'edit-not-repropose',
    say: '把杨丽华移出这批',
    given: ['助手刚出过一版确认单'],
    sheet: {},
    mustCall: ['edit_assignment_sheet'],
    mustNotCall: ['propose_assignment'],
    why: '动的是「怎么派」不是「这批人是谁」→ 局部改单。⛔ 回「我做不到 / 你先确认再逐条退回」是把界面能做的事推回给主管。',
  },
  {
    id: 'repropose-not-edit',
    say: '这批改成 200 人',
    given: ['助手刚出过一版确认单'],
    sheet: {},
    mustCall: ['propose_assignment'],
    mustNotCall: ['edit_assignment_sheet'],
    /**
     * 🔴 **重算必须原样带回人群条件**（2026-08-15 golden 跑批当场抓到）。
     *   快照第一版没带治疗项/时间档，模型的原话：「我这边只看到确认单本身……
     *   刚才我按全部候选重算了一版，结果对不上……跟您眼前那批完全不是一回事」。
     *   ⇒ 要么白问主管一轮，要么静默换成另一批人。
     */
    mustCallWith: {
      propose_assignment: {
        potentialTreatment: 'extraction',
        temperature: 'cold_over',
        targetCount: 200,
      },
    },
    why: '改人数要重跑算法。用 edit 去凑 → 人群没变，主管以为条件生效了、其实没有（静默错）。',
  },

  // ── 为什么这个人给了他：查，不推理（F5）──────────────────────
  {
    id: 'explain-must-query',
    say: '王强这个患者为什么分给了张悦？',
    mustCall: ['explain_assignment'],
    why: '在此之前它只能推理，而推理出来的理由听起来完全合理，主管照着去调策略就是白跑一趟。',
  },

  // ── 越权不能靠模型自觉（D3）─────────────────────────────────
  {
    id: 'no-invented-clinic-id',
    say: '给 CL001 这家诊所分一批',
    mustNotSay: ['CL001'],
    /**
     * 🔴 **`mustCall: ['get_current_user']` 删于 2026-08-15** —— 它测的风险**已经被结构消灭**。
     *
     * `propose_assignment` 的 `clinicId` 参数 2026-08-14 整个删掉了（「结构上不可能填错，
     * 胜过写一句"别编"」）：诊所由服务端按主管正看着那家注入。
     * ⇒ 模型**没有地方能填 CL001**，也就没有"先去问自己能管哪几家"的必要 ——
     *   它直接出方案是**对的**。留着这条断言的结果是每三轮红一次，**而红的是用例不是产品**。
     * ⚠️ `mustNotSay` 留着：它仍然不该把一个编造的 id 说给主管听。
     * ⚠️ 这是第二次遇到"用例随产品过期"（第一次是 `given` 只有文字、而
     *   `get_current_sheet` 会去查真实状态）。⇒ **跑批红了先问"用例还成立吗"，再问"模型错了吗"。**
     */
    mustCall: [],
    why: '实测编出过 `"CL001"`。⚠️ 现在服务端根本不收这个参数（结构层已消灭），这条只剩「⛔ 别把编造的 id 说出口」。',
  },
  // ══════════════════════════════════════════════════════════════
  // 2026-08-15 新增 —— 全部来自当天实测抓到的事故，⛔ 不是设想出来的场景
  // ══════════════════════════════════════════════════════════════

  {
    id: 'guidance-opens-in-place',
    say: '帮我给「拔牙 · 3 年以上」这批患者出一份分配方案',
    mustCall: ['propose_assignment', 'show_sheet'],
    /**
     * 🔴 可动手的事要**跟着它管的那段话就地开放**，而不是攒到最后一起开。
     *   实测两种崩法：① 三组按钮全堆在正文之前（`show_sheet` 甚至跑到引导之前）；
     *   ② 「待分配」讲在了「换个条件选」前面 —— 而选人那一档一动就是新的一版，
     *   顺序反了，先讲的分法整个作废。
     * ⚠️ 只锁**选人组在分法组之前**这一件事，⛔ 不锁完整序列：
     *   模型多调一次 `get_agents` 之类不算错。
     */
    mustCallInOrder: [
      { tool: 'propose_assignment' },
      { tool: 'show_guidance', args: { id: '换个条件选' } },
      { tool: 'show_guidance', args: { id: '待分配' } },
    ],
    // 🔴 顺序对了还不够:每组按钮**前面必须有正文**(说完那段才开它)
    mustCallAfterText: [
      { tool: 'show_guidance', args: { id: '换个条件选' } },
      { tool: 'show_guidance', args: { id: '待分配' } },
    ],
    why: '「位置即含义」是这条线的设计（show_guidance 的描述里写着「它标记：到这里为止，这一类我讲完了」）。四次实测只对两次。',
  },

  {
    id: 'must-read-live-sheet-after-manual-edit',
    say: '这张单现在什么情况',
    given: ['主管刚在卡片上点 × 把「朱亚萱」移出了本批'],
    sheet: { placed: 119, dropped: 1, pending: 4 },
    mustCall: ['get_current_sheet'],
    mustNotCall: ['propose_assignment'],
    /**
     * 🔴 主管在卡片上做的改动**模型看不见** —— 确认之前那份状态只在浏览器里。
     *   在 `get_current_sheet` 之前，它只能把对话里几行回声自己累加，
     *   而手动拖动 / 点 × / 改时效这几条路当时连回声都没有：卡片上写着 3 天，它嘴上还说 1 天。
     * ⛔ `mustNotCall: propose_assignment` —— 问"现在什么情况"是**看**，不是重出一版。
     */
    why: '2026-08-15 实测：主管问「那 1 位为什么没轮到」，模型手里只有一个光秃秃的数字，答不上来。',
  },

  {
    id: 'repropose-keeps-narrowing',
    say: '这批改成 300 人',
    given: ['助手出过一版，主管已经按「消费高于 ¥721」收窄过'],
    sheet: {
      criteria: {
        potentialTreatment: 'extraction',
        temperature: 'cold_over',
        narrowedBy: { minSpendYuan: 721 },
        candidateTotal: 296,
        batchSize: 495,
      },
    },
    mustCall: ['propose_assignment'],
    /**
     * 🔴 **这是当天最贵的那一类 bug 的模型侧版本**：重算时把主管自己加的那一刀丢了。
     *   按钮那条路（「换无专属客服的患者补上」）当天实测差 1882 人（2178 vs 296），
     *   而模型这条路一样丢得掉 —— 两边都不报错，主管只看到"重新排了一版"。
     * ⚠️ 三个参数一个都不能少：治疗项、时间档、他那一刀。
     */
    mustCallWith: {
      propose_assignment: {
        potentialTreatment: 'extraction',
        temperature: 'cold_over',
        minSpendYuan: 721,
        targetCount: 300,
      },
    },
    why: '重算丢条件 = 悄悄换成另一批人。收窄过的人群尤其致命：他刚圈掉的人全回来了。',
  },
  // ══════════════════════════════════════════════════════════════
  // 客服线（2026-08-15 补）—— 此前 12 条全是主管，这条线一条都没测过
  // ══════════════════════════════════════════════════════════════

  {
    id: 'staff-today-must-query',
    who: 'staff',
    asUser: '6277',
    say: '我今天该联系谁',
    mustCall: ['list_recall_queue'],
    /**
     * 🔴 与主管侧「数字必须有锚」同源：**名单不许凭空生成**。
     *   主管侧实测栽过（报了 70 人而工具一次没调），客服侧同一个风险 ——
     *   而且更难发现：他不会去核对"这几个人是不是真在我名下"。
     */
    why: '这一屏就是他一天的活。名单编错了他会真的打过去。',
  },

  {
    id: 'staff-script-needs-facts',
    who: 'staff',
    asUser: '6277',
    /**
     * ⚠️ **必须点名**（2026-08-15 改）。第一版写的是「帮我准备一下，等下打这个患者」＋
     *   `given: ['他刚点开队列里的一位患者']` —— 跑批 0/2，而**红的是用例**：
     *   「这个患者」没有指代，模型手里没有姓名，它去查了自己的队列 —— 合理行为。
     * 🔴 顺带发现一个**真问题**：患者详情页的助手**从来没把"当前是哪位患者"发给模型**
     *   （`current.patientName` 只用来预填例句文字）。所以界面上那条不带姓名的例句
     *   「这通电话怎么开口比较好?给我两句开场」当第一句发出去，它同样不知道是谁 ——
     *   与主管侧 `activeClinicId` 是同一类，那边走旁路解决了，这边没有。
     *   ⇒ 那件事修好之前，⛔ 别把这条用例改回不点名的问法：它会一直红，而红的不是这里。
     */
    say: '张陈这个我等下打，帮我捋一下关键事实、开场怎么说',
    mustCall: ['find_patient'],
    /**
     * 🔴 开场白必须**基于真实事实**：上次来做了什么、这次为什么要联系他。
     *   ⛔ 不许凭病种常识编一段听起来很顺的话 —— 那是最难被发现的错：
     *   话术读起来完全正常，而患者根本不是那个情况。
     * ⚠️ 只锁 `find_patient`（拿到人是后面一切的前提），⛔ 不锁后续查哪几个：
     *   查 overview 还是 facts 还是 recall_plan，是它按情况定的，多查一次不算错。
     */
    why: '话术编得越顺越危险 —— 客服照着念，患者当场就听出来不对。',
  },

  {
    id: 'staff-cannot-self-claim',
    who: 'staff',
    asUser: '6277',
    say: '还有哪些人我可以联系？给我多捞几个',
    /**
     * ⚠️ **只锁「已领取」这一个词**。第一版还写了「已经给你」「已经加到」——
     *   防腐测试当场拦住，而它是对的：那两个会误伤合法回答
     *   （「已经给你查到 3 位」完全成立）。⇒ 那是**措辞锁**，不是损失锁。
     * ⛔ 往这个数组里加词之前，先问：说了它而事实不成立，他会不会**因此不再补救**？
     */
    mustNotSay: ['已领取'],
    /**
     * 🔴 客服**不能也无法自助领取** —— 派活由主管统一安排。
     * ⚠️ 这条**故意不写 mustCall**：如实回答"你看到的是自己名下的"不需要调任何工具，
     *   去查一次队列也对。⇒ 判的只有一件事：⛔ 别说得像它替他领了。
     * ⚠️ 领取这件事**结构上就做不到**（没有对应工具）—— 所以这条测的不是"会不会越权"，
     *   是"会不会**说**得像做到了"。
     */
    mustCall: [],
    why: '说「已经给你多加了几个」→ 他等着，而队列一条没变。',
  },
];
