import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import {
  Permission,
  AnchorMode,
  ANCHOR_MODE_TOOL_DESC,
  TEMPERATURE_TOOL_DESC,
  TEMPERATURE_TOOL_VALUES,
  type AnchorModeValue,
  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 { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';

/**
 * ⚠️ 这里刻意**不写死身份**：同一个助手同时服务门诊经理（主管）和客服，
 * 具体在跟谁说话由 `systemExtra`（buildSystemExtra，按权限切两套）和 `get_current_user` 决定。
 * 🔴 2026-08-12：原文写的是「为牙科诊所的客服人员提供帮助」，而主管也在用同一条链路 ——
 *   与后面拼上来的 DISPATCHER_EXTRA（「你现在在跟门诊经理说话」）当场矛盾。
 */
const SYSTEM_PROMPT = `你是一个通用智能助手，目前在为牙科诊所的工作人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题，都请正常、友好地回答。
重要：不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据；像一个真正的通用助手那样尽力帮忙即可。

你额外接入了 PAC(疗效保障 / 患者分析中心)的一组患者工具，在用户需要查患者时按需调用：
- 用户给患者姓名/手机/患者号时，先用 find_patient 拿到 patientId,再继续。
- 要患者全景就用 get_patient_overview(一次给画像+事实+召回计划);要细节再用 get_persona / get_facts / get_recall_plan。
- 问"现在该联系谁 / 召回池"用 list_recall_queue。

当用工具回答患者相关问题时（数据正确性，务必遵守）：只依据工具返回的真实数据，绝不编造；工具没返回的就如实说没有该信息；手机号只显示掩码。

展示方式（重要）：
- 简短问答、闲聊、解释 → 直接用文字(markdown)回答，不要产卡片。
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表（可含图表）——先用工具取真实数据，然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」：用 Tailwind 工具类排版，强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适；需要图表时用 Chart.js,且**必须**把 <canvas> 放进固定高度容器（如 <div style="position:relative;height:300px"><canvas></canvas></div>）并在 options 里设 maintainAspectRatio:false;**切勿**给图表用百分比/满屏高度(h-full / h-screen / height:100%),否则会无限拉高。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们；严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码，只用工具返回的真实值。
- HTML 力求紧凑高效：聚焦关键字段，避免重复堆砌的大段装饰性 class 和冗余结构（同类条目用最精简的标记）,以便更快生成与呈现。
- 产出卡片后，再用一两句话点出要点即可（数据细节在卡片里）。

用中文，简洁专业、友好。`;

/**
 * 单轮里最多跑几步工具循环。
 * ⚠️ 改这个值时记得：到顶不是"正常结束"，控制器要把它当异常路径报给前端（no silent cap）。
 */
export const MAX_TOOL_STEPS = 8;

/** 桌宠"小牙"的人设（pet-say 专用，无工具、极短输出）。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察，用第一人称说一句话：中文，不超过 30 个字，口语化、俏皮但不油腻，最多一个 emoji。
只评论观察里出现的事，不编造患者信息，不给医疗建议，不自称 AI 或模型；不假设用户的性别/称呼（不要叫哥哥姐姐，直接用"你"或不称呼）。
直接输出台词本身，不要引号、不要解释。`;

export interface AssistantChatInput {
  userToken: string;
  modelId?: string;
  messages: ModelMessage[];
  /** 追加到 system 的渠道特定指令（如企微：纯文字、链接格式、多轮指代）。 */
  systemExtra?: string;
  /**
   * 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
   * (真正的判定在 MCP server 的条件注册与各 handler 里)。
   * 不传 = 按无特权处理：宁可少给工具，也不能把主管的工具漏给客服。
   */
  permissions?: readonly string[];
  /**
   * 侧信道 —— 把**肥载荷**直接推给前端，不经过模型上下文。
   *
   * ⭐ 确认单是这条通道存在的理由：它含几百个 planId(一批 850 人 ≈ 12k token),
   * 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型
   * **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。
   * ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**,
   * 同样是它在编。真数据只能由服务端产、由服务端推。
   */
  onSideEvent?: (event: Record<string, unknown>) => void;
  /** 当前登录人（本地工具要用 scope 取数）;缺省则本地写工具不注册 */
  scope?: TenantScopeContext;
  abortSignal?: AbortSignal;
}

/**
 * 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
 *
 * ⚠️ 凡是**会改变工具清单**的权限，都必须列进来，否则两类人会共用一份缓存（串号且不报错）。
 * 现在只有 `plan:dispatch` 影响清单（主管多出分配/跟踪工具）;将来再有条件注册的工具,
 * 加权限的同时**必须**在这里加一位，并在 mcp-server.factory 的条件注册处对齐。
 */
export function mcpCapabilityKey(permissions: readonly string[] | undefined): string {
  return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic';
}

/**
 * AssistantService — 独立的"外部 agent"模拟器（不复用 AiCall 单发框架）。
 *
 * 模型自主决定调哪些工具（model-driven tool-calling,非强制工作流）：
 *   动态从 MCP 拉工具 → 包成 AI SDK tool(execute = 回调 MCP)→ streamText 多步循环
 *   (模型出 tool-call → 执行 → 喂回 → 直到产出最终文本)→ 流式返回。
 * provider 可切换(deepseek / gemini / qwen),复用 AiProviderService 配置。
 */
@Injectable()
export class AssistantService {
  private readonly logger = new Logger(AssistantService.name);

  constructor(
    private readonly provider: AiProviderService,
    private readonly mcp: McpClientService,
    private readonly proposals: AssignmentProposalService,
  ) {}

  // 显式收窄返回类型为控制器实际消费的最小形状，避开 streamText 返回类型引用 ai 内部
  // 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。
  async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> {
    // 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用，带用户 token)
    const mcpTools = await this.mcp.listTools(
      input.userToken,
      mcpCapabilityKey(input.permissions),
    );
    const tools: ToolSet = {};
    for (const t of mcpTools) {
      tools[t.name] = tool({
        description: t.description ?? t.name,
        inputSchema: jsonSchema(t.inputSchema),
        execute: async (args: unknown) => {
          // ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是
          //   「模型没调工具，却报了一串看起来很像真的数字」（2026-08-06 实测：
          //   它对主管说「这批共 70 人，重要价值 4 人、青少年 3 人」，而
          //   get_cohort_attributes **一次都没被调用**,真值是 278 / 97 / 40）。
          //   没有这行日志，排查时根本分不清「工具算错了」还是「压根没算」。
          // ⚠️ args 截断 200 字：确认单那类肥载荷不该灌进日志。
          const brief = JSON.stringify(args ?? {}).slice(0, 200);
          this.logger.log(`工具调用：${t.name} args=${brief}`);
          try {
            return await this.mcp.callTool(input.userToken, t.name, args);
          } catch (e) {
            // ⛔ 工具报错必须**看得见** —— 模型拿到错误往往会"绕过去"继续编,
            //   而那正是最难发现的失败形态。
            this.logger.warn(`工具失败：${t.name} ${e instanceof Error ? e.message : e}`);
            throw e;
          }
        },
      });
    }

    // ── 本地工具：全景确认单 ─────────────────────────────────
    // ⚠️ 刻意**不做成 MCP 工具**：MCP 的返回值会原样回灌进模型上下文,
    //   而这个载荷有几百个 planId。这里走侧信道推前端，只回给模型一句短话。
    if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
      const scope = input.scope;
      const push = input.onSideEvent;
      tools.propose_assignment = tool({
        description:
          '出「全景确认单」：从召回池选人，按「专属优先 → 无主补空」落到客服头上,' +
          '并把卡片直接呈现给主管。' +
          '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看**。' +
          '\n⭐ 返回的是**结构化事实**(怎么选的 / 怎么派的 / 要主管定的),' +
          '你的活是把它组织成人话 —— 怎么写见系统提示词的「第 0.5 条」。' +
          '\n⚠️ **这只是提案，一个字都没写库**。绝不要说「已经分配好了」。' +
          '\n⚠️ 人数与时效**不问主管**:不传就按「在岗人数 × 每天 15 通 × 时效」估。' +
          '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays。' +
          '\n⚠️ **没有"容量上限"** —— 池子里还有人就随时能再分一批。' +
          '\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' +
          '在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            clinicId: {
              type: 'string',
              description:
                '诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
                '要显式指定时，只能用 get_current_user 返回的 clinicIds 里的值。',
            },
            potentialTreatment: {
              type: 'string',
              description: POTENTIAL_TREATMENT_DESC,
            },
            temperature: {
              type: 'string',
              enum: [...TEMPERATURE_TOOL_VALUES],
              description: TEMPERATURE_TOOL_DESC,
            },
            anchorMode: {
              type: 'string',
              enum: [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT],
              description: ANCHOR_MODE_TOOL_DESC,
            },
            personaTags: {
              type: 'string',
              description:
                '主管在调整阶段追加的画像条件。' +
                '先用 get_cohort_attributes 看清各口子多少人，再带着它重出确认单。\n' +
                // ⚠️ 维度字典**必须给全**：少给一份，模型就得自己猜 key/value,
                //   而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
                PERSONA_TAGS_DESC,
            },
            expiresInDays: {
              type: 'number',
              description: '批次时效天数（基数②）。主管说「给 5 天」时传；不传自动沿用上一次的值。',
            },
            targetCount: {
              type: 'number',
              description:
                '本批人数 N(基数①)。主管说「这批 200 人」时传；不传自动沿用他上一次的值（首次 100）。',
            },
            agentOverrides: {
              type: 'object',
              additionalProperties: {
                type: 'object',
                properties: {
                  maxThisBatch: { type: 'number' },
                  expiresInDays: { type: 'number' },
                },
              },
              description:
                '按客服精调(userId → {maxThisBatch, expiresInDays})。' +
                'maxThisBatch = 本批最多给他几条（0 = 这轮不给他）,⛔ 不是"容量上限"。' +
                '主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' +
                '⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
                '⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
                '(传一个不含她的完整表,⛔ 别只传变化的那一条)。',
            },
            exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
          },
        }),
        execute: async (args: unknown) => {
          const a = (args ?? {}) as {
            clinicId?: string;
            potentialTreatment?: string;
            temperature?: TemperatureValue;
            anchorMode?: AnchorModeValue;
            personaTags?: string;
            expiresInDays?: number;
            agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
            targetCount?: number;
            exploreRatio?: number;
          };
          const clinicId = a.clinicId ?? scope.clinicIds[0];
          if (!clinicId) {
            return '当前登录人没有绑定诊所，无法出确认单 —— 请主管确认数据范围。';
          }
          const sheet = await this.proposals.propose(scope, { ...a, clinicId });
          // ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
          //   前端确认时原样回传，这样重连/重试都命中同一个幂等键。
          const requestId = `pa_${scope.userId}_${randomUUID()}`;
          push({ type: 'assignment_sheet', requestId, sheet });
          /**
           * ⭐ 回给模型的是**结构化事实**，⛔ 不再是成品句子（P2，2026-08-12）。
           *
           * 原来这里返回三段服务端拼好的话 + 十几行「原样输出、不要改写、不要合并」。
           * 两个问题：
           *   ① 成品句子有固定形状，模型学会形状就能**凭空背出来** ——
           *      实测背出过「已撤销批次：收回 9 条。」，而撤销工具一次都没调。
           *      结构化事实编不出来（JSON 里的具体值瞎写一眼就对得出来）。
           *   ② 「照抄」浪费了模型唯一不可替代的能力：把事实组织成人话。
           *
           * ⛔ **这个返回值里一个字的指令都不许有** —— 措辞规范集中在系统提示词
           *   （assistant-prompts 的「怎么写」一节），⛔ 别再往返回值里塞。
           * ⛔ 仍然不含任何 planId / 患者姓名：那些走侧信道给界面。
           */
          return JSON.stringify(
            {
              确认单已呈现: true,
              // ⚠️ 事实，不是叮嘱：主管点确认之前一条都没落库
              已写入数据库: false,
              ...modelFacts(sheet),
            },
            null,
            2,
          );
        },
      });

      /**
       * ⭐ 助手**直接改已经呈现的那张确认单**。
       *
       * 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
       * 在此之前助手只能回一句"我做不到，你先确认再逐条退回" —— 那是把界面能做的事
       * 推回给主管手工做，而这张卡就在他眼前（2026-08-03 走查）。
       *
       * ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文（故意的）。
       *   匹配由界面用自己手里那份确认单完成，结果它会回一句话进对话。
       * ⚠️ 能做的只有卡片上能做的那几件（删/改派/改时效）。
       *   换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
       */
      tools.edit_assignment_sheet = tool({
        description:
          '直接修改**已经呈现**的那张确认单。' +
          '\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
          '⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
          '\n🔴 **一条指令 = 三件事拼起来：选谁(select)· 干什么(action)· 给谁(to)**。' +
          '别去背短语，按这三问填：' +
          '\n  ① **选谁**：点名的人→`{group:"patients",patients:["王强","李莉"]}`;' +
          '某位客服名下全部→`{group:"agent",agent:"张悦"}`;' +
          '「待分配」那一组→`{group:"pending"}`;整批→`{group:"batch"}`。' +
          '\n  ② **干什么**：改派→`assign`;移出本批→`remove`;改时效→`set_expiry`(配 days);' +
          '设批次福利→`set_benefit`(配 text,select 必须是 batch,空串=撤掉)。' +
          '\n  ③ **给谁**(只有 `assign` 要):' +
          '\n     · `{mode:"owner"}` = **各自回自己的专属客服**,一条专属关系都不动;' +
          '\n     · `{mode:"balance"}` = 谁手上少先给谁（铺平）;' +
          '点名了人就加 `agents:["张悦","李莉"]` 把范围限定在他们里（只点一位 = 都给他）。' +
          '\n🔴🔴 **`owner` 和 `balance` 结果正好相反，选错主管一眼看得出来**(实测栽过):' +
          '他说「把待分配的患者**各自分给各自的专属客服**」，助手用了铺平,' +
          '18 个人被散给了 17 位**别人**。' +
          '⛔ 话里出现「各自 / 自己的 / 本来的 / 专属 / 谁的客户归谁」→ `owner`;' +
          '「平均分 / 谁手上少给谁 / 铺下去 / 随机分一分」→ `balance`。' +
          '\n⚠️ `balance` **不是真随机**(那会让同一张单每次算出不同结果),' +
          '界面回报里会说清铺给了谁几条,**照抄**;⛔ 别顺着"随机"两个字复述成"我随机分了"。' +
          '⛔ 把患者从专属客服手里挪走是主管的决定：**他没明确要求就不要用**,更不要主动提议。' +
          '\n⚠️ 举几个拼法（⛔ 别当成清单，没列到的组合照样按三问拼）：' +
          '「把王强移出这批」→`{select:{group:"patients",patients:["王强"]},action:"remove"}`;' +
          '「待分配的各自给回专属客服」→`{select:{group:"pending"},action:"assign",to:{mode:"owner"}}`;' +
          '「张悦名下的分给别人」→`{select:{group:"agent",agent:"张悦"},action:"assign",to:{mode:"balance"}}`;' +
          '「待分配的时效给 5 天」→`{select:{group:"pending"},action:"set_expiry",days:5}`。' +
          '\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单，那走 propose_assignment。' +
          '\n⚠️ 患者用**姓名或病历号**指代，客服用姓名;⛔ 你没有 planId,也不需要。' +
          '\n⚠️ 改完**不要说"已完成"**：界面执行后会自己回一句结果（可能没找到人）,等那句再说。' +
          '\n⚠️ 批次**已经确认分配**之后：只有 `set_benefit` 还能改（福利只影响此后生成的话术,' +
          '界面会真的写库并回报"作废了几条话术缓存 / 几条客服已经打开过"）;' +
          '人员和时效**改不了** —— 单子已经在客服手上，那要走撤销重分。' +
          '⛔ 这两种情况都以界面回的那句为准，别自己判断成没成。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            ops: {
              type: 'array',
              description: '一组操作，按顺序执行',
              items: {
                type: 'object',
                properties: {
                  select: {
                    type: 'object',
                    description: '① 选谁 —— 这条指令作用在哪些人身上',
                    properties: {
                      group: {
                        type: 'string',
                        enum: ['patients', 'agent', 'pending', 'batch'],
                      },
                      patients: {
                        type: 'array',
                        items: { type: 'string' },
                        description: 'group=patients 时：患者姓名或病历号，可给多个',
                      },
                      agent: { type: 'string', description: 'group=agent 时：客服姓名' },
                    },
                    required: ['group'],
                  },
                  action: {
                    type: 'string',
                    enum: ['assign', 'remove', 'set_expiry', 'set_benefit'],
                    description: '② 干什么',
                  },
                  to: {
                    type: 'object',
                    description:
                      '③ 给谁 —— 只有 action=assign 要。owner=各自回自己的专属客服（不动任何专属关系）;' +
                      'balance=谁手上少先给谁。',
                    properties: {
                      mode: { type: 'string', enum: ['owner', 'balance'] },
                      agents: {
                        type: 'array',
                        items: { type: 'string' },
                        description:
                          'mode=balance 时可选：把候选限定在这几位客服（姓名）。' +
                          '不给 = 本批所有在岗（含一条都没分到的 —— 他们恰恰手上最空）。' +
                          '⛔ mode=owner 不吃这个参数：收谁由患者自己的专属关系决定。',
                      },
                    },
                    required: ['mode'],
                  },
                  days: { type: 'number', description: 'set_expiry 的天数(1-90)' },
                  text: {
                    type: 'string',
                    description:
                      'set_benefit 的福利原文（挂在整批上，会进这批人的话术）。空串 = 撤掉福利。' +
                      '⛔ 原话写，不要替主管加条件/期限/承诺。',
                  },
                },
                required: ['select', 'action'],
              },
            },
          },
          required: ['ops'],
        }),
        execute: async (args: unknown) => {
          const ops = (args as { ops?: unknown[] })?.ops;
          if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作，确认单未改动。';
          push({ type: 'assignment_sheet_edit', ops });
          // ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事,
          //   它会把结果作为一条消息回到对话里（前端 appendAssistantNote）。
          return `已把 ${ops.length} 条修改交给界面执行，结果会由界面回报（可能有人没找到）。请等那句结果,⛔ 不要自己宣布已完成。`;
        },
      });
    }

    // 本地"渲染"工具（不走 MCP）：模型把自包含 HTML 片段交给前端，在沙箱 iframe 里渲染成卡片/报表。
    // html 通过 tool-call 入参流式到前端;execute 仅回执，模型据此继续给一句话总结。
    tools.render_artifact = tool({
      description:
        '把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表（可含图表）。用 Tailwind 工具类排版，可含 <canvas>+<script> 画 Chart.js 图表；运行环境已注入 Tailwind 与 Chart.js,勿自行引入，勿访问外部网络，数据内联、手机号掩码、只用真实数据。',
      inputSchema: jsonSchema({
        type: 'object',
        properties: {
          title: { type: 'string', description: '卡片标题（可选）' },
          html: {
            type: 'string',
            description: '<body> 内部 HTML 片段（Tailwind 类；可含 <canvas>+<script> 图表）',
          },
        },
        required: ['html'],
      }),
      execute: async () => '已在界面渲染该卡片。',
    });

    this.logger.log(
      `assistant chat: model=${input.modelId ?? 'deepseek'} tools=${Object.keys(tools).join(',')}`,
    );

    // 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
    const { model } = this.provider.resolve(input.modelId ?? 'deepseek');
    return streamText({
      model,
      system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
      messages: input.messages,
      tools,
      // 防失控：最多 8 步工具循环。
      // 🔴 到顶必须**报出来**（控制器按 finishReason='tool-calls' 发 step_limit 事件）——
      //   静默截断会让模型停在「我先查一下」之后，而主管以为它查完了。
      stopWhen: stepCountIs(MAX_TOOL_STEPS),
      abortSignal: input.abortSignal,
    });
  }

  /** 桌宠环境观察发言 —— 无工具、限长、流式；失败由前端静默降级（宠物只是不说话）。 */
  petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
    const { model } = this.provider.resolve('deepseek');
    return streamText({
      model,
      system: PET_SYSTEM_PROMPT,
      prompt: `环境观察：${input.observation}`,
      // 注：思考型模型 reasoning 也计入 output token,上限给宽；台词长度靠系统提示词约束（≤30 字）
      maxOutputTokens: 500,
      temperature: 1.0,
      abortSignal: input.abortSignal,
    });
  }
}
