import { Body, Controller, Logger, Post, Req, Res } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { createOpenAICompatible, type OpenAICompatibleProvider } from '@ai-sdk/openai-compatible';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import type { AppConfig } from '../../config/configuration';
import {
  DAILY_CALLS_PER_AGENT,
  TEMPERATURE_TOOL_DESC,
  TEMPERATURE_TOOL_VALUES,
  type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { modelFacts } from '../plan/assignment-facts';
import { GUIDANCE_KIND, GUIDANCE_KINDS } from '../plan/assignment-signals';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';

/**
 * 🔬 assistant-lab —— **裸的 agent 试验台**（2026-08-14）。
 *
 * 目的：看模型的**原生 agent 能力**能到什么程度 —— 边写正文边调工具、把按钮和卡片
 * 摆在它写到的位置。⛔ 这里**没有任何补偿**：不重排、不补标题、不兜底、不删历史。
 * 模型漏调工具，界面上就真的什么都没有 —— 那正是要观察的东西。
 *
 * ═══ 与产品助手（AssistantController）的关系：**没有关系** ═════════════
 * ⛔ 不复用 `AssistantService`、⛔ 不复用 `buildSystemExtra`、⛔ 不复用前端那套块模型。
 *   提示词直接贴在本文件里（下面 `SYSTEM`），改一版跑一版，⛔ 别去 import 产品那份 ——
 *   两边各自演进才是这个试验台的意义。
 * ⚠️ 只复用**基础设施**：provider（模型路由）、MCP 客户端（工具清单）、
 *   `AssignmentProposalService`（真取数）、`modelFacts`（同一份事实投影）。
 *   它们不是"助手的逻辑"，是这条业务线本身。
 *
 * ═══ 历史怎么传：**服务端原样回吐** ═══════════════════════════════════
 * 每轮结束把 `result.response.messages`（AI SDK 生成的 assistant / tool 消息，
 * 含真正的 tool-call 与 tool-result）整包发给前端，前端**原样存、原样回传**。
 * ⇒ 前端一行历史重建逻辑都没有，也就没有"拍平成文本 → 模型照抄格式"那类坑
 *   （产品那边为此栽过两次：`〔调用 …〕` 被抄进正文、位置空壳被当成输出格式）。
 *
 * ═══ 执行过程（debug）════════════════════════════════════════════════
 * `step` 事件是**纯 debug 通道**：只给界面画「执行过程」用，
 * ⛔ 不进 `messages`、⛔ 不进任何回传给模型的东西。
 */

/** 单轮最多几步工具循环 —— 到顶会以 `step_limit` 事件报出来，⛔ 不静默截断。 */
const MAX_STEPS = 16;

// 🔴 引导标识与取值域已升进 `assignment-signals.ts`（产品共用同一份）——
//   裸台曾经自己维护过一份，搬回产品后那就是第二份副本，⛔ 已删。

/**
 * 系统提示词 —— 🔬 **直接贴在这里，改完重启就生效**。
 *
 * ⚠️ 起点是 2026-08-14 产品助手那一份的快照（base + 主管层）。
 *   ⛔ 别改成 import：试验台就是要能单独改一句话跑一轮，不牵动线上那份。
 *
 * ═══ 🔴 三层职责边界 —— 这条是为了**在将来的变化中保持不变** ═══════════
 *
 *   系统提示词    你是谁、跟谁说话、你们的关系；以及贯穿**所有**输出的规范
 *                （说人话、数字有出处、只读边界、怎么把结构化事实讲成话、写作风格）。
 *                ⛔ **一个工具名都不出现**，⛔ 不写"什么时候调哪个工具"。
 *
 *   工具描述      这个工具干什么、什么时候调、参数从哪来、调了会怎样。
 *                ⭐ "会怎样"必须写 —— 没有后果的时机要求就是一条没理由的仪式，
 *                  而没理由的格式规则遵守率最低（qwen 批量调工具正是不理解时机为何重要）。
 *
 *   工具返回值    只回**事实**。⛔ 不含指令，⛔ 不含模型用不上的词汇。
 *
 * ⚠️ 判据（加一句话之前先问）：**换一批工具、改一版界面之后，这句话还成立吗？**
 *   成立 → 提示词；不成立 → 它属于某个工具，去那个工具的描述里写。
 *
 * 🔴 **删一句话之前，先问它承担的是不是「调几次」或「什么时候调」。**
 *   2026-08-14 实测，思维链把这条写得明明白白：我按"通用/简洁"重写工具描述时，
 *   把「有几条就该调几次」换成了「⛔ 别攒着最后一次性全调」——只说了别批量、没说每条都要，
 *   再配上开头那句「某**一件**事」，模型的思考里当场推出：
 *     「注意：show_guidance **一次只开放一件**，就是我刚讲完的那件（待分配）。」
 *   于是三条引导只讲了一条、也只调了一次。
 *   同一轮还有第二处：⛔「没有返回内容可用」本是为降低泄漏风险加的，它反手用成了推迟的理由——
 *     「不过 show_sheet 和 show_guidance 没有返回内容可用。**我在讲完话之后再调**。」
 *   ⇒ 措辞的**每一处删减都可能带走一条正在起作用的约束**，而模型会把剩下的话往
 *     对自己最省事的方向读。⛔ 别只做减法不看它接手给谁。
 *
 * 🔴 **但"接手"要交给结构，别交给另一句话。**（同日产品复盘）
 *   我第一版的补法是加一句「「要主管定的」里有几条，你就要调几次」—— 那是**跨工具耦合**：
 *   `show_guidance` 凭什么知道 `propose_assignment` 返回值里有个键叫「要主管定的」？
 *   那个键一改，这句话就是错的。
 *   ⇒ 正解是把**取值域连同每一类的含义**写进参数（`enum` + 逐条解释）：
 *     四类摆在那儿，模型自然知道"这不是只有一件事"，⛔ 一句"调几次"都不用写。
 *   ⚠️ 判据：这句话要是提到了**别的工具的字段名**，它就放错地方了。
 *
 * 🔴 **⛔ 别把"位置"告诉模型。** 它看不见页面，位置对它没有对应物 ——
 *   给了词汇它就会用，而用了必然编：产品那版实测说过「确认单已经摆在**上方**」，
 *   而卡片在那句话的**下面**。⇒ 位置是**界面**从调用时机推出来的，
 *   模型只需要知道"讲完这一条就调一次"和"调了会发生什么"。
 */
const SYSTEM = `你是 PAC（疗效保障 / 患者分析中心）工作台里的助手。
你和使用者看着同一块屏幕：界面自己也在向他显示东西，你说的和他看到的合起来才是完整的一次回答。
但你**看不到**它显示成什么样 —— ⛔ 别描述界面上的位置或长相（"上面""下面""这里"）。
他问什么你答什么 —— 患者的事查工具，别的事（闲聊、常识、写作、计算）凭本事答。

你接入了 PAC 的一组患者工具，按需调用即可。

说人话：使用者的词汇表就是他在界面上见过的那些。取值码（\`cold_3y\`）、字段名（\`personaTags\`）、
工具名（\`get_cohort_attributes\`），以及只在代码里存在的概念（温度、口径、水位、基数、探索配额、排序键），
都不在里面 —— 他看到不认识的词，第一反应是系统坏了。工具返回值里已经给了中文，照着说。

你说的每个数都要指得到出处，出处只有一个：**本轮真的调到的工具返回**。指不到就去调，
或者如实说「我先查一下」。上文的工具记录旁边写着当时的参数 —— 参数变了，那些数就作废了。
⛔ 不估、不「大概」、不拿两个数相减凑一个出来。工具返回值有固定形状，你能背出那个形状 ——
但背出来的是假的。工具没返回的就如实说没有该信息，手机号只显示掩码。

除了你自己调的写工具，你做的一切都是只读。界面上发生的改动是使用者的手做的，⛔ 别说成你做的。

写作风格（管你所有的输出）：
- 用中文，简洁专业、友好。短句，主动语态，⛔ 不用「本系统」「该批次」这类公文腔。
- 用 markdown 组织：讲三件以上的事就用 \`###\` 起小标题分块，并列的事实用 \`-\` 列成条。
- 加粗留给「需要对方动手、或不看会出事」的那一句 —— 满屏加粗等于没有加粗。
- 中文标点一律全角（，；：（）），例外只有小数点（3.5 天）和代码原文。

## 你是门诊经理（主管）的助手

他坐在一张矩阵前：一行一个治疗项目，一列一档时间。他很忙，一眼要看出这批人是谁、有没有卡住的、该不该点确认。

**他做决定，你把决定落成动作** —— 这条关系管住下面所有事。

界面上那根时间轴没有名字，就是一档一档的时间：说「往后放一档」，不说「放宽温度」。

### 怎么把工具给的事实讲出来

工具返回值是**结构化事实，键序就是段落顺序** —— 照它讲，措辞归你。

返回值里每一项是一段。段里成组的条目逐条讲清「是什么」和「不管会怎样」，有几条讲几条。返回值里没有的不要补：上一轮有、这一轮没有，就是这一轮不该有。

界面上已经显示的不用你再说一遍（⛔ 也不要把可选动作列一遍，他在界面上能直接点）。你写的是界面说不出来的那半句：为什么是这些人、他现在该做什么。

### 决定他做，执行你做

这条线是：他在矩阵上选人群 → 你出确认单（直出，不追问、不做画像分层）→ 他在卡片上确认，**这一下才真的分下去** → 之后可跟踪、可撤销。他主动提要求时才收窄。

你手上唯一动得了已经分下去那批的是撤销工具，「已撤销」只能出现在它真的返回之后。确认单是「已呈现」，不是「已经分配好了」——点确认的是他的手。

你**不许自作主张**替他做决定，尤其不把患者从他的专属客服手里挪给别人：那是关系层面的决定。但他开口了你就要能一次干完 —— 把几十次拖拽推回给他不是保护，是甩锅。

本系统**不统计成功与否**，「成功率」「转化率」这类数不存在。

### 拟分怎么落人

两趟 + 一组：有专属且在岗的回自己人手上（最多到他这轮该拿的那份）；无专属或专属已离岗的给当前手上最少的那个；专属客服这轮已排满的不动，单列成一组交他定。

因此这批可能不满、团队也不齐平 —— 那是刻意的：宁可少分几个，也不动别人的客户。他问「怎么没分够」就说有几个人卡在待分配等他定，不是系统故障。

不是每人加一样多，是每条都给当前手上最少的那个。没有「容量上限」这回事，负载就是在手量本身。

人数和时限不问他：不传就按「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时限」估，并与符合条件的总人数取小。他说了数才传。池子里还有人就随时能再分一批。`;

// 🔴 `relabel`（编号→标识）与 `regroup`（要主管定的→怎么派的）已删（2026-08-14）：
//   两者都搬进了产品的 `modelFacts`，裸台再做一遍就是**空转** —— 而空转的代码
//   最贵的地方是它看起来还在服役。

interface LabBody {
  /** 上一轮服务端原样吐回去的 messages + 这一轮的 user 消息。⛔ 前端不做任何重建。 */
  messages: ModelMessage[];
  model?: string;
  /**
   * 🔬 qwen 的思考开关（对 deepseek / gemini 无效，它们本来就开着）。
   *
   * 🔴 2026-08-14 查出来的：共用的 `AiProviderService` 里有个 fetch 中间件，
   *   **无条件**给每一个 qwen 请求塞 `enable_thinking = false` —— 连 agent 这条带工具的链路也塞。
   *   理由写在那边（2026-06 实测：流式下 thinking 会污染 content），但那次跑的是
   *   **结构化输出**那条路（generateObject + json mode、无工具）。
   *   ⚠️ 证据就在它旁边三行：`response_format` 的注入判了 `hasTools`，`enable_thinking` **没判**。
   * ⇒ 这几天拿 qwen 跟 DeepSeek 比"要不要边写边调工具"，其实是
   *   **「便宜档 + 会思考」对「最高档 + 不许思考」**，比较本身不成立。
   */
  thinking?: boolean;
  /** 主管当前看的诊所 —— `propose_assignment` 不传 clinicId 时兜底。 */
  activeClinicId?: string;
}

@ApiTags('assistant')
@ApiBearerAuth('accessToken')
@Controller('assistant/lab')
export class AssistantLabController {
  private readonly logger = new Logger(AssistantLabController.name);

  /**
   * 🔬 试验台专用的 qwen 实例 —— **思考开着**。
   *
   * ⛔ 不改共用的 `AiProviderService`：那一份产品也在用，改了两边一起变，
   *   而这一轮只是要验"开了思考 qwen 会不会好起来"。
   * ⚠️ 只塞 `enable_thinking`，⛔ 不碰 `response_format` —— agent 这条路一定带 tools，
   *   而 DashScope 的 json mode 与工具调用互斥（共用那份注释里写着）。
   * ⚠️ `@ai-sdk/openai-compatible@2.0.48` 本身就解析 `reasoning_content`，⛔ 不用升级 SDK。
   */
  private readonly qwenThinking: OpenAICompatibleProvider;
  private readonly qwenDefaultModel: string;

  constructor(
    private readonly provider: AiProviderService,
    private readonly mcp: McpClientService,
    private readonly proposals: AssignmentProposalService,
    config: ConfigService<AppConfig, true>,
  ) {
    const ai = config.get('ai', { infer: true });
    this.qwenDefaultModel = ai.qwenDefaultModel;
    this.qwenThinking = createOpenAICompatible({
      name: 'qwen-thinking',
      apiKey: ai.qwenApiKey,
      baseURL: ai.qwenBaseUrl,
      supportsStructuredOutputs: false,
      // ⚠️ 用 `Parameters<typeof fetch>[0]` 而不是写死 `string | URL | Request` ——
      //   本文件从 express 引了同名的 `Request`，直接写会撞名（共用那份没引 express，没这问题）。
      fetch: (async (url: Parameters<typeof globalThis.fetch>[0], options?: RequestInit) => {
        if (options?.body && typeof options.body === 'string') {
          try {
            const b = JSON.parse(options.body) as Record<string, unknown>;
            b.enable_thinking = true;
            options = { ...options, body: JSON.stringify(b) };
          } catch {
            /* 非 JSON body 不动 */
          }
        }
        return fetch(url, options);
      }) as typeof fetch,
    });
  }

  /** 模型路由：qwen + 开思考 → 走试验台自己那份；其余一律交给共用的 provider。 */
  private resolveModel(
    modelId: string,
    thinking: boolean,
  ): { model: Parameters<typeof streamText>[0]['model']; modelId: string; thinking: boolean } {
    if (thinking && modelId.startsWith('qwen')) {
      const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId;
      return { model: this.qwenThinking(canonical), modelId: canonical, thinking: true };
    }
    const r = this.provider.resolve(modelId);
    // ⚠️ deepseek / gemini 本来就带思考；只有 qwen 会被共用那份中间件关掉
    return { model: r.model, modelId: r.modelId, thinking: !r.modelId.startsWith('qwen') };
  }

  @Post('chat')
  @ApiOperation({ summary: '🔬 裸 agent 试验台（SSE）—— ⛔ 无任何补偿，只看原生能力' })
  async chat(
    @Req() req: Request,
    @Res() res: Response,
    @Body() body: LabBody,
    @CurrentUser() user: AuthenticatedUser,
    @TenantScope() scope: TenantScopeContext,
  ): Promise<void> {
    const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '');
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache, no-transform');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders?.();
    const send = (e: Record<string, unknown>): void => {
      res.write(`data: ${JSON.stringify(e)}\n\n`);
    };
    const ac = new AbortController();
    req.on('close', () => ac.abort());

    try {
      const tools = await this.buildTools(token, user, scope, body.activeClinicId, send);
      const { model, modelId, thinking } = this.resolveModel(
        body.model ?? 'deepseek',
        body.thinking !== false,
      );
      this.logger.log(
        `lab chat: model=${modelId} thinking=${thinking ? 'on' : 'off'} tools=${Object.keys(tools).join(',')}`,
      );

      const result = streamText({
        model,
        system: SYSTEM,
        messages: body.messages ?? [],
        tools,
        stopWhen: stepCountIs(MAX_STEPS),
        abortSignal: ac.signal,
      });

      for await (const part of result.fullStream) {
        const p = part as Record<string, unknown> & { type: string };
        switch (p.type) {
          case 'text-delta':
          case 'text':
            send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
            break;
          /**
           * 🔬 思考也照原样转发 —— 它跟正文走**同一条流**，按到达顺序穿插。
           *
           * ⭐ 这一条是这个试验台的主要观察点之一：**模型是在哪一刻想的**
           *   （写正文之前？调工具之前？拿到工具结果之后？）。
           * ⚠️ 只是**显示**分开，⛔ 它本身仍然是模型输出的一部分，
           *   最后随 `messages` 原样回传（DeepSeek 文档明确要求思考内容参与后续上下文拼接）。
           */
          case 'reasoning-delta':
            send({ type: 'reasoning', text: (p.text as string) ?? '' });
            break;
          // ⚠️ 下面三个是**纯 debug 通道**，只画「执行过程」用，⛔ 不进 messages。
          case 'tool-call':
            send({ type: 'step', phase: 'call', id: p.toolCallId, tool: p.toolName, args: p.input });
            break;
          case 'tool-result':
            send({ type: 'step', phase: 'result', id: p.toolCallId, tool: p.toolName, result: p.output });
            break;
          case 'tool-error':
            send({
              type: 'step',
              phase: 'error',
              id: p.toolCallId,
              tool: p.toolName,
              error: p.error instanceof Error ? p.error.message : String(p.error),
            });
            break;
          case 'finish':
            // 🔴 到顶要报出来 —— 静默截断会让模型停在「我先查一下」而使用者以为查完了
            if (p.finishReason === 'tool-calls') send({ type: 'step_limit', limit: MAX_STEPS });
            break;
          case 'error':
            send({ type: 'error', error: String(p.error) });
            break;
          default:
            break;
        }
      }

      /**
       * ⭐ 本轮生成的 assistant / tool 消息**原样吐回前端**，下一轮原样带回来。
       *   这是这个试验台"上下文不加工"的落点：前端一行重建逻辑都没有。
       */
      const resp = await result.response;
      send({ type: 'messages', messages: resp.messages });
      send({ type: 'done' });
    } catch (err) {
      send({ type: 'error', error: err instanceof Error ? err.message : String(err) });
    } finally {
      res.end();
    }
  }

  /**
   * 工具清单 = MCP 那一整套 + 三个本地工具（与产品助手同一批，行为一致）。
   *
   * ⚠️ `place_sheet` / `place_guidance` 是**纯位置标记**：不查库不写库，
   *   唯一作用是让界面知道"卡片/按钮该出现在正文的哪一处"。
   */
  private async buildTools(
    token: string,
    user: AuthenticatedUser,
    scope: TenantScopeContext,
    activeClinicId: string | undefined,
    send: (e: Record<string, unknown>) => void,
  ): Promise<ToolSet> {
    const mcpTools = await this.mcp.listTools(token, 'dispatch');
    const tools: ToolSet = {};
    for (const t of mcpTools) {
      /**
       * 🔬 裸台**不注册** `get_current_user`（2026-08-14）。
       *
       * 这条流程用不上它：身份由系统提示词说了（「你是门诊经理的助手」），
       *   能不能分配由**工具清单**说了（拿得到 propose_assignment 就是能），
       *   诊所由服务端定（clinicId 参数已删）。⇒ 调它是纯花一步。
       * ⚠️ 实测它每轮开头都被调一次，返回值除了"我是主管"没被用上过。
       * ⛔ 别顺手把别的工具也摘掉：其余那些是**没被用到**，不是**不该存在** ——
       *   模型有没有能力自己判断"用不上就不调"，正是这个试验台要观察的。
       */
      if (t.name === 'get_current_user') continue;
      tools[t.name] = tool({
        description: t.description ?? t.name,
        inputSchema: jsonSchema(t.inputSchema),
        execute: async (args: unknown) => this.mcp.callTool(token, t.name, args),
      });
    }
    void user;

    /** 本轮「要主管定的」有哪几条 —— 标题给界面画绿框，标识给工具校验 */
    let guidanceTitles: string[] = [];
    let guidanceKinds: string[] = [];
    /** 本轮算出过方案没有 —— 两个呈现器都靠它拦"还没有东西可呈现就先调了" */
    let hasProposal = false;

    tools.propose_assignment = tool({
      /**
       * 按同一套写法重写（2026-08-14）：是什么 / 什么时候用 / 什么时候别用 / 边界。
       *
       * 删掉的四句，各有各的去处 —— ⛔ 别抄回来：
       *   「并把卡片呈现给主管」        → 界面词，而且**是错的**：呈现时机由 show_sheet 决定
       *   「返回的是结构化事实…组织成人话」→ 系统提示词的「怎么把工具给的事实讲出来」已经管了
       *   「绝不要说已经分配好了」      → 系统提示词的权责段 + 返回值键名 `已经分下去了` 各一份，这是第三份
       *   「专属客服排满的不会自动改派」 → 返回值里 `专属客服排满、这批没发的` 自己就说清了（判据②）
       * ⚠️ 「不问人数时限、直出不追问」也删了 —— 系统提示词的流程线里写着，这里是复述。
       */
      description:
        '从召回池按条件选出一批人，算出一版分配方案：谁分给谁、每人几条、谁卡着没发。' +
        '\n什么时候调：他说出要哪一格的人（治疗项 + 时间档）就调。' +
        '他要换条件重来（只选其中一类、改人数、改时限）也是**重调这个工具出新的一版**，' +
        '⛔ 不是在上一版已经排好的人里再挑。' +
        '\n⚠️ 本批人数取**小的那个**：估出来的基数 vs 符合条件的总人数。' +
        '⛔ 所以这一格人不够时，把人数往上调没有任何作用 —— 卡住的是候选，不是基数。' +
        '\n⛔ 它不写任何数据，只是算一版方案。' +
        /**
         * 🔴 **讲述顺序写在这里，⛔ 不在系统提示词里**（2026-08-14 产品定）。
         *
         * 理由是**耦合方向**：`propose_assignment` 是生产者，它知道自己产出什么、该被怎么读；
         *   `show_sheet` / `show_guidance` 是呈现器，⛔ 它们不该知道内容结构
         *   （上一轮就是因为 show_guidance 提到了「要主管定的」这个键才删的）。
         * ⚠️ 这里**不点名那两个工具**，用的是它们自己描述里的动词（"看明细""开放"）——
         *   语义对得上就够了，⛔ 别把工具名写进来，那又成了跨工具引用。
         * ⚠️ 段落顺序**由键序表达**（`modelFacts` 的键序），这里只写键序表达不了的三件事：
         *   开头给结论 / 明细什么时候看 / 可动手的东西就近开放。
         * 🔴 初稿还列了「② 这批人对不对 ③ 分完什么样」—— 那是**把键名又抄了一遍**；
         *   还写了「这是主管的读法」「他很忙」这种具体人物与心理描写，
         *   以及一句光禁不说后果的「⛔ 别自己换」。三条都违反本文件自己的写法约定，已删。
         *
         * 顺序本身是站在使用者的决策链上定的：他只做一个决定 —— 点不点确认。
         *   要不要我管 → 人对不对 → 分得合不合理 → 现在做什么
         */
        '\n开头先一句话说清：这一版是什么、有没有要他动手的 —— 多数时候看完这一句就够了，' +
        '后面那些是给他要动手时看的。' +
        '\n讲完人是怎么分下去的，让他看这一版的完整明细。' +
        '\n可动手的事，在**它所属的那一段讲完之后**开放：他读到哪儿就在哪儿能动手，' +
        '攒到最后他得把上下文重新想一遍。',
      inputSchema: jsonSchema({
        type: 'object',
        properties: {
          /**
           * 🔴 **`clinicId` 参数已删（2026-08-14）—— 结构上不可能填错，胜过写一句"别编"。**
           *
           * 上一版写的是「⛔ 不知道就别传，更不要自己编」+ 后果说明。但复盘发现：
           *   `get_current_user` 回的 `clinicIds` **只有 uuid、没有名字**，
           *   主管说「给建国门那家出一版」时，模型手里根本没有「名字 → id」的映射。
           * ⇒ 这个参数只有两种填法：照抄一个它不懂含义的 uuid，或者编一个。
           *   **没有一种是"用对"** —— 那它就不该出现在模型面前。
           * ⚠️ 诊所由服务端定：前端传的 activeClinicId（主管现在看的那家）→ 兜底取他名下第一家。
           * ⛔ 要恢复"按诊所选"这个能力，正解是给一个**带名字的 enum**（取值域 = 他真正有权限的那几家），
           *   ⛔ 不是把这个裸 id 参数放回来。
           */
          potentialTreatment: { type: 'string', description: POTENTIAL_TREATMENT_DESC },
          temperature: {
            type: 'string',
            enum: [...TEMPERATURE_TOOL_VALUES],
            description: TEMPERATURE_TOOL_DESC,
          },
          personaTags: { type: 'string', description: PERSONA_TAGS_DESC },
          minSpendYuan: {
            type: 'number',
            description:
              '只选累计净消费**高于**这个数的人，单位是**元**（严格大于，等于不算）。' +
              '\n⛔ 别自己拍这个数 —— 他没说数字就先去查这批人的分布，' +
              '凭空定一个门槛，他拿到的是一批说不清来历的人。',
          },
          /**
           * ⚠️ 这三个的关系是**有歧义**的，实现见 assignment-proposal.service：
           *   batchSize = targetCount ?? (在岗人数 × dailyCalls × expiresInDays)
           *   本批人数  = min(batchSize, 符合条件的总人数)
           * ⇒ 传了 targetCount，dailyCalls 就完全不起作用；expiresInDays 则**身兼两职**。
           *   2026-08-14 走查：原描述只写了各自是什么，没写它们互相怎么影响。
           */
          targetCount: {
            type: 'number',
            description:
              '本批发多少人。他明确说「这批 200 人」时才传。' +
              '\n⚠️ 传了它就**直接按这个数**，下面那个换算公式整个不参与（dailyCalls 传了也白传）。',
          },
          dailyCalls: {
            type: 'number',
            description:
              '「每人每天打几通」，1–60。**只用来估本批人数**（在岗人数 × 它 × 时限）。' +
              '\n⛔ 它不是容量上限，⛔ 也不参与"谁分给谁"。⚠️ 传了 targetCount 时它不起作用。',
          },
          expiresInDays: {
            type: 'number',
            description:
              '批次时限天数。他明确说了天数（「给 5 天」）时才传。' +
              '\n⚠️ 它**身兼两职**：① 这批要在几天内打完（过期不收走，只记为超期）；' +
              '② 没传 targetCount 时，它还参与估本批人数。改它两件事一起变。',
          },
        },
      }),
      execute: async (args: unknown) => {
        const a = (args ?? {}) as {
          potentialTreatment?: string;
          temperature?: TemperatureValue;
          personaTags?: string;
          minSpendYuan?: number;
          expiresInDays?: number;
          dailyCalls?: number;
          targetCount?: number;
        };
        // ⚠️ 诊所**不从模型来**（参数已删，见上）：主管现在看的那家 → 兜底他名下第一家
        const clinicId = activeClinicId ?? scope.clinicIds[0];
        if (!clinicId) return '当前登录人没有绑定诊所，出不了方案。';
        const sheet = await this.proposals.propose(scope, { ...a, clinicId });
        const actions = sheet.signals.filter((s) => s.tier === 'action');
        guidanceTitles = actions.map((s) => s.title);
        guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key);
        hasProposal = true;
        // 🔬 试验台只推一个**概要**给界面（要看的是"摆在哪"，不是卡片本身）
        send({
          type: 'sheet_ready',
          summary: {
            共: sheet.candidateTotal,
            已排好: sheet.placed,
            待分配: sheet.pending.length,
            时限天数: sheet.expiresInDays,
          },
        });
        const facts = modelFacts(sheet);
        return JSON.stringify({ 确认单已呈现: true, 已经分下去了: false, ...facts }, null, 2);
      },
    });

    /**
     * ═══ 两个"给他看"的工具 —— 命名与措辞的取舍 ═══════════════════════
     *
     * 🔴 原来叫 `place_sheet` / `place_guidance`。`place` 是**空间词**，而模型看不见页面 ——
     *   工具名是模型接触到的**第一层**词汇，比描述和返回值都靠前，给了它空间词汇，
     *   它就会向主管描述位置，而描述必然会错（产品实测：说「确认单已经摆在**上方**」，
     *   卡片却在那句话下面）。
     * ⇒ 改成 `show_*`：**`show` 描述的是模型真能意图的动作**（现在把这个给他看），
     *   `place` 描述的是它感知不到的结果（摆在某处）。时机是它的，位置是界面推出来的。
     * ⚠️ 保留「引导」这个词（`show_guidance`）—— 它确实是这类节点最贴切的叫法。
     *
     * ⚠️ 时机要求 + 它的后果**都写在这里**（⛔ 不在系统提示词里，见 SYSTEM 上方的边界）。
     * ⚠️ 返回值只回一个事实，⛔ 不含"位置""你刚写到的地方"这类模型用不上的词 ——
     *   返回值是泄漏风险最高的一层（`已插入选项按钮`、`<按钮位置 />` 两次都出在这）。
     */
    tools.show_sheet = tool({
      // ⚠️「什么时候调」不在这儿 —— 它归生产者（propose_assignment 的讲述顺序里那句
      //   「讲完人是怎么分下去的，让他看这一版的完整明细」）。呈现器只说自己是什么。
      description:
        '让他看到这一版的完整明细：谁分给了谁、每人几条 —— 他要据此决定确不确认。' +
        '\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「明细从这里开始看」。' +
        '\n⛔ 这一版还没算出来之前别调：那时没有明细，他会看到一块空的。' +
        '\n⛔ 它不改任何数据；没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调，' +
        '它的含义全在位置上。',
      inputSchema: jsonSchema({ type: 'object', properties: {} }),
      execute: async () => {
        // ⚠️ 跟 show_guidance 一样，能拦的用**结构**拦，⛔ 别只靠上面那句 ⛔。
        //   拦下来还要**说清现在该干什么** —— 这是模型自纠的唯一线索。
        if (!hasProposal) return '这一轮还没算出方案，没有明细可看。先算一版。';
        send({ type: 'place', what: 'sheet' });
        return JSON.stringify({ 已收到: true });
      },
    });

    tools.show_guidance = tool({
      description:
        '把他接下来可以动手的事开放给他 —— 他直接点就行，⛔ 不必再打字把需求描述一遍。' +
        '\n什么时候调：讲完其中一类，**紧接着**调一次、传它的 id；再讲下一类，再调一次。' +
        '\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「到这里为止，这一类我讲完了」。' +
        // ⚠️ 2026-08-14 实测：它把「换个条件选」和「本批人数」写成同一节的两条 bullet，
        //   然后连着调两次 —— 于是前一类的动作落到了后一类的说明后面。
        //   ⇒ 这句从「别攒到最后」改成「哪怕只攒两类也不行」，并把后果说具体。
        //   ⛔ 别写成「不许把两类写进同一段」：那是在管它怎么分段，越界了（③级格式规则最不被遵守）。
        '⛔ 所以别攒着一起调 —— 哪怕只攒了两类，前一类的动作也会落到后一类的说明后面。' +
        '\n⛔ 还没讲到的别提前调 —— 他看到一件没头没尾的事，只会回头来问。' +
        '\n⛔ 它不改任何数据；没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调，' +
        '它的含义全在位置上。',
      inputSchema: jsonSchema({
        type: 'object',
        properties: {
          id: {
            type: 'string',
            // ⚠️ 判据是固定的四类,所以 enum 是静态的 —— ⛔ 别做成"随每版提案变"，
            //   工具清单在 propose_assignment 跑之前就建好了，那时还不知道这一版有哪几类。
            enum: GUIDANCE_KINDS,
            /**
             * 🔴 **每一类的含义写在这里，⛔ 不去引用别的工具的返回值结构。**
             *   上一版写的是「取自 propose_assignment 返回值里「要主管定的」每一条的 `标识`」——
             *   那是**跨工具耦合**：那个键名一改这句就错了，而且工具之间不该知道对方的内部形状。
             * ⭐ 把四类摆出来，模型自然知道「这不是只有一件事」，⛔ 不需要再写一句"有几条调几次"。
             */
            description:
              '要开放哪一类：' +
              '待分配 = 有人卡着没发出去、等他定去向；' +
              '换个条件选 = 这一格可以只选其中一类人，重出一版；' +
              '本批人数 = 这一批发多少人，可以调；' +
              '最忙和超期 = 分完之后最忙那位手上的量，超过了「每天几通 × 时限」；可以调时限或每天几通。' +
              '\n⚠️ 只能开放**这一版真的有**的那几类；没有的会被退回，并告诉你这一版有哪几类。',
          },
        },
        required: ['id'],
      }),
      execute: async (args: unknown) => {
        const kind = String((args as { id?: unknown })?.id ?? '');
        const index = guidanceKinds.indexOf(kind) + 1;
        if (index === 0) {
          // ⚠️ 错误里**把这一版真有哪几条列出来** —— 这是模型自纠的唯一线索
          if (!hasProposal) return '这一轮还没算出方案，没有可开放的事。先算一版。';
          return guidanceKinds.length
            ? `这一版没有「${kind}」。只有：${guidanceKinds.join('、')}。`
            : '这一版没有可开放的事，一件都不用调。';
        }
        // ⚠️ title 只走**界面**这条侧信道（画绿框用），⛔ 不进返回值 ——
        //   把标题回给模型，它下一步就会把这句话再复述一遍（实测过）。
        send({ type: 'place', what: 'guidance', index, kind, title: guidanceTitles[index - 1] ?? '' });
        return JSON.stringify({ 已收到: kind });
      },
    });

    return tools;
  }
}
