Commit 6658baca by luoqi

docs+feat(助手): 四份 agent 设计文档 + P0 落地(取值码中文化/解读规范下沉/截断可见)

文档(新增四份,成套):
- agent-doctrine.md      通用教条 49 条,按「后补代价」分三档
- agent-architecture.md  四控制面 + 八原则 + 术语甄别 + 三方分工
- assignment-agent-flow.md   分配的 Agent 行为规格(状态机 + 引导节点,定稿)
- assignment-agent-dev-plan.md  P0-P6 改造规划 + 代码组织规范

P0 落地:
- model-facing.ts —— 取值码/桶名 → 中文。给模型现成的中文(①级),
  替代提示词里两页「 不许说出取值码」(③级)。只翻译不判断,底层
  service 形状不动(REST/前端是另一个消费者)。
- guides.ts —— 解读规范随返回值下发(`_guide`)。get_assignment_detail
  的 35 行描述是常驻成本,而只有约三成会话用得到;挂返回值上零往返、
  必然到达、用不到时零成本。
- stopWhen 到顶不再静默 —— finishReason='tool-calls' 时发 step_limit
  事件,前端如实告知。原来模型可能停在「我先查一下」之后,主管以为查完了。
- SYSTEM_PROMPT 不再写死「为牙科诊所的客服人员」—— 主管也走同一条链路,
  与后面拼上的 DISPATCHER_EXTRA 当场矛盾。
- 提示词减 35 行:删掉已被结构保证的部分(取值码、五个进度桶、noTag 桶名),
   保留仍需靠模型的(工具名、内部黑话)。
- 骨架顺序改成「怎么排的 → 已排好 → 要您定的」,与 propose_assignment
  的实际输出顺序对齐(此前提示词与代码打架)。

