Commit 50f0dec7 by luoqi

feat(助手): P5 explain_assignment —— 「为什么这个人给了张悦」改成查, 不让模型推理

在此之前模型没有这个工具,被问到只能**推理** —— 而推理出来的理由听起来完全合理,
主管照着去调策略就是白跑一趟。这是 F5(每加一个写工具,先回答"它怎么知道自己做对了")
在读侧的对应物。

- 依据全部来自 followup_plans 上那组**分配当时的决策快照**列
  (assign_strategy / dedicated_cs_at_assign / dedicated_cs_last_visit_at /
  priority_score_at_assign / selection_mode)—— schema 注释写的正是
  「为了半年后回答'当初为什么选了这个人'」。
   不拿"现在"的值解释"当时"的决定:专属客服会被摄入覆盖、优先级分会被引擎
  就地改分,两者都不留痕。
- 🔴 查不到就**如实说查不到**, 不返回空壳 —— 空壳会让模型顺着编。有测试锁。
- 「专属排满溢出」与「从来没有专属」必须分得开:主管据此做的事完全不同
  (前者是要不要挪回专属,后者是这人根本没人管)。
- dedicated_cs_last_visit_at 回**时间戳不回结论**:「在岗」是滚动窗口算的,
  今天在岗的人半年后回查会变成离岗,给证据让主管按当时口径自己判断。
- 只回**已落库**的分配;草稿阶段的「为什么」由卡片解释(明细在它手里,不在模型手里)。

dev-plan 标了 P5 各项真实状态:21 validate 暂缓(草稿不落库,服务端拿不到它);
23 count 模式已有一半;22/24 未做。

1242 passed(新增 5 条);type-check(含 tests)干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 156a7cff
......@@ -450,6 +450,29 @@ export class McpServerFactory {
),
);
/**
* 🔴 **「为什么这个人给了张悦」** —— F5 的兑现。
*
* 在此之前模型没有这个工具,被问到只能**推理**,而推理出来的理由听起来
* 完全合理,主管照着去调策略就是白跑一趟。⇒ 唯一诚实的回答方式是查。
*/
server.registerTool(
'explain_assignment',
{
description:
'回答「这个患者**为什么**分给了他」—— 从分配当时的决策快照查,⛔ 不要自己推理。' +
'\n⚠️ 只回**已经确认落库**的分配;还在确认单上的那批,明细在卡片里,主管自己看得到。' +
'\n⚠️ 回的是**分配当时**的情况(专属客服会被摄入覆盖、优先级分会被引擎就地改分,' +
'两者都不留痕)—— ⛔ 别拿"现在"的值去解释"当时"的决定。' +
'\n先用 find_patient 拿到 patientId。',
inputSchema: { patientId: z.string() },
},
async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId);
return jsonResult(await this.assignments.explainAssignment(scope, patientId));
},
);
server.registerTool(
'get_assignment_detail',
{
......
......@@ -8,6 +8,7 @@ import {
import { Prisma } from '@prisma/client';
import {
ABANDON_REASON_META,
AssignStrategy,
type AbandonReason,
ASSIGNMENT_ITEMS_HARD_LIMIT,
EXECUTION_OUTCOME_META,
......@@ -1906,6 +1907,78 @@ export class PlanAssignmentService {
const tz = (host?.pullConfig as { timezone?: string } | null)?.timezone;
return tz ?? 'Asia/Shanghai';
}
/**
* 「**为什么这个人给了张悦**」—— 从分配当时的决策快照回答,⛔ 不让模型编。
*
* ⭐ 这是 F5 的兑现:模型对已落库的分配唯一诚实的回答方式,就是**查**。
* 在此之前它没有这个工具,被问到只能推理 —— 而推理出来的理由听起来完全合理,
* 主管照着去调策略就是白跑一趟。
*
* ⚠️ 答案**全部来自 followup_plans 上那几列分配当时的快照**
* (assign_strategy / dedicated_cs_at_assign / dedicated_cs_last_visit_at /
* priority_score_at_assign / selection_mode),⛔ 不拿"现在"的值去解释"当时"的决定:
* 专属客服会被摄入覆盖、priority_score 会被引擎就地改分,两者都不留痕。
* ⚠️ 只回**已落库**的分配。草稿阶段的「为什么」由卡片自己解释(明细在它手里,不在模型手里)。
*/
async explainAssignment(
scope: TenantScopeContext,
patientId: string,
): Promise<Record<string, unknown>> {
const plan = await this.prisma.followupPlan.findFirst({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
assignmentId: { not: null },
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
},
orderBy: { updatedAt: 'desc' },
select: {
assigneeUserId: true,
assignStrategy: true,
dedicatedCsAtAssign: true,
dedicatedCsLastVisitAt: true,
priorityScoreAtAssign: true,
selectionMode: true,
assignmentId: true,
status: true,
},
});
if (!plan) {
// ⚠️ 如实说没有,⛔ 别返回一个空壳让模型顺着编
return { 结论: '这位患者没有已落库的分配记录(可能还在草稿里,或从未被分过)' };
}
const strategy = plan.assignStrategy ?? null;
return {
分给了谁: plan.assigneeUserId,
怎么落到他头上的:
strategy === AssignStrategy.DEDICATED
? '他本来就是这位患者的专属客服'
: strategy === AssignStrategy.SPREAD_NO_DEDICATED
? '这位患者当时没有可用的专属客服,就给了手上最空的人'
: strategy === AssignStrategy.SPREAD_OVERFLOW
? '他有专属客服,但那位当时已经排满,所以给了手上最空的人'
: strategy === AssignStrategy.MANUAL
? '主管在确认单上手工指定的'
: '没有记录(分配当时这一列还没开始记)',
分配当时的专属客服: plan.dedicatedCsAtAssign ?? '当时没有专属客服',
// ⚠️ 存的是**证据不是结论**:「在岗」是"近 N 月有回访"这个滚动窗口算出来的,
// 今天在岗的人半年后回查会变成离岗。给时间戳,让主管自己按当时口径判断。
那位专属最近一次回访: plan.dedicatedCsLastVisitAt?.toISOString() ?? '查不到回访记录',
分配当时的优先级分: plan.priorityScoreAtAssign ?? '没有记录',
怎么被选进这批的:
plan.selectionMode === 'explore'
? '随机探索配额(不是按分选的)'
: plan.selectionMode === 'swap'
? '重排一版时顶替进来的'
: plan.selectionMode === 'rank'
? '按排序正常入选'
: '没有记录',
属于哪一批: plan.assignmentId,
当前状态: plan.status,
};
}
}
function summarizeSkipped(skipped: AssignmentSkipped[]): string {
......@@ -1961,4 +2034,5 @@ export function endOfDayInHostTimezone(base: Date, days: number, timezone: strin
const mm = String(target.getUTCMonth() + 1).padStart(2, '0');
const dd = String(target.getUTCDate()).padStart(2, '0');
return new Date(`${yyyy}-${mm}-${dd}T23:59:59.999${suffix === 'Z' ? 'Z' : suffix}`);
}
import { AssignStrategy } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import type { PrismaService } from '../src/prisma/prisma.service';
import type { AgentRosterService } from '../src/modules/plan/agent-roster.service';
import type { TenantScopeContext } from '../src/common/decorators/tenant-scope.decorator';
/**
* 「为什么这个人给了张悦」——**从分配当时的决策快照回答**。
*
* 🔴 这个工具存在的理由:在此之前模型被问到只能**推理**,而推理出来的理由
* 听起来完全合理,主管照着去调策略就是白跑一趟。
* ⇒ 所以这里锁的核心不变式是:**查不到就如实说查不到,⛔ 不给一个能顺着编的空壳。**
*/
const SCOPE: TenantScopeContext = {
hostId: 'h',
tenantId: 't',
clinicIds: ['c1'],
sourceUnits: [],
userId: 'u',
};
function svc(plan: unknown) {
const prisma = {
followupPlan: { findFirst: jest.fn(async () => plan) },
} as unknown as PrismaService;
return new PlanAssignmentService(prisma, {} as AgentRosterService);
}
describe('explainAssignment', () => {
test('🔴 查不到就如实说,⛔ 不返回空壳让模型顺着编', async () => {
const r = await svc(null).explainAssignment(SCOPE, 'p1');
expect(String(r['结论'])).toContain('没有已落库的分配记录');
// ⛔ 一个能被当成"理由"的字段都不许有
expect(r['怎么落到他头上的']).toBeUndefined();
expect(r['分给了谁']).toBeUndefined();
});
test('⭐ 专属命中 → 说清是他本来就是专属客服', async () => {
const r = await svc({
assigneeUserId: 'a1',
assignStrategy: AssignStrategy.DEDICATED,
dedicatedCsAtAssign: 'a1',
dedicatedCsLastVisitAt: new Date('2026-07-01T00:00:00Z'),
priorityScoreAtAssign: 82,
selectionMode: 'rank',
assignmentId: 'b1',
status: 'assigned',
}).explainAssignment(SCOPE, 'p1');
expect(r['分给了谁']).toBe('a1');
expect(String(r['怎么落到他头上的'])).toContain('本来就是');
expect(r['属于哪一批']).toBe('b1');
});
test('🔴 专属排满溢出 与 从来没有专属 —— 两种说法必须分得开', async () => {
const overflow = await svc({
assigneeUserId: 'a2',
assignStrategy: AssignStrategy.SPREAD_OVERFLOW,
dedicatedCsAtAssign: 'a1',
dedicatedCsLastVisitAt: null,
priorityScoreAtAssign: null,
selectionMode: 'rank',
assignmentId: 'b1',
status: 'assigned',
}).explainAssignment(SCOPE, 'p1');
const none = await svc({
assigneeUserId: 'a2',
assignStrategy: AssignStrategy.SPREAD_NO_DEDICATED,
dedicatedCsAtAssign: null,
dedicatedCsLastVisitAt: null,
priorityScoreAtAssign: null,
selectionMode: 'rank',
assignmentId: 'b1',
status: 'assigned',
}).explainAssignment(SCOPE, 'p1');
// ⚠️ 主管据此做的事完全不同:前者要不要挪回专属;后者是这人根本没人管
expect(String(overflow['怎么落到他头上的'])).toContain('已经排满');
expect(String(none['怎么落到他头上的'])).toContain('没有可用的专属客服');
expect(overflow['分配当时的专属客服']).toBe('a1');
expect(String(none['分配当时的专属客服'])).toContain('当时没有');
});
test('⭐ 探索配额选进来的要说明白 —— 它**不是**按分选的', async () => {
const r = await svc({
assigneeUserId: 'a1',
assignStrategy: AssignStrategy.MANUAL,
dedicatedCsAtAssign: null,
dedicatedCsLastVisitAt: null,
priorityScoreAtAssign: 12,
selectionMode: 'explore',
assignmentId: 'b1',
status: 'assigned',
}).explainAssignment(SCOPE, 'p1');
expect(String(r['怎么被选进这批的'])).toContain('不是按分选的');
expect(String(r['怎么落到他头上的'])).toContain('手工指定');
});
test('🔴 快照缺列时说「没有记录」,⛔ 不许拿默认值冒充事实', async () => {
const r = await svc({
assigneeUserId: 'a1',
assignStrategy: null,
dedicatedCsAtAssign: null,
dedicatedCsLastVisitAt: null,
priorityScoreAtAssign: null,
selectionMode: null,
assignmentId: 'b1',
status: 'assigned',
}).explainAssignment(SCOPE, 'p1');
expect(String(r['怎么落到他头上的'])).toContain('没有记录');
expect(String(r['分配当时的优先级分'])).toContain('没有记录');
expect(String(r['怎么被选进这批的'])).toContain('没有记录');
});
});
......@@ -81,13 +81,19 @@
### P5 · 工具补齐
| # | 改什么 | 依据 |
|---|---|---|
| 20 | `explain(planId)` —— 「为什么这个人给了张悦」 | F5,现在模型只能编 |
| 21 | `validate(draft)` + read-back | F5 |
| 22 | 兜底 `query_cohort(枚举化 filters)` | F8 |
| 23 | 各 list 工具补 count 模式 | F9 |
| 24 | 测 `get_persona`/`get_facts`/`get_recall_plan` 使用率 → 可能折成 `overview(sections)` | 同质化 |
| # | 改什么 | 依据 | 状态 |
|---|---|---|---|
| 20 | `explain_assignment(patientId)` —— 「为什么这个人给了张悦」 | F5 | ✅ **已落** |
| 21 | `validate(draft)` | F5 | ⏸ **暂缓** —— 草稿不落库(§七 4),服务端拿不到它;卡片已在本地校验。等草稿真要落库时再做 |
| 22 | 兜底 `query_cohort(枚举化 filters)` | F8 | ⬜ 未做 |
| 23 | 各 list 工具补 count 模式 | F9 | 🟡 **已有一半** —— `recall_queue_stats` / `list_recall_queue` 就是这个模式;缺的是把它变成通例 |
| 24 | 测 `get_persona`/`get_facts`/`get_recall_plan` 使用率 → 可能折成 `overview(sections)` | 同质化 | ⬜ 未做(要先有线上调用统计) |
> ⭐ **20 的依据全在库里**:`followup_plans` 上有一组「分配当时的决策快照」列
> (`assign_strategy` / `dedicated_cs_at_assign` / `dedicated_cs_last_visit_at` /
> `priority_score_at_assign` / `selection_mode`),schema 注释写的正是
> 「为了半年后回答'当初为什么选了这个人'」。⛔ 不拿"现在"的值去解释"当时"的决定 ——
> 专属客服会被摄入覆盖、优先级分会被引擎就地改分,两者都不留痕。
### P6 · 减法与评测(**必须最后**)
......
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