import {
  Body,
  Controller,
  Get,
  Param,
  Post,
  Query,
  Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
  ApiCode,
  Permission,
  PlanEventReason,
  PlanEventType,
  SubmitRecallFeedbackRequestSchema,
} from '@pac/types';
import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { BizError } from '../../common/errors/biz-error';
import { assertPlanClaimedBy } from '../plan/claim-guard';
import { recordPlanEvent } from '../plan/plan-event.recorder';
import {
  TenantScope,
  TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator';
import {
  CurrentUser,
  AuthenticatedUser,
} from '../../common/decorators/current-user.decorator';
import { PrismaService } from '../../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import { WecomScriptOrchestrator } from '../ai/orchestrators/wecom-script.orchestrator';
import type { PlanScriptStreamEvent } from '../ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator';
import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator';
import { PersonaSummaryOrchestrator } from '../ai/orchestrators/persona-summary.orchestrator';
import { RecallBriefOrchestrator } from '../ai/orchestrators/recall-brief.orchestrator';
import { PlanAggregateService } from './plan-aggregate.service';
import {
  renderAgentIdentity,
  resolveScriptAgent,
} from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';

const ScriptFeedbackSchema = z.object({
  feedback: z.enum(['up', 'down']),
});

/// 复制话术埋点的入参 —— 渠道闭集,⛔ 不收裸字符串(reason 列要能直接 group by)
const ScriptCopySchema = z.object({
  channel: z.enum(['wecom', 'phone']),
});

/**
 * PlansAggregateController — 生产路径的 plan 详情聚合 + AI 资产再生成端点。
 *
 * 端点(全 /pac/v1/plans/:id/...):
 *   GET  :id/full              详情聚合(plan+patient+profile+persona+chains+facts)
 *   POST :id/script:regenerate 同步重生成话术(测试用,等价 stream 但不流)
 *   GET  :id/script:stream     流式重生成话术(SSE)
 *   GET  :id/summary:stream    流式重生成 plan 摘要(SSE,1 次产 3 段)
 *
 * 跟 PlanController(同 /plans 前缀,strict Zod 的 CRUD)分工:
 *   - PlanController:list / detail / assign / recycle / executions(都走 strict Zod 契约)
 *   - 本控制器:聚合 + AI 资产相关(响应 shape 复杂,跳过 strict Zod 提速迭代)
 *
 * 放 PlanAggregateModule:避免 PlanModule ↔ PlanAggregateModule 循环依赖(PlanAggregateModule 已 import PlanModule/AiModule)。
 */
@ApiTags('plan')
@ApiBearerAuth('accessToken')
@Controller('plans')
export class PlansAggregateController {
  constructor(
    private readonly demo: PlanAggregateService,
    private readonly planScript: PlanScriptOrchestrator,
    private readonly wecomScript: WecomScriptOrchestrator,
    private readonly planSummary: PlanSummaryOrchestrator,
    private readonly recallSummary: RecallSummaryOrchestrator,
    private readonly personaSummary: PersonaSummaryOrchestrator,
    private readonly recallBrief: RecallBriefOrchestrator,
    private readonly prisma: PrismaService,
  ) {}

  /**
   * 认领闸(见 plan/claim-guard.ts)—— 「未认领只能看,不能操作」。
   * 本控制器的写操作都是**详情页上的按钮**(重生成话术 / 打反馈),按钮就该受这道闸;
   * 只读端点(:id/full、recall-brief 等)不过闸 —— "看"始终开放。
   */
  private async assertClaimed(
    scope: TenantScopeContext,
    planId: string,
    userId: string,
  ): Promise<void> {
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { assigneeUserId: true },
    });
    if (!plan) throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} not found`);
    assertPlanClaimedBy(plan, userId);
  }

  // ─────────────────────────────────────────────
  // 详情聚合
  // ─────────────────────────────────────────────

  @Get(':id/full')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({
    summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)',
    description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。',
  })
  getFull(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
  ) {
    // ⭐ 话术里的【回访客服】按**当前登录人**回填(缓存是 per-plan、池子共享,不能烤人名)
    return this.demo.getPlanDetailByPlanId(scope, planId, resolveScriptAgent(user));
  }

  // 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调:
  //   有 ready 摘要秒回;没有则当场跑 AiCall 生成存库再回;无回访记录 → status='empty')。
  @Get(':id/recall-summary')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '回访历史一句话摘要(有则取、无则当场生成)' })
  getRecallSummary(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
    return this.recallSummary.getOrGenerate(scope, planId);
  }

  // 画像标签「一句话重点」get-or-generate(详情页「画像标签」卡进卡片即调:
  //   有 ready 摘要秒回;没有则当场跑 AiCall 生成存库再回;无 persona/feature → status='empty')。
  @Get(':id/persona-summary')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '画像标签一句话重点(有则取、无则当场生成)' })
  getPersonaSummary(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
    return this.personaSummary.getOrGenerate(scope, planId);
  }

  // 本次召回「一句话简报」get-or-generate(详情页「参考话术」标题下;输入=召回原因+历史治疗+画像,
  //   回答 患者是谁 / 帮其解决什么问题 / 邀约到诊做什么 三问;无召回原因 → status='empty')。
  @Get(':id/recall-brief')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '本次召回一句话简报(有则取、无则当场生成)' })
  getRecallBrief(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
    return this.recallBrief.getOrGenerate(scope, planId);
  }

  // ─────────────────────────────────────────────
  // 话术 — 同步重生成 / 流式重生成
  // ─────────────────────────────────────────────

  /**
   * 生成 / 重新生成**企微**话术。
   *
   * ⚠️ 同步返回,**不做流式** —— 企微稿是一整块、几百字,几秒就出;
   *   为它再铺一条 SSE 通道只是把电话档那套逐段推送复制一遍,而这里没有"段"可推。
   * ⚠️ 失败**不兜底**:返回 source='failed' + failReason,前端如实显示。
   *   ⛔ 别回落成电话稿(口语分段的东西复制发给患者很怪,而客服不会注意到)。
   */
  /**
   * 流式生成企微话术(SSE)—— ⭐ 事件形状与电话档 `script:stream` 一致,
   * 前端那套「深度过程可见」的时间线 / 停止 / AIStamp 因此原样复用。
   * ⚠️ 只推**步骤**不推正文增量:企微稿是一整块,逐字推过去只是闪。
   */
  @Get(':id/wecom-script:stream')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '流式生成企微话术(SSE,只推步骤)' })
  async streamWecomScript(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Query('model') model: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    // 认领闸 —— 在开 SSE 之前抛,让错误走正常 JSON envelope 而不是流里的 error 事件
    await this.assertClaimed(scope, planId, user.sub);
    const agent = resolveScriptAgent(user);
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const self = this;
    await this.pipeSse(res, async function* (signal) {
      for await (const ev of self.wecomScript.generateStream(planId, {
        modelIdOverride: model,
        signal,
      })) {
        // 落库存的是【回访客服】占位,推给前端前按当前登录人回填
        yield ev.type === 'done' ? { ...ev, content: renderAgentIdentity(ev.content, agent) } : ev;
      }
    });
  }

  // ═══════════════════════════════════════════════════════════════════
  // 🔴🔴 **字面量路由必须声明在 `xxx:verb` 路由之前** —— 顺序即语义,别重排。
  //
  // Express 把 `:id/script:regenerate` 里的 `:regenerate` 当成**路径参数**:
  // 模式实际是「字面量 `script` + 参数」,于是 `/script-feedback`、`/script-copy`
  // 这些以 `script` 开头的路径**全部命中它**(参数 = `-feedback` / `-copy`)。
  //
  // 实测后果(2026-08-04 发现):话术 👍👎 反馈按钮**从来没生效过** ——
  // 每点一次实际是在**重新生成话术**(真花 AI 钱 + 覆盖已有稿),而前端拿到 200
  // 看起来一切正常。同一个坑把新加的复制埋点也吞了。
  //
  // ⚠️ 治标是这里的顺序,治本是把 `:verb` 风格的路径改成 `/verb`(会动前端与文档契约)。
  // ⛔ 在此之前:任何以 `script` / `wecom-script` 开头的**新字面量路由,必须加在这一段里**。
  // ═══════════════════════════════════════════════════════════════════

  /**
   * 反馈某 plan 当前话术(对应 plan_scripts.agent_invocation_id)up/down。
   * 同一 invocation 反复点会覆盖(再点 down 覆盖 up,以最新为准)。
   * 没找到 planScript / agentInvocationId → 404(避免无 invocation 的 plan 接受反馈)。
   *
   * 后续可加:summary feedback(对称端点)/ 聚合 dashboard(up/down 比 + 命中 fallback 时禁止评)。
   */
  @Post(':id/script-feedback')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '对当前话术 thumbs up/down 反馈("本段是否好用")' })
  async submitScriptFeedback(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Body() body: unknown,
  ) {
    await this.assertClaimed(scope, planId, user.sub); // 认领闸
    const { feedback } = ScriptFeedbackSchema.parse(body);
    const script = await this.prisma.planScript.findFirst({
      where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
    });
    if (!script?.agentInvocationId) {
      return { ok: false as const, reason: 'no_invocation' };
    }
    await this.prisma.agentInvocation.update({
      where: { id: script.agentInvocationId },
      data: {
        userFeedback: feedback,
        userFeedbackAt: new Date(),
        userFeedbackBy: user.sub,
      },
    });
    return { ok: true as const, invocationId: script.agentInvocationId, feedback };
  }

  /**
   * 上报「复制了参考话术」—— 点一次记一条。
   *
   * ⭐ 为什么值得记:企微稿的正常用法就是**复制出去发给患者**,复制那一下之后客服就离开 PAC 了。
   *   这是我们能观测到的**最接近"真的用了"**的信号 —— 生成完没人复制 = 生成了但没人用,
   *   那是产品问题不是模型问题,而现在这件事完全看不见。
   *
   * ⚠️ **不加认领闸**(同 `:id/view`):主管在池子里浏览时也能复制,加了闸这个埋点
   *   就只剩已认领的,统计直接偏。
   * ⚠️ 埋点是**尽力而为的旁路**:找不到 plan 静默返回,⛔ 不能因为它失败而打断复制这个动作
   *   —— 前端那边也不 await(见 wecom-script-view)。
   */
  @Post(':id/script-copy')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '上报复制参考话术(埋点)—— 每点一次「复制」记一条' })
  async reportScriptCopy(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Body() body: unknown,
  ) {
    const { channel } = ScriptCopySchema.parse(body);
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { id: true, hostId: true, tenantId: true, patientId: true, assignmentId: true },
    });
    if (!plan) return { ok: false as const, reason: 'not_found' };
    await recordPlanEvent(this.prisma, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      planId: plan.id,
      patientId: plan.patientId,
      event: PlanEventType.SCRIPT_COPY,
      // 渠道进 reason 列(登记过的枚举,可直接 group by),⛔ 不塞 details
      reason: channel === 'wecom' ? PlanEventReason.COPY_WECOM : PlanEventReason.COPY_PHONE,
      actorUserId: user.sub,
      // ⚠️ 复制**不改变归属**,但要记是哪一批的单 —— 否则"这批的话术有没有被用"answer 不了。
      //   与 release/auto_release 同一条口径:此刻取是对的(覆盖只发生在下次分配)。
      assignmentId: plan.assignmentId,
    });
    return { ok: true as const };
  }

  @Post(':id/wecom-script:regenerate')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '生成 / 重新生成企微话术(一次性单块)' })
  async regenerateWecomScript(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Query('model') model?: string,
  ) {
    // 认领闸:同电话档 —— 生成要真花 AI 钱
    await this.assertClaimed(scope, planId, user.sub);
    const result = await this.wecomScript.generate(planId, { modelIdOverride: model });
    const agent = resolveScriptAgent(user);
    return {
      planId: result.planId,
      planScriptId: result.planScriptId,
      agentInvocationId: result.agentInvocationId,
      source: result.source,
      costYuan: result.costYuan,
      // 落库存的是【回访客服】占位,返回时按当前登录人回填
      content: renderAgentIdentity(result.content, agent),
      ...(result.failReason ? { failReason: result.failReason } : {}),
    };
  }

  @Post(':id/script:regenerate')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' })
  async regenerateScript(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Query('bustCache') bustCache?: string,
    @Query('model') model?: string,
    @Query('tier') tier?: string,
    @Body() body?: { dryRun?: boolean },
  ) {
    // 认领闸:重生成要真花 AI 钱,不该让没认领的人点
    await this.assertClaimed(scope, planId, user.sub);
    const result = await this.planScript.generate(planId, {
      bustCache: bustCache === 'true' || bustCache === '1',
      modelIdOverride: model,
      tier: parseScriptTier(tier),
      dryRun: body?.dryRun ?? false,
    });
    const agent = resolveScriptAgent(user);
    return {
      planId: result.planId,
      planScriptId: result.planScriptId,
      agentInvocationId: result.agentInvocationId,
      source: result.source,
      cacheHit: result.cacheHit,
      costYuan: result.costYuan,
      // 落库的是占位符正文,返回给调用方的是回填后的(structured 保持原始,便于调试看 LLM 真实输出)
      content: renderAgentIdentity(result.content, agent),
      structured: result.structured,
    };
  }

  /**
   * 流式重生成话术 — SSE。
   *
   * 事件 data 是 JSON,见 PlanScriptStreamEvent:
   *   start    {invocationId, modelId, promptVersion}
   *   partial  {structured, sections}        ← 多次
   *   done     {planScriptId, source, content, sections, costYuan}
   *   error    {message}
   *
   * 用裸 Response 而不是 @Sse() Observable — 后者把 finalize 时机藏起来。
   */
  @Get(':id/script:stream')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' })
  async streamScript(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Query('model') model: string | undefined,
    @Query('tier') tier: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    // 认领闸 —— 在开 SSE 之前抛,让错误走正常 JSON envelope 而不是流里的 error 事件
    await this.assertClaimed(scope, planId, user.sub);
    await this.pipeSse(res, (signal) =>
      renderAgentInStream(
        this.planScript.generateStream(planId, {
          modelIdOverride: model,
          tier: parseScriptTier(tier),
          signal,
        }),
        resolveScriptAgent(user),
      ),
    );
  }

  // ─────────────────────────────────────────────
  // Summary — 流式重生成(1 次产 3 段:onePage / medicalRecord / treatmentChain)
  // ─────────────────────────────────────────────

  // ─────────────────────────────────────────────
  // 话术 / 摘要 thumbs up/down 反馈("本段是否好用")
  // ─────────────────────────────────────────────

  @Post(':id/view')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({
    summary: '上报 plan 详情页浏览(PV/UV 埋点)—— 每进入一次记一条',
  })
  async reportPlanView(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
  ) {
    // ⚠️ **不加认领闸**(区别于 recall-feedback / 执行结果):浏览发生在认领**之前**,
    //    池子里任何一条都能点开看。加了闸这个埋点就只剩已认领的,PV/UV 直接废掉。
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { id: true, hostId: true, tenantId: true, patientId: true },
    });
    // 静默返回 not_found:埋点是**尽力而为**的旁路,不能因为它失败而影响页面
    if (!plan) return { ok: false as const, reason: 'not_found' };
    await recordPlanEvent(this.prisma, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      planId: plan.id,
      patientId: plan.patientId,
      event: PlanEventType.VIEW,
      actorUserId: user.sub,
    });
    return { ok: true as const };
  }

  @Post(':id/recall-feedback')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({
    summary: '召回反馈(plan 级)— 对"这条召回准不准"打拇指 + 文字(down 弹窗收集)',
  })
  async submitRecallFeedback(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Body() body: unknown,
  ) {
    await this.assertClaimed(scope, planId, user.sub); // 认领闸
    const { feedback, note } = SubmitRecallFeedbackRequestSchema.parse(body);
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { id: true, hostId: true, tenantId: true, patientId: true },
    });
    if (!plan) return { ok: false as const, reason: 'not_found' };
    // 反馈是对 plan 本身的判断 → 直接写 followup_plans(正交于执行,不进事实层)
    await this.prisma.$transaction(async (tx) => {
      await tx.followupPlan.update({
        where: { id: plan.id },
        data: {
          recallFeedback: feedback,
          recallFeedbackNote: note?.trim() ? note.trim() : null,
        },
      });
      // ⭐ 同时落事件账本:followup_plans.recall_feedback 是**就地覆盖**的,
      //   客服先点 👍 后改 👎(或反复评)会把前一次冲掉。准确度统计要的是**全部判定**,
      //   只看最终值会低估分子,也看不出"改判"这种强信号。
      await recordPlanEvent(tx, {
        hostId: plan.hostId,
        tenantId: plan.tenantId,
        planId: plan.id,
        patientId: plan.patientId,
        event: PlanEventType.FEEDBACK,
        actorUserId: user.sub,
        reason: feedback, // 'up' | 'down' —— 立柱,统计直接按它过滤
        details: note?.trim() ? { note: note.trim() } : null,
      });
    });
    return { ok: true as const, feedback };
  }

  @Get(':id/summary:stream')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '流式重新生成 plan 摘要(SSE,1 次产 3 段)' })
  async streamSummary(
    @Param('id') planId: string,
    @Query('model') model: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    // 摘要档为单次调用,暂不透传 signal(controller 断连仍会停心跳/停写);如需取消在途再透传
    await this.pipeSse(res, () => this.planSummary.generateStream(planId, { modelIdOverride: model }));
  }

  // ─────────────────────────────────────────────
  // 内部:把 AsyncGenerator → SSE 写出(共用,两个流式端点都走)
  // ─────────────────────────────────────────────

  private async pipeSse<T extends { type: string }>(
    res: Response,
    makeStream: (signal: AbortSignal) => AsyncIterable<T>,
  ): Promise<void> {
    res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
    res.setHeader('Cache-Control', 'no-cache, no-transform');
    res.setHeader('Connection', 'keep-alive');
    res.setHeader('X-Accel-Buffering', 'no'); // 关 nginx buffering
    res.flushHeaders?.();

    // ⭐ 客户端断连(关页面/点停止/网络断)→ abort:取消在途 LLM(尤其深度 3-4 步,别白烧)
    //    + 停心跳、停写,避免往已关闭 socket 写。
    const ac = new AbortController();
    res.on('close', () => ac.abort());

    // 心跳防代理断连(每 15s 一个注释行);socket 已关则不写
    const heartbeat = setInterval(() => {
      if (!res.writableEnded) res.write(': hb\n\n');
    }, 15_000);

    try {
      for await (const evt of makeStream(ac.signal)) {
        if (ac.signal.aborted) break;
        res.write(`event: ${evt.type}\n`);
        res.write(`data: ${JSON.stringify(evt)}\n\n`);
        (res as Response & { flush?: () => void }).flush?.();
        if (evt.type === 'done' || evt.type === 'error') break;
      }
    } catch (err) {
      if (!ac.signal.aborted && !res.writableEnded) {
        const message = err instanceof Error ? err.message : String(err);
        res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', message })}\n\n`);
      }
    } finally {
      clearInterval(heartbeat);
      if (!res.writableEnded) res.end();
    }
  }
}

/**
 * SSE 流的【回访客服】回填 —— 落库存占位符,推给前端的每一帧都换成当前登录客服。
 *
 * 只动 `partial` / `done` 的展示字段(sections.markdown / content);`structured` 保持 LLM 原始输出
 * 不动(调试时要能看到模型到底吐了什么)。partial 是**全量快照**(非增量 delta),所以逐帧替换安全,
 * 不存在占位符被切在两帧之间的问题。
 */
async function* renderAgentInStream(
  stream: AsyncIterable<PlanScriptStreamEvent>,
  agent: ScriptAgentIdentity,
): AsyncGenerator<PlanScriptStreamEvent, void, void> {
  for await (const evt of stream) {
    if (evt.type === 'partial' || evt.type === 'done') {
      const sections = evt.sections.map((s) => ({
        ...s,
        markdown: renderAgentIdentity(s.markdown, agent),
      }));
      yield evt.type === 'done'
        ? { ...evt, sections, content: renderAgentIdentity(evt.content, agent) }
        : { ...evt, sections };
      continue;
    }
    yield evt;
  }
}

/** 投入档 query 解析:只接受白名单,非法/缺省 → undefined(orchestrator 默认 stable) */
function parseScriptTier(tier: string | undefined): 'stable' | 'standard' | 'deep' | undefined {
  return tier === 'stable' || tier === 'standard' || tier === 'deep' ? tier : undefined;
}