tsc(service/web) 干净;jest 1212 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent d0a9f5f4
import { Body, Controller, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ModelMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service'; import { AssistantService, MAX_TOOL_STEPS } from './assistant.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator'; import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
...@@ -69,6 +69,8 @@ function extractHtmlField(jsonText: string): string | null { ...@@ -69,6 +69,8 @@ function extractHtmlField(jsonText: string): string | null {
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@Controller('assistant') @Controller('assistant')
export class AssistantController { export class AssistantController {
private readonly logger = new Logger(AssistantController.name);
constructor( constructor(
private readonly assistant: AssistantService, private readonly assistant: AssistantService,
private readonly transcriber: TranscribeService, private readonly transcriber: TranscribeService,
...@@ -210,6 +212,22 @@ export class AssistantController { ...@@ -210,6 +212,22 @@ export class AssistantController {
error: p.error instanceof Error ? p.error.message : String(p.error), error: p.error instanceof Error ? p.error.message : String(p.error),
}); });
break; break;
case 'finish':
/**
* 🔴 **no silent cap** —— `stopWhen(MAX_TOOL_STEPS)` 到顶时,SDK 以
* `finishReason='tool-calls'` 收尾:模型还想继续调工具,被截断了。
*
* 不报出来的后果很隐蔽:它可能停在「我先查一下」之后就没了下文,
* 而主管以为查完了。⚠️ 这里只**陈述事实**(本轮到上限了),
* ⛔ 不替模型补话、⛔ 不自动续跑 —— 续不续是用户的决定。
*/
if (p.finishReason === 'tool-calls') {
this.logger.warn(
`助手单轮工具步数到顶(${MAX_TOOL_STEPS}),模型仍想继续调用 —— 本轮被截断`,
);
send({ type: 'step_limit', limit: MAX_TOOL_STEPS });
}
break;
case 'error': case 'error':
send({ type: 'error', error: String(p.error) }); send({ type: 'error', error: String(p.error) });
break; break;
......
...@@ -16,7 +16,13 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service'; ...@@ -16,7 +16,13 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。 /**
* ⚠️ 这里刻意**不写死身份**:同一个助手同时服务门诊经理(主管)和客服,
* 具体在跟谁说话由 `systemExtra`(buildSystemExtra,按权限切两套)和 `get_current_user` 决定。
* 🔴 2026-08-12:原文写的是「为牙科诊所的客服人员提供帮助」,而主管也在用同一条链路 ——
* 与后面拼上来的 DISPATCHER_EXTRA(「你现在在跟门诊经理说话」)当场矛盾。
*/
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的工作人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。 你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。
重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。 重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。
...@@ -37,6 +43,12 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所 ...@@ -37,6 +43,12 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所
用中文,简洁专业、友好。`; 用中文,简洁专业、友好。`;
/**
* 单轮里最多跑几步工具循环。
* ⚠️ 改这个值时记得:到顶不是"正常结束",控制器要把它当异常路径报给前端(no silent cap)。
*/
export const MAX_TOOL_STEPS = 8;
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */ /** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。 const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。 根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
...@@ -436,7 +448,10 @@ export class AssistantService { ...@@ -436,7 +448,10 @@ export class AssistantService {
system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT, system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
messages: input.messages, messages: input.messages,
tools, tools,
stopWhen: stepCountIs(8), // 防失控:最多 8 步工具循环 // 防失控:最多 8 步工具循环。
// 🔴 到顶必须**报出来**(控制器按 finishReason='tool-calls' 发 step_limit 事件)——
// 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。
stopWhen: stepCountIs(MAX_TOOL_STEPS),
abortSignal: input.abortSignal, abortSignal: input.abortSignal,
}); });
} }
......
/**
* guides —— 随工具**返回值**下发的解读规范(`_guide`)。
*
* ═══ 为什么不放在工具描述里 ═══════════════════════════════════════
* 工具描述是**常驻**的:每一次对话、每一轮,都要连同工具清单一起发给模型。
* 而「批次跟踪的数怎么读」只有约三成会话用得到 —— 其余七成在为它白付上下文。
*
* 挂在返回值上则是**按需加载最便宜的形态**:零往返(不用模型主动去取)、
* 必然到达(它一定会看到自己调的工具的返回)、用不到时零成本。
*
* ═══ 边界 ═══════════════════════════════════════════════════════
* ✅ 这里写「**怎么解读这批数**」——容易误读的地方、必须一起报的东西。
* ⛔ 「**怎么用这个工具**」(什么时候调、参数怎么填)仍然留在工具描述里 ——
* 那是模型在**决定调不调**的时候要看的,来不及等返回值。
* ⛔ ⛔ **不许在这里写"照抄下面这句"这类成品句子。**
* 2026-08-08 栽过:`get_assignment_detail` 的 `note` 字段混着给模型的指令,
* 模型照抄就把内部指令原样贴进了主管的对话框。护栏写成**规范**,不写成台词。
*
* [弥补模型] —— 模型变强后这些规范应当逐条复查是否还必要。
*/
/** 批次跟踪(`get_assignment_detail`)的解读规范 */
export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。',
'recordsTruncated=true 时必须说明「只是最近的一部分」。',
'每条 record 里 outcome 只是其中一个字段,还有客服勾的子选项:abandonReasons(放弃原因,只有「放弃」才有)、inaccurateTreatments(客服说这条召回判断错了,调算法要看它)、scheduledNextAt(约的回访日)、channel(电话/企微/短信)。⛔ 只报 outcome 等于漏掉一半 ——「为什么放弃」的答案在 abandonReasons 里。',
'上面这些数用你自己的话讲,⛔ 别整段照搬工具返回的字符串。',
];
/** 批次列表(`list_assignment_batches`)的解读规范 */
export const BATCH_LIST_GUIDE = [
'「已处理」不等于「已成功」:含召回出池、被抑制、已结案三种,⛔ 别说成转化。',
'各批次的处理率不能直接横比,先看各自跑了多少天。',
'要看某一批为什么这样,用 get_assignment_detail 取明细,⛔ 不要从汇总数里推原因。',
];
...@@ -23,6 +23,16 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -23,6 +23,16 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id'; import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import type { McpAuthContext } from './mcp-auth.service'; import type { McpAuthContext } from './mcp-auth.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc';
// ⭐ 面向模型的翻译层 —— 取值码换中文、桶名换中文键。见 model-facing.ts 顶部注释:
// 给模型现成的中文(① 级),替代提示词里那两页「⛔ 不许说出取值码」(③ 级)。
import {
criteriaZh,
zhCohortDim,
zhProgressBuckets,
zhScenario,
} from './model-facing';
// ⭐ 解读规范随返回值下发,⛔ 不再常驻在工具描述里。见 guides.ts 顶部注释。
import { BATCH_LIST_GUIDE, TRACKING_GUIDE } from './guides';
function jsonResult(data: unknown) { function jsonResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
...@@ -370,9 +380,8 @@ export class McpServerFactory { ...@@ -370,9 +380,8 @@ export class McpServerFactory {
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'), keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'),
}, },
}, },
async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) => async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) => {
jsonResult( const r = await this.cohorts.describe(
await this.cohorts.describe(
scope, scope,
{ {
clinicId: resolveClinicId(scope, clinicId), clinicId: resolveClinicId(scope, clinicId),
...@@ -382,8 +391,14 @@ export class McpServerFactory { ...@@ -382,8 +391,14 @@ export class McpServerFactory {
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
}, },
{ ...(keys ? { keys } : {}) }, { ...(keys ? { keys } : {}) },
), );
), return jsonResult({
...r,
// ⭐ 条件回显 —— 模型用码调工具,但要用中文回话。不给回显它就只能自己翻译。
criteriaZh: criteriaZh({ potentialTreatment, temperature, anchorMode }),
dimensions: r.dimensions.map(zhCohortDim),
});
},
); );
// ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。 // ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。
...@@ -396,14 +411,17 @@ export class McpServerFactory { ...@@ -396,14 +411,17 @@ export class McpServerFactory {
{ {
description: description:
'我分过的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' + '我分过的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' +
'\n⚠️ done 是**已处理**不是**已成功**(召回出池 / 被抑制 / 已结案),别说成转化。' + '\n回答「我分的那些批,哪批出问题了」。要看某批细节再用 get_assignment_detail。' +
'回答"我分的那些批,哪批出问题了"。要看某批细节再用 get_assignment_detail。', '\n⚠️ 返回里带 `_guide`,读数前先看它。',
inputSchema: { inputSchema: {
mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'), mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'),
}, },
}, },
async ({ mine }) => async ({ mine }) =>
jsonResult(await this.assignments.list(scope, mine ? scope.userId : undefined)), jsonResult({
batches: await this.assignments.list(scope, mine ? scope.userId : undefined),
_guide: BATCH_LIST_GUIDE,
}),
); );
server.registerTool( server.registerTool(
...@@ -435,42 +453,25 @@ export class McpServerFactory { ...@@ -435,42 +453,25 @@ export class McpServerFactory {
server.registerTool( server.registerTool(
'get_assignment_detail', 'get_assignment_detail',
{ {
// ⚠️ 这里只写「**怎么用这个工具**」—— 那是模型在决定调不调时要看的。
// 「**怎么解读这批数**」全部搬进返回值的 `_guide`(见 guides.ts 顶部注释):
// 原来那 35 行是常驻成本,而只有约三成会话用得到。
description: description:
'单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 未动过的条数。' + '单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 通话成效 + 逐条明细与电话纪要。' +
'\n⚠️⚠️ progress 是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。' + '\n回答「我分的那批怎么样了 / 哪个患者为什么退回 / 客服怎么说的」。' +
'resolved=该患者召回已出池(引擎按客观事实判定需求已了) / suppressed=客服写了回访结果 / ' + '\n⚠️ 返回里带 `_guide` —— **读数之前先看它**,里面是这批数容易被误读的地方。',
'inHandPending=还在手上没动 / backToPool=退回或到期落回池子。' +
'⛔ 不要把 resolved 说成"转化成功",⛔ 不要拿这些数自己算转化率。' +
'\n⚠️ 报处理率**必须带批次年龄**(progress.ageDays):跑了三个月的批次天然比跑了三天的好看。' +
'\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' +
'"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' +
'\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。' +
'\n\n⚠️⚠️ `outcomes`(通话成效)与 `releaseReasons`(退回原因)是**两件不同的事**,⛔ 绝不能混说:' +
'\n · releaseReasons = 「这单**不该我做**」—— 客服**没打**就还回去了 → 是**分配**问题' +
'\n · outcomes = 「打了,结果这样」—— 客服**做了事** → 是**召回效果**问题' +
'\n 说反了主管会去改错的东西(一个要调派单、一个要调话术和人群)。' +
'\n⚠️ `outcomes.noOutcome`(一次结果都没有)**必须单独报出来**,' +
'⛔ 不许算进"不成功" —— 那不是效果差,是**根本没做/没记**。' +
'\n⚠️ `outcomes.success` 含**约定下次回访**(约到下次也算有效推进),' +
'⛔ 别说成"成交/转化了这么多"。' +
// 🔴 这里原来还有一句「照抄 `outcomes.note` 最稳」——2026-08-08 连同那个字段一起删了:
// note 里混着给模型的指令(「不要算成功率、不要画图」),模型照抄就把内部指令
// 原样贴进了主管的对话框。⛔ 别再造这种"成品句子"字段,护栏就写在本说明里。
'\n⚠️ 上面这些数**用你自己的话讲**,⛔ 别整段照搬工具返回的字符串。' +
'\n\n⭐ `outcomes.records` = **逐条明细 + 客服手写的电话纪要**(`notes`)。' +
'主管问"哪个患者/为什么/客服怎么说的",答案只在这里 —— 别只回聚合数。' +
'\n⚠️ `notes` 为 null = **没留纪要**(不是没打)。' +
'结果填了、纪要空着本身是信息:说明只点了个选项。' +
'\n⚠️ 引用纪要时**照原话**,⛔ 别润色成"客户表示…" —— 主管要看的就是客服当时怎么写的。' +
'\n⚠️ `recordsTruncated=true` 时必须说明"只是最近的一部分"。' +
'\n⚠️ 每条 record 里 `outcome` **只是其中一个字段**,还有客服勾的子选项:' +
'`abandonReasons`(放弃原因,只有「放弃」有)、' +
'`inaccurateTreatments`(客服说**这条召回判断错了**,调算法要看它)、' +
'`scheduledNextAt`(约的回访日)、`channel`(电话/企微/短信)。' +
'⛔ 只报 outcome 等于漏掉一半 —— "为什么放弃"的答案在 `abandonReasons` 里。',
inputSchema: { assignmentId: z.string() }, inputSchema: { assignmentId: z.string() },
}, },
async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)), async ({ assignmentId }) => {
const d = await this.assignments.detail(scope, assignmentId);
// ⭐ 五个桶换成中文键。⚠️ 「已出池·引擎判定需求已了」刻意写全 —— resolved
// 不是「谈成了」,键名本身就该挡住这个误读(T14)。
const p = d.progress as Record<string, unknown> | undefined;
return jsonResult({
...(p ? { ...d, progress: { ...zhProgressBuckets(p), note: p.note } } : d),
_guide: TRACKING_GUIDE,
});
},
); );
} }
...@@ -513,10 +514,13 @@ export class McpServerFactory { ...@@ -513,10 +514,13 @@ export class McpServerFactory {
}, },
}); });
if (!plan) return null; if (!plan) return null;
const reasons = await this.prisma.planReason.findMany({ const rawReasons = await this.prisma.planReason.findMany({
where: { planId: plan.id }, where: { planId: plan.id },
select: { scenario: true, priorityScore: true, reason: true }, select: { scenario: true, priorityScore: true, reason: true },
}); });
// ⭐ 场景码带上中文(`treatment_initiation_recall` → 「潜在治疗」)。
// ⚠️ 保留原 `scenario` 字段:模型有时要拿它当别的工具的参数。
const reasons = rawReasons.map((r) => ({ ...r, scenarioZh: zhScenario(r.scenario) }));
return { return {
planId: plan.id, planId: plan.id,
status: plan.status, status: plan.status,
......
import {
ANCHOR_MODE_META,
TEMPERATURE_META,
planScenarioLabel,
potentialTreatmentCardLabel,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
/**
* model-facing —— 服务层返回值 →「面向模型」的形状。
*
* ═══ 为什么要有这一层 ═══════════════════════════════════════════════
* 模型说出 `cold_3y` `implant` `inHandPending` 这类词,主管的第一反应是**系统坏了**。
* 原来的对策是在提示词里写两页「⛔ 不许说出取值码」——那是 P4 ③ 级(只拦得住预想到的),
* 而且实测拦不住:模型调工具**必须**用码当参数,回话时自然就带出来了。
*
* 真正的解法是**给它现成的中文**:返回值里既回显它传进来的条件(`criteriaZh`),
* 又把桶名换成中文键。它手里有话可说,就不会去说码。—— 这是 ① 级(结构上不可能)。
*
* ═══ 三条边界 ═══════════════════════════════════════════════════════
* ⛔ **只做翻译,不做业务判断。** 任何"要不要提醒""算不算成功"的判断都不属于这里。
* ⛔ **不改底层 service 的形状。** REST / 前端是另一个消费者,它们要的是稳定的 key。
* 翻译只发生在 MCP 边界上。
* ⛔ **不在这里生成成品句子。** 那是 P2 要消除的东西(G6:给事实不给句子)。
*
* 标签一律取自 `@pac/types` 的既有映射,⛔ 不在这里另立一套中文 —— 两份必然漂。
*/
// ─────────────────────────────────────────────────────────
// 单值翻译
// ─────────────────────────────────────────────────────────
/** 时间档取值码 → 矩阵上那一列的中文(`hot` → 「三个月内」) */
export function zhTemperature(code: string | null | undefined): string | null {
if (!code) return null;
return TEMPERATURE_META[code as TemperatureValue]?.zh ?? code;
}
/** 潜在治疗取值码 → 矩阵上那一行的中文(`implant` → 「种植」) */
export function zhTreatment(code: string | null | undefined): string | null {
if (!code) return null;
return potentialTreatmentCardLabel(code);
}
/** 召回场景取值码 → 中文(`treatment_initiation_recall` → 「潜在治疗」) */
export function zhScenario(code: string | null | undefined): string | null {
if (!code) return null;
return planScenarioLabel(code);
}
/** 档位口径 → 中文(`last_visit` → 「按末诊」) */
export function zhAnchorMode(code: string | null | undefined): string | null {
if (!code) return null;
return ANCHOR_MODE_META[code as AnchorModeValue]?.zh ?? code;
}
// ─────────────────────────────────────────────────────────
// 条件回显
// ─────────────────────────────────────────────────────────
/**
* 把模型传进来的筛选条件原样回一份中文 —— **这是本模块最关键的一个函数**。
*
* 模型必须用码调工具(`temperature: 'hot'`),但回话要说「三个月内」。
* 不给回显,它只能自己翻译;自己翻译就会翻错,或者干脆把码念出来。
* ⇒ 每个吃这些条件的工具,返回值里都带一份 `criteriaZh`。
*
* [弥补模型]
*/
export function criteriaZh(input: {
potentialTreatment?: string | null;
temperature?: string | null;
anchorMode?: string | null;
}): Record<string, string> {
const out: Record<string, string> = {};
const t = zhTreatment(input.potentialTreatment);
const temp = zhTemperature(input.temperature);
const anchor = zhAnchorMode(input.anchorMode);
if (t) out['治疗项'] = t;
if (temp) out['时间档'] = temp;
if (anchor) out['口径'] = anchor;
return out;
}
// ─────────────────────────────────────────────────────────
// 桶名翻译
// ─────────────────────────────────────────────────────────
/**
* 批次处理进度的五个桶 → 中文键。
*
* ⚠️ 这几个词是提示词里被反复叮嘱的重灾区(`resolved` `suppressed` `inHandPending`
* `backToPool`),因为它们既是内部枚举、又必须报给主管看。换成中文键之后,
* 模型照着键名说就是对的。
*
* ⚠️ 中文键刻意写全「已出池」而不是「已完成」—— `resolved` 是**引擎判定召回需求没了**,
* ⛔ 不是「谈成了」。键名本身就该挡住这个误读(T14)。
*
* [弥补模型]
*/
export function zhProgressBuckets(p: {
done?: number;
resolved?: number;
suppressed?: number;
closed?: number;
inHandPending?: number;
backToPool?: number;
reassigned?: number;
ageDays?: number;
}): Record<string, number> {
const out: Record<string, number> = {};
if (p.done !== undefined) out['已处理'] = p.done;
if (p.resolved !== undefined) out['已出池·引擎判定需求已了'] = p.resolved;
if (p.suppressed !== undefined) out['客服已写回访结果'] = p.suppressed;
if (p.closed !== undefined) out['已结案'] = p.closed;
if (p.inHandPending !== undefined) out['还在客服手上没动'] = p.inHandPending;
if (p.backToPool !== undefined) out['退回或到期落回池子'] = p.backToPool;
if (p.reassigned !== undefined) out['已被后续批次挑走'] = p.reassigned;
if (p.ageDays !== undefined) out['本批已跑天数'] = p.ageDays;
return out;
}
/**
* 画像维度里的 `noTag` → 中文键。
*
* ⚠️ 「没有这条记录」而不是「无标签」:`noTag` 是**没有这条画像证据**,
* ⛔ 不是反面(「没有商保标签」≠「自费」)。这是提示词里最长的一条护栏,
* 键名写清楚就少一次误读。
*
* [弥补模型]
*/
export function zhCohortDim<T extends { id: string; nameZh: string; noTag: number; multi: boolean }>(
dim: T,
): Omit<T, 'noTag' | 'multi'> & { 没有这条记录的人数: number; 一人可命中多项: boolean } {
const { noTag, multi, ...rest } = dim;
return { ...rest, 没有这条记录的人数: noTag, 一人可命中多项: multi };
}
...@@ -347,6 +347,19 @@ export function useAssistantChat() { ...@@ -347,6 +347,19 @@ export function useAssistantChat() {
return blocks.map((x, i) => (i === realIdx ? updated : x)); return blocks.map((x, i) => (i === realIdx ? updated : x));
}); });
break; break;
case 'step_limit':
/**
* 🔴 单轮工具步数到顶(服务端 stopWhen 截断)。
*
* ⚠️ 必须让主管看见:模型可能停在「我先查一下」之后就没了下文,
* 而他以为查完了 —— 这跟「说了已撤销但没调工具」是同一类静默失败。
* ⚠️ 只陈述事实 + 给一个明确的下一步,⛔ 不自动续跑(续不续是他的决定)。
*/
appendText(
`\n\n⚠️ **这一轮查到上限了**(${String(evt.limit ?? '')} 步),助手可能还没查完。` +
`若上面的回答不完整,请说一句「接着查」。`,
);
break;
case 'error': case 'error':
appendText(`\n\n⚠️ 出错:${String(evt.error)}`); appendText(`\n\n⚠️ 出错:${String(evt.error)}`);
break; break;
......
# Agent 设计教条
> 给产品与业务读。**一条一句,不解释。**
> 完整论证与图示见 [agent-architecture.md](./agent-architecture.md)。
| | |
|---|---|
| **性质** | 通用设计原则,与具体业务无关 |
| **用法** | 评审时逐条对照;争议时引用编号 |
| **评级** | ★★★ 地基(不这样做必出问题,事后修改等于重做)· ★★ 必做(会出问题,但可后补)· ★ 看信号(有明确信号再做,提前做是浪费) |
---
## 只记五条
1. **能写成规则的交程序,写不出规则的才交模型。**
2. **模型报的每个数字都要指得到来源。**
3. **不可逆的动作必须有人点头——因为必须有人负责。**
4. **让人审四百条,等于没人在审。**
5. **提示词是最弱的手段,永远最后用。**
---
## A · 人、模型、程序的分工
| 级 | | 教条 |
|---|---|---|
| ★★★ | **A1** | 能写成规则的交程序,写不出规则的才交模型。判据是「能不能写成规则」,不是「谁能做」。 |
| ★★★ | **A2** | 模型报出的每个数字,都必须能指到**本轮**某次工具返回。⛔ 不许从上文抄、不许估、不许自己算派生量(百分比、差值、交叉人数)。<br>⚠️ 判据是「**数字要有锚**」,不是「不给数字」——模型的算力在变强,但「引用了没有来源的数」和「跨轮串用旧条件下的数」跟算力无关。 |
| ★★★ | **A3** | 不可逆的动作必须由人确认。理由不是模型会错,是必须有人负责。 |
| ★★★ | **A4** | 责任不可委托。「AI 决定的」不是一个解释。 |
| ★★ | **A5** | 明细直接呈现给人,不经过模型。 |
| ★★ | **A6** | 让人审四百条等于没人在审。每一步都要收敛规模,落到人面前的判断压到十位以内。 |
| ★★ | **A7** | 人的注意力是三方中最稀缺的资源,只花在只有人能做的判断上。 |
| ★ | **A8** | 模型变强只移动「模型与人」的分界,不移动「模型与程序」的分界。 |
## B · 模型的自主
| 级 | | 教条 |
|---|---|---|
| ★★★ | **B1** | 主线是给能力,不是加限制。不要用固定流程替换模型的判断。 |
| ★★ | **B2** | 「我不知道」必须是一个合法答案。不确定就停下来问,不要猜。 |
| ★★ | **B3** | 给模型验证手段,比给它规则有效。 |
| ★ | **B4** | 不要按阶段藏工具——走错一步之后它就无法自己纠回来。 |
## C · 可靠性
| 级 | | 教条 |
|---|---|---|
| ★★★ | **C1** | 防错分三级:让错误不可能 ≫ 让错误可见可撤 ≫ 事后校验。只能靠第三级实现的,多半是上游设计有问题。 |
| ★★★ | **C2** | 全程留痕、可回放。没有它,之后所有排查都是盲的。 |
| ★★★ | **C3** | 每条防错措施都要标明是「弥补模型」还是「业务要求」。前者随模型变强要拆掉,后者永远保留。 |
| ★★ | **C4** | 提示词是最弱的手段,永远最后用;而且规则越多,每条被遵守的概率越低。 |
| ★★ | **C5** | 没有回归测试,质量就是随机游走。判定看它做了哪些动作,不看它说了什么话。 |
| ★★ | **C6** | 批量动作必须可整体撤销。 |
## D · 身份与权限
| 级 | | 教条 |
|---|---|---|
| ★★★ | **D1** | 身份由登录决定,不由模型或提示词决定。提示词从来不是安全边界。 |
| ★★★ | **D2** | 权限不同的角色不能共用一个会话。上下文是单向的,进去的数据不会因为一句话就消失。 |
| ★★★ | **D3** | 模型不传就不会越权的参数,不要让它传。 |
| ★★ | **D4** | 角色不同,能看到的工具也要不同。看不见比看见了被拒更安全。 |
| ★ | **D5** | 同一套实现按身份参数化即可,不必做成两个 agent。 |
## E · 给模型看什么
| 级 | | 教条 |
|---|---|---|
| ★★★ | **E1** | **按量级决定给原文还是给摘要**:几十条给原文(摘要是有损的,损失的往往正是后面要追问的那部分);几百条以上只给数量 + 入口;**量级未知时先取数量,再决定取不取**。 |
| ★★★ | **E2** | 给了摘要就必须给「怎么取到明细」,两者缺一不可。 |
| ★★ | **E9** | 多轮对话必须带上完整会话脉络,**但要分层带**:工具调用带「名字+关键参数+结果摘要」,大载荷只带 id + 一句话,失败只带错因。 |
| ★★ | **E3** | 任务状态存在库里,不存在对话里。对话只是当轮的工作台。 |
| ★★ | **E4** | 脏数据在源头就别产生,而不是事后清理。 |
| ★★ | **E5** | 对话历史只追加,不修改、不重排——这直接决定响应速度和成本。 |
| ★★ | **E6** | 固定不变的内容放前面,每次都变的放后面。当前时间这类东西绝不能写进固定部分。 |
| ★ | **E7** | 同一份数据换个呈现形式,模型的准确度会变;这件事几乎零成本,值得试。 |
| ★ | **E8** | 材料太多时,派一个只读助手去看,只把结论带回来。 |
## F · 工具
| 级 | | 教条 |
|---|---|---|
| ★★★ | **F1** | 工具的粒度等于用户会说的一句话。 |
| ★★★ | **F2** | 每个选项列表都要给全,并永远留一个「不确定」。缺一个值,模型必然填错,而且不会报错。 |
| ★★ | **F3** | 规则写在工具说明里,比写在提示词里管用。 |
| ★★ | **F4** | 错误提示要写成模型能据此改正的话。 |
| ★★ | **F5** | 每加一个会改数据的工具,先回答「它怎么知道自己做对了」。 |
| ★★ | **F6** | 写操作必须可重复执行而结果不变——模型会重试。 |
| ★★ | **F7** | 界面要的数据和模型要的数据分开返回。 |
| ★★ | **F8** | **高频做专用,长尾留一个受控的兜底工具。** 专用工具的名字就是意图,选错的空间小;没有兜底则未预见的需求只能回「做不到」。兜底的参数必须是枚举化的维度,⛔ 不能是自由查询语句。 |
| ★★ | **F9** | 每个列表类工具都要有「只回数量」的轻量对应物 —— 先问有多少,再决定取不取。 |
| ★ | **F10** | 只在特定场景才需要的解读规范,随该工具的**返回值**下发(`_guide`),⛔ 别常驻在工具说明或提示词里。这是按需加载最便宜的形态:零往返、必然到达。 |
## G · 表达与引导
| 级 | | 教条 |
|---|---|---|
| ★★ | **G1** | 可选的下一步由程序算出,模型只负责挑选和措辞。 |
| ★★ | **G2** | 模型不得建议系统做不到的事。 |
| ★★ | **G3** | 没有实质下一步时,不要凑一句建议。 |
| ★★ | **G4** | 预设默认值是在替人做决定,归程序管,且必须偏安全一侧 —— **默认永远是「不动」**。 |
| ★★ | **G5** | **布局可以交给模型,数据不行。** 模型写结构 / 样式 / 图表类型和占位符,真值在渲染时注入。⛔ 别让它把数据逐字抄进产物。 |
| ★★★ | **G6** | 给模型**结构化事实**,不要给成品句子;措辞由它组织,写作规范集中写一处。<br>⚠️ 成品句子有固定形状,模型学会形状就能凭空背出来(而且看不出真假);结构化事实编不出来。⛔ 「照抄」也浪费了模型唯一不可替代的能力。 |
| ★★ | **G7** | 强引导的节点由程序确定性判定,⛔ 不由模型判断哪条该提。 |
| ★★ | **G8** | 引导节点**只陈述事实,不给建议**;选项中性并列;不阻塞用户直接完成。 |
| ★★ | **G9** | **有实际后果的节点必须全亮,⛔ 不许截断。** 防噪音靠**分层**(需处置的展开、纯信息的折叠),不靠丢弃 —— 藏起一条,用户就不知道有东西卡着。 |
| ★★ | **G10** | 引导选项做成**可直接点击执行的动作**,⛔ 不是让人照着打字;自由输入只作兜底。<br>⚠️ 打字 → 模型理解 → 翻译成动作,这条链每一环都可能错;点击是确定性的。两条路必须收敛到**同一个动作契约**。 |
| ★★ | **G11** | 「下一步做什么」必须是**确定性可答**的:当前状态 + 未处置的节点 + 默认路径,⛔ 不由模型即兴发挥。由此推出的下一步永远不会指向系统做不到的事。 |
## H · 要不要上
| 级 | | 教条 |
|---|---|---|
| ★★★ | **H1** | 任何手段进门前三问:解决什么问题、这个问题现在有多严重(有数据吗)、有没有更便宜的解法。答不出第二问就是在赶时髦。 |
| ★★ | **H2** | 清单是用来过一遍的,不是用来全打勾的。不做可以,但要知道自己没做。 |
| ★★ | **H3** | 为从未被测量过的问题写防御,就是过度设计。 |
| ★ | **H4** | 结构化的数据不要用语义检索——查询语言本身就是检索。 |
| ★ | **H5** | 「记不下」通常说明缺一张表。先建表,别做记忆。 |
| ★ | **H6** | 不要让模型自己积累经验并固化成规则——没法调试,出问题查不出是哪一条学坏的。 |
| ★ | **H7** | 只在出现第二个入口、或已列入能力开放计划时,才把工具做成通用封装。 |
---
## 一页速查
| 主题 | ★★★ |
|---|---|
| **分工** | A1 规则归程序 · A2 数字要有锚 · A3 不可逆动作过人 · A4 责任不可委托 |
| **自主** | B1 给能力而非加限制 |
| **可靠** | C1 防错三级 · C2 全程留痕 · C3 标明措施归属 |
| **权限** | D1 身份由登录定 · D2 角色不共用会话 · D3 越权参数不给模型 |
| **输入** | E1 按量级给原文/摘要 · E2 摘要配指针 |
| **工具** | F1 粒度=一句话 · F2 选项给全留兜底 |
| **表达** | G6 给事实不给成品句子 |
| **取舍** | H1 进门三问 |
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment