Commit cb56c1fd by luoqi

feat(plan): S2.4 画像圈人 —— get_cohort_attributes + 人群取数收口

T9-B「调整阶段画像圈人」落地。主管说「只要商保直付的」「排掉怕疼的」时,
助手先看这批人里各口子多少人,再据实回话,然后带 personaTags 重出确认单。

T10 是真的成立而不只是口号:维度全集直接取 PERSONA_TAG_FILTER_DIMS
(与列表页筛选面板、list_recall_queue 的 personaTags 同一张表)——
以后加权益身份/治疗史/家庭结构是往那张表加一行的事,本工具一个字都不用改。
教条五要合并的 getDedicatedCs + getPersonaFeatures 就是这一个口。

 人群取数抽成共用的 cohort-filter.ts,出确认单与看分布**同一份 SQL**:
各写一遍必然出现「分布说商保 32 人、提案只有 28 人」,主管当场不信且分不清哪边错。
顺带把温度(矩阵 Y 轴)与 personaTags 接进 propose_assignment —— 在此之前
主管的收窄条件没有任何通路能流进确认单。

 noTag(没有这条画像证据)≠ 反面:
「32 人有商保标签」剩下的**不是自费**,是院内没留痕。模型极容易说成「其余 68 人自费」,
那是凭空造事实(T14),而主管会拿它去定价。返回结构单列 noTag + 提示词写死,双保险。

 隐藏维度可点名但默认不给:教条举的「排掉怕疼的」正落在 treatment_sensitivity
(hidden:true)上 —— hidden 语义是「面板不展示」不是「不能用」;
但默认推 16 个维度,模型会挑个不相干的开始发挥。

 temperatureUnknown:上线到全量重算跑完之间,老画像没有窗口边界,
三档之和会少于该行总数。把差额报出来(而不是塞进「冷」把数字凑上)——
凑上是假分布,报出来主管知道那是重算进度。

本地真实数据验证(5,825 患者 / 2,724 池子):
  三档求和对数    44+25+304 = 373 = 潜在种植全部 373  
  口径对数        分布 22 → 收窄 22 → 提案 candidateTotal 22  
  隐藏维度点名    treatment_sensitivity → 看牙恐惧 2 人  
  耗时           23-96ms

937 tests passing。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0df27893
...@@ -27,7 +27,7 @@ const DISPATCHER_EXTRA = ` ...@@ -27,7 +27,7 @@ const DISPATCHER_EXTRA = `
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调) 5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 条硬约束(违反任何一条都会造成真实损失) ### 条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。 1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是 ⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
...@@ -63,6 +63,17 @@ const DISPATCHER_EXTRA = ` ...@@ -63,6 +63,17 @@ const DISPATCHER_EXTRA = `
⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 —— ⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 ——
没有证据的不许写进结论,**界面元素也算证据**。 没有证据的不许写进结论,**界面元素也算证据**。
8. **画像收窄要先看分布,再动手。**
主管说「只要商保直付的」「排掉怕疼的」时:
① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句
→ ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,
主管白等一轮。
⚠️⚠️ **noTag 是「没有这条画像证据」,不是反面。**
「32 人有商保标签」剩下的 **不是自费**,是**没证据**。
⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。
正确说法:「32 人有商保直付记录,其余 68 人**没有这条记录**(不代表没有,只是院内没留痕)」。
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他 - **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服, - **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服,
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Permission } from '@pac/types'; import { Permission, type TemperatureValue } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service'; import { AssignmentProposalService } from '../plan/assignment-proposal.service';
...@@ -130,6 +130,17 @@ export class AssistantService { ...@@ -130,6 +130,17 @@ export class AssistantService {
description: description:
'画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction', '画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction',
}, },
temperature: {
type: 'string',
enum: ['hot', 'warm', 'cold'],
description: '窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给',
},
personaTags: {
type: 'string',
description:
'主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。',
},
targetCount: { type: 'number', description: '拟分人数(不传用默认批次规模)' }, targetCount: { type: 'number', description: '拟分人数(不传用默认批次规模)' },
exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' }, exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
}, },
...@@ -138,6 +149,8 @@ export class AssistantService { ...@@ -138,6 +149,8 @@ export class AssistantService {
const a = (args ?? {}) as { const a = (args ?? {}) as {
clinicId?: string; clinicId?: string;
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue;
personaTags?: string;
targetCount?: number; targetCount?: number;
exploreRatio?: number; exploreRatio?: number;
}; };
......
...@@ -10,6 +10,7 @@ import { PlanService } from '../plan/plan.service'; ...@@ -10,6 +10,7 @@ import { PlanService } from '../plan/plan.service';
import { AgentRosterService } from '../plan/agent-roster.service'; import { AgentRosterService } from '../plan/agent-roster.service';
import { PlanAssignmentService } from '../plan/plan-assignment.service'; import { PlanAssignmentService } from '../plan/plan-assignment.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service'; import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attributes.service';
import type { ListPlansQueryDto } from '../plan/dto/plan.dto'; import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { McpAuthContext } from './mcp-auth.service'; import type { McpAuthContext } from './mcp-auth.service';
...@@ -81,6 +82,7 @@ export class McpServerFactory { ...@@ -81,6 +82,7 @@ export class McpServerFactory {
private readonly roster: AgentRosterService, private readonly roster: AgentRosterService,
private readonly assignments: PlanAssignmentService, private readonly assignments: PlanAssignmentService,
private readonly proposals: AssignmentProposalService, private readonly proposals: AssignmentProposalService,
private readonly cohorts: CohortAttributesService,
) {} ) {}
build(ctx: McpAuthContext): McpServer { build(ctx: McpAuthContext): McpServer {
...@@ -314,6 +316,46 @@ export class McpServerFactory { ...@@ -314,6 +316,46 @@ export class McpServerFactory {
jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })), jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })),
); );
server.registerTool(
'get_cohort_attributes',
{
description:
'看**当前这批人**的画像分布 —— 主管在调整阶段说「只要商保直付的」「排掉怕疼的」时,' +
'先用它拿到「哪些口子可切、各多少人」,再据实回话,然后用同样的条件重出确认单。' +
'\n⚠️ **`noTag` 是「没有这条画像证据」的人数,不是反面** —— ' +
'「32 人有商保标签」剩下的**不是自费**,是没证据。⛔ 绝不能说成「其余 68 人自费」。' +
'\n⚠️ 标了 `multi` 的维度一个人可命中多项,合计会大于 cohortSize,**别拿它算百分比**。' +
'\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以主管说的为准,别替他挡人。' +
`\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`,
inputSchema: {
clinicId: z.string(),
potentialTreatment: z
.string()
.optional()
.describe('矩阵 X 轴:implant / ortho / early_ortho / endo / perio / filling / restoration / extraction'),
temperature: z
.enum(['hot', 'warm', 'cold'])
.optional()
.describe('矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给'),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'),
},
},
async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) =>
jsonResult(
await this.cohorts.describe(
scope,
{
clinicId,
...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature } : {}),
...(personaTags ? { personaTags } : {}),
},
{ ...(keys ? { keys } : {}) },
),
),
);
// ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。 // ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。
// 原因:确认单载荷含**几百个 planId**,MCP 工具的返回值会原样回灌进模型上下文 // 原因:确认单载荷含**几百个 planId**,MCP 工具的返回值会原样回灌进模型上下文
// (实测一批 850 人 ≈ 12k token),而模型对它唯一要做的事是"转述一句话"。 // (实测一批 850 人 ≈ 12k token),而模型对它唯一要做的事是"转述一句话"。
......
...@@ -7,10 +7,12 @@ import { ...@@ -7,10 +7,12 @@ import {
type AgentInfo, type AgentInfo,
type AssignmentProposal, type AssignmentProposal,
type ProposedItem, type ProposedItem,
type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service'; import { AgentRosterService } from './agent-roster.service';
import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter';
/** /**
* AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。 * AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
...@@ -75,13 +77,25 @@ export class AssignmentProposalService { ...@@ -75,13 +77,25 @@ export class AssignmentProposalService {
clinicId: string; clinicId: string;
/** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */ /** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
potentialTreatment?: string; potentialTreatment?: string;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
temperature?: TemperatureValue;
/** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
personaTags?: string;
/** 拟分人数;不传 = 按在岗容量总量估 */ /** 拟分人数;不传 = 按在岗容量总量估 */
targetCount?: number; targetCount?: number;
/** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */ /** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
exploreRatio?: number; exploreRatio?: number;
}, },
now: Date = new Date(),
): Promise<AssignmentProposal> { ): Promise<AssignmentProposal> {
const { clinicId, potentialTreatment } = input; const { clinicId, potentialTreatment } = input;
const criteria: CohortCriteria = {
clinicId,
...(potentialTreatment ? { potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}),
...(input.personaTags ? { personaTags: input.personaTags } : {}),
};
assertCohortCriteria(criteria);
const roster = await this.roster.list(scope, clinicId); const roster = await this.roster.list(scope, clinicId);
const agents = roster.agents; const agents = roster.agents;
...@@ -106,7 +120,7 @@ export class AssignmentProposalService { ...@@ -106,7 +120,7 @@ export class AssignmentProposalService {
// ── ① 收敛:按三层键取前 N ──────────────────────────────── // ── ① 收敛:按三层键取前 N ────────────────────────────────
// ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。 // ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。
const pool = await this.selectCandidates(scope, clinicId, potentialTreatment, Math.ceil(target * 1.5) + 20); const pool = await this.selectCandidates(scope, criteria, Math.ceil(target * 1.5) + 20, now);
// 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用) // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>(); const seenPatient = new Set<string>();
...@@ -196,50 +210,18 @@ export class AssignmentProposalService { ...@@ -196,50 +210,18 @@ export class AssignmentProposalService {
*/ */
private async selectCandidates( private async selectCandidates(
scope: TenantScopeContext, scope: TenantScopeContext,
clinicId: string, criteria: CohortCriteria,
potentialTreatment: string | undefined,
limit: number, limit: number,
now: Date,
): Promise<Array<{ planId: string; patientId: string; priorityScore: number }>> { ): Promise<Array<{ planId: string; patientId: string; priorityScore: number }>> {
const brandFilter = scope.sourceUnits.length // ⭐ 人群条件走**共用**片段(cohort-filter),与 get_cohort_attributes 出的分布同源。
? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})` // 两处各写一遍就会出现「属性分布说商保 32 人,提案只有 28 人」,主管当场不信。
: Prisma.empty;
// 潜在治疗:画像特征 data->'types' 是字符串数组,用 @> 命中(与 persona-tag-filters 同口径)
//
// 🔴 **必须按 `pe.patient_id = fp.patient_id` 关联,⛔ 不能按 `pe.id = fp.persona_id`**。
// `followup_plans.persona_id` 指向**建 plan 那一刻的画像版本**;画像一升版本,
// 那一行就被 supersede,而 plan 的外键**不会跟着改**。于是
// `pe.id = fp.persona_id AND pe.superseded_at IS NULL` 会在下一次重算后**恒为假**,
// 整个筛选静默变成 0 条 —— 不报错、不告警,主管只看到"这个格子没人了"。
// 2026-08-02 本地实测:一次全量重算后,2,724 条池子里只剩 97 条还指向活着的画像版本;
// 同一个「潜在种植」条件,按 persona_id 关联 **0 条**,按 patient_id 关联 **376 条**。
// 列表页(`plan.service.buildListWhere` 的 `patient.personas.some`)一直是按患者取当前版的,
// 两处不一致正是 T6a「同源保证」要防的「矩阵上有人、点进去列表没有」。
const treatmentFilter = potentialTreatment
? Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
WHERE pe.patient_id = fp.patient_id
AND pe.superseded_at IS NULL
AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
)`
: Prisma.empty;
return this.prisma.$queryRaw<Array<{ planId: string; patientId: string; priorityScore: number }>>( return this.prisma.$queryRaw<Array<{ planId: string; patientId: string; priorityScore: number }>>(
Prisma.sql` Prisma.sql`
SELECT fp.id AS "planId", fp.patient_id AS "patientId", fp.priority_score AS "priorityScore" SELECT fp.id AS "planId", fp.patient_id AS "patientId", fp.priority_score AS "priorityScore"
FROM followup_plans fp FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id JOIN patients p ON p.id = fp.patient_id
WHERE fp.host_id = ${scope.hostId}::uuid WHERE ${cohortWhereSql(scope, criteria, now)}
AND fp.tenant_id = ${scope.tenantId}
AND fp.target_clinic_id = ${clinicId}
AND fp.status = 'active'
AND fp.assignee_user_id IS NULL
AND fp.superseded_at IS NULL
-- 冷静期/约定回访未到 → 不该进池,更不该被分
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= NOW())
${brandFilter}
${treatmentFilter}
${SELECTION_ORDER} ${SELECTION_ORDER}
LIMIT ${limit} LIMIT ${limit}
`, `,
......
import { Prisma } from '@prisma/client';
import {
PERSONA_TAG_FILTER_DIMS,
Temperature,
parsePersonaTags,
personaTagDimId,
visitRecencyRange,
type TemperatureValue,
} from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/**
* 「这批人是谁」的**唯一**取数口径 —— 出确认单(assignment-proposal)与看属性分布
* (cohort-attributes)共用同一份 SQL 片段。
*
* ⚠️⚠️ **为什么必须共用**:两处各写一遍,就会出现「属性分布说商保直付 32 人,
* 提案出来只有 28 人」。主管当场就不信了,而且分不清是哪一边错。
* 这正是 T6a「同源保证」和验证策略里「口径对数」那一条要防的事。
*
* ⚠️ 与列表页(`plan.service.buildListWhere`,Prisma 形态)是**两套实现、一套口径**:
* 列表页要分页/排序走 Prisma,这里要表达 `jsonb_array_elements` + 时刻比较走原生 SQL,
* Prisma 表达不了后者。两边的等价性靠 `tests/cohort-filter.spec.ts` + 本地实测对数守住。
*/
export interface CohortCriteria {
/** 批次不跨诊所 */
clinicId: string;
/** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
potentialTreatment?: string;
/** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
temperature?: TemperatureValue;
/** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
personaTags?: string;
}
/**
* 温度必须挂在某个治疗项上 —— 「热」本身不是患者属性。
* 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的「热」是无意义的。
* ⛔ 不要"贴心地"给个默认治疗项,那会让主管拿到一批他没要的人。
*/
export function assertCohortCriteria(c: CohortCriteria): void {
if (c.temperature && !c.potentialTreatment) {
throw new Error('温度必须与潜在治疗项一起给 —— 同一个人可能对种植是热、对补牙是冷。');
}
}
/** 池子基线:未被认领、未 supersede、冷静期已过的活跃 plan(与列表页 view=pool 同义) */
export function poolBaseSql(scope: TenantScopeContext, clinicId: string): Prisma.Sql {
return Prisma.sql`
fp.host_id = ${scope.hostId}::uuid
AND fp.tenant_id = ${scope.tenantId}
AND fp.target_clinic_id = ${clinicId}
AND fp.status = 'active'
AND fp.assignee_user_id IS NULL
AND fp.superseded_at IS NULL
-- 冷静期/约定回访未到 → 不该进池,更不该被分
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= NOW())
${scope.sourceUnits.length ? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})` : Prisma.empty}
`;
}
/**
* 画像必须按**患者**取当前版。
*
* 🔴 ⛔ 绝不能写成 `pe.id = fp.persona_id AND pe.superseded_at IS NULL`:
* `followup_plans.persona_id` 指向建 plan 那一刻的画像版本,画像一升版本那行就被 supersede,
* 而 plan 的外键不跟着走 → 条件恒为假 → 筛选静默变 0 条,不报错不告警。
* 实测见教条 §4.38(全量重算后 2,724 条池子里只剩 97 条还指向活版本)。
*/
const CURRENT_PERSONA = Prisma.sql`pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL`;
/** X 轴:潜在治疗(data->'types' 是字符串数组,与 persona-tag-filters 同口径用 @> 命中) */
function treatmentSql(potentialTreatment: string): Prisma.Sql {
return Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
WHERE ${CURRENT_PERSONA}
AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
)`;
}
/**
* Y 轴:窗口温度 —— 读时拿 `now` 跟存好的两个边界时刻比,**不做任何天数运算**。
* 口径与算法见 `@pac/types` 的 temperature.ts(为什么存边界不存天数/档位)。
*
* ⚠️ `hotUntil IS NOT NULL` 是必须的:本次改动之前算出来的老画像没有边界字段,
* 它们是「温度未知」,⛔ 不许掉进任何一档(尤其不许默认掉进冷),否则主管看到假分布。
* 调用方应另外把"未知"的条数显式报出来(见 cohort-attributes 的 unknownTemperature)。
*/
function temperatureSql(
potentialTreatment: string,
temperature: TemperatureValue,
now: Date,
): Prisma.Sql {
const phase =
temperature === Temperature.HOT
? Prisma.sql`${now} <= (e->>'hotUntil')::timestamptz`
: temperature === Temperature.WARM
? Prisma.sql`${now} > (e->>'hotUntil')::timestamptz AND ${now} <= (e->>'warmUntil')::timestamptz`
: Prisma.sql`${now} > (e->>'warmUntil')::timestamptz`;
return Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
CROSS JOIN LATERAL jsonb_array_elements(pf.data -> 'detail') e
WHERE ${CURRENT_PERSONA}
AND e->>'key' = ${potentialTreatment}
AND e->>'hotUntil' IS NOT NULL
AND ${phase}
)`;
}
/**
* 主管追加的画像条件 —— 同一维度多选 = OR,跨维度 = AND(与列表页语义完全一致)。
* 维度/取值以 `PERSONA_TAG_FILTER_DIMS` 为单一真理源,非法 key/value 静默丢弃。
*/
function personaTagsSql(personaTags: string, now: Date): Prisma.Sql {
// ⚠️ 按**维度 id** 分组而不是 persona 特征 key —— visit_recency 一个特征出三个可筛字段,
// 按 key 分组会把它们串成一条 OR,筛出错的人(列表页踩过,这里照抄结论)。
const byDim = new Map<string, { dim: (typeof PERSONA_TAG_FILTER_DIMS)[number]; values: string[] }>();
for (const { key: dimId, value } of parsePersonaTags(personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === dimId);
if (!dim) continue;
if (!dim.dynamic && !dim.options.some((o) => o.value === value)) continue;
const cur = byDim.get(dimId) ?? { dim, values: [] };
cur.values.push(value);
byDim.set(dimId, cur);
}
const frags: Prisma.Sql[] = [];
for (const { dim, values } of byDim.values()) {
// ── 事实派生属性:直接查 patient_profiles,不穿 personas → persona_features ──
if (dim.source === 'patient') {
if (dim.patientField === 'lastVisitAt') {
// ⭐ 存的是日期,区间按 now 现算(与 visitRecencyRange 同一个函数)——
// 跟温度存边界时刻是同一条道理:存死分档会过期。
const ranges = values
.map((v) => visitRecencyRange(v, now))
.filter((r): r is { gte?: Date; lt?: Date } => !!r);
if (ranges.length === 0) continue;
const ors = ranges.map((r) =>
Prisma.sql`(${r.gte ? Prisma.sql`pp.last_visit_at >= ${r.gte}` : Prisma.sql`TRUE`}
AND ${r.lt ? Prisma.sql`pp.last_visit_at < ${r.lt}` : Prisma.sql`TRUE`})`,
);
frags.push(Prisma.sql`AND EXISTS (
SELECT 1 FROM patient_profiles pp
WHERE pp.patient_id = fp.patient_id AND (${Prisma.join(ors, ' OR ')}))`);
} else {
const col =
dim.patientField === 'lastVisitDoctor'
? Prisma.sql`pp.last_visit_doctor`
: Prisma.sql`pp.preferred_doctor`;
frags.push(Prisma.sql`AND EXISTS (
SELECT 1 FROM patient_profiles pp
WHERE pp.patient_id = fp.patient_id AND ${col} IN (${Prisma.join(values)}))`);
}
continue;
}
const ors = values.map((v) =>
dim.isArray
? Prisma.sql`(pf.data #> ${`{${dim.dataPath}}`}::text[]) @> ${JSON.stringify([v])}::jsonb`
: Prisma.sql`pf.data ->> ${dim.dataPath} = ${v}`,
);
frags.push(Prisma.sql`AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = ${dim.key}
WHERE ${CURRENT_PERSONA} AND (${Prisma.join(ors, ' OR ')}))`);
}
return frags.length ? Prisma.sql`${Prisma.join(frags, ' ')}` : Prisma.empty;
}
/**
* 完整的人群过滤片段 —— 调用方拼在 `FROM followup_plans fp JOIN patients p ON p.id = fp.patient_id WHERE …` 之后。
*/
export function cohortWhereSql(
scope: TenantScopeContext,
c: CohortCriteria,
now: Date,
): Prisma.Sql {
assertCohortCriteria(c);
return Prisma.sql`
${poolBaseSql(scope, c.clinicId)}
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${c.temperature && c.potentialTreatment ? temperatureSql(c.potentialTreatment, c.temperature, now) : Prisma.empty}
${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty}
`;
}
...@@ -5,6 +5,7 @@ import { AssignmentController } from './assignment.controller'; ...@@ -5,6 +5,7 @@ import { AssignmentController } from './assignment.controller';
import { PlanAssignmentService } from './plan-assignment.service'; import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service'; import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service'; import { AssignmentProposalService } from './assignment-proposal.service';
import { CohortAttributesService } from './cohort-attributes.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service'; import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service'; import { RecycleSchedulerService } from './recycle-scheduler.service';
...@@ -32,6 +33,7 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -32,6 +33,7 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
PlanAssignmentService, PlanAssignmentService,
AgentRosterService, AgentRosterService,
AssignmentProposalService, AssignmentProposalService,
CohortAttributesService,
ExecutionService, ExecutionService,
ExecutionCallbackService, ExecutionCallbackService,
RecycleSchedulerService, RecycleSchedulerService,
...@@ -42,6 +44,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -42,6 +44,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
RecallDebugService, RecallDebugService,
], ],
// MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory) // MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService], exports: [PlanService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, CohortAttributesService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
}) })
export class PlanModule {} export class PlanModule {}
import { readFileSync } from 'node:fs'; import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
/** /**
* 圈人取数的关联口径护栏。 * 「画像必须按患者取当前版」的**全仓**护栏。
* *
* 🔴 这条拦的是一个**静默归零**的 bug:按 `pe.id = fp.persona_id AND pe.superseded_at IS NULL` * 🔴 拦的是一个**静默归零**的写法:按 `pe.id = fp.persona_id AND pe.superseded_at IS NULL`
* 关联画像,看着天经地义,实际上 `followup_plans.persona_id` 指向的是**建 plan 那一刻**的画像版本, * 关联画像看着天经地义,实际上 `followup_plans.persona_id` 指向的是**建 plan 那一刻**的
* 画像一升版本那行就被 supersede,而 plan 的外键不跟着走 —— 于是条件恒为假, * 画像版本,画像一升版本那行就被 supersede,而 plan 的外键不跟着走 —— 条件恒为假,
* 整个「潜在治疗」筛选变成 0 条,**不报错、不告警**,主管只看到"这个格子没人了"。 * 筛选变成 0 条,**不报错、不告警**,主管只看到"这个格子没人了"。
* *
* 2026-08-02 本地实测(跑完一次全量 persona 重算之后): * 2026-08-02 本地实测(跑完一次全量 persona 重算之后):
* ``` * ```
...@@ -17,30 +17,39 @@ import { join } from 'node:path'; ...@@ -17,30 +17,39 @@ import { join } from 'node:path';
* ``` * ```
* 两处不一致 = T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。 * 两处不一致 = T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。
* *
* 单测拦不住 SQL 语义(要真库才跑得出来),所以这里退而**锁源码形态** —— * ⚠️ 故意**扫整个 src**,不是盯某一个文件:这个写法太顺手,下一个人会在别处再写一遍。
* 便宜,且正好拦住"顺手改回去"这一种复发方式。 * 生成出来的 SQL 长什么样另有 `cohort-filter.spec.ts` 逐条断言,那一层比文本匹配强;
* 这一层只负责"别处也不许冒出来"。
*/ */
const SRC = readFileSync( const SRC_ROOT = join(__dirname, '../src');
join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
'utf-8',
);
/**
* 只看**代码**,不看注释 —— 那段注释里就写着反面教材 `pe.id = fp.persona_id`,
* 不剥掉的话这个护栏第一个抓住的是它自己。
*/
const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
describe('圈人取数 —— 画像必须按患者取当前版', () => { function walk(dir: string): string[] {
test('⭐⭐ ⛔ 不许出现 `pe.id = fp.persona_id`(升一次版本就静默归零)', () => { return readdirSync(dir).flatMap((name) => {
expect(CODE).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/); const p = join(dir, name);
if (statSync(p).isDirectory()) return walk(p);
return p.endsWith('.ts') ? [p] : [];
}); });
}
test('⭐ 必须按 `pe.patient_id = fp.patient_id` 关联 —— 与列表页 buildListWhere 同口径', () => { /** 只看代码不看注释 —— 注释里正写着这条反面教材,不剥掉护栏会先抓住它自己 */
expect(CODE).toMatch(/pe\.patient_id\s*=\s*fp\.patient_id/); const stripComments = (s: string) =>
s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
describe('圈人取数 —— 画像必须按患者取当前版', () => {
test('⭐⭐ 全仓 ⛔ 不许出现 `pe.id = fp.persona_id`(升一次画像版本就静默归零)', () => {
const offenders = walk(SRC_ROOT).filter((f) =>
/\bpe\.id\s*=\s*fp\.persona_id/.test(stripComments(readFileSync(f, 'utf-8'))),
);
expect(offenders.map((f) => f.replace(SRC_ROOT, 'src'))).toEqual([]);
}); });
test('⭐ 且必须限定当前版(否则历史版本会把同一患者算多次)', () => { test('⭐ 人群取数的关联口径收口在 cohort-filter 一处 —— 出确认单与看分布共用', () => {
expect(CODE).toContain('pe.superseded_at IS NULL'); // 各写一遍就会出现「属性分布说商保 32 人,提案只有 28 人」,主管当场不信,
// 而且分不清是哪一边错。
const proposal = readFileSync(join(SRC_ROOT, 'modules/plan/assignment-proposal.service.ts'), 'utf-8');
const attrs = readFileSync(join(SRC_ROOT, 'modules/plan/cohort-attributes.service.ts'), 'utf-8');
expect(proposal).toContain('cohortWhereSql');
expect(attrs).toContain('cohortWhereSql');
}); });
}); });
import { Test } from '@nestjs/testing';
import { CohortAttributesService, COHORT_DIM_CATALOG } from '../src/modules/plan/cohort-attributes.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 画像圈人(T9-B)的行为回归。
*
* 🔴 这里最贵的一条是 **noTag ≠ 反面**:
* 「32 人有商保标签」剩下的**不是自费**,是**没证据**。模型极容易顺口说成
* 「其余 68 人自费」—— 那是凭空造事实(T14),而且主管会拿它去定价。
*/
const SCOPE = {
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
sourceUnits: [] as string[],
clinicIds: ['c1'],
userId: 'u1',
} as never;
/**
* ⚠️ **按 SQL 形态发牌,不按调用顺序** —— 四个查询里有两个是条件发出的
* (温度未知计数只在指定治疗项时查、上次到诊只在点名该维度时查),
* 按序号取牌会在换一组 keys 时整体错位,而且错得很像"逻辑 bug"。
*/
function makePrisma(opts: {
patientIds: string[];
features?: Array<{ patientId: string; key: string; data: unknown }>;
dedicated?: string[];
}) {
return {
$queryRaw: jest.fn(async (q: { strings: string[] }) => {
const sql = q.strings.join(' ');
if (sql.includes('count(DISTINCT fp.patient_id)')) return [{ n: BigInt(0) }];
if (sql.includes('FROM followup_plans')) return opts.patientIds.map((patientId) => ({ patientId }));
if (sql.includes('FROM persona_features') || sql.includes('JOIN persona_features'))
return opts.features ?? [];
if (sql.includes('patient_profiles')) return [];
throw new Error(`未预期的查询:${sql.slice(0, 80)}`);
}),
patient: {
findMany: jest.fn(async () =>
opts.patientIds.map((id) => ({
id,
preferences: opts.dedicated?.includes(id) ? { dedicatedCs: { id: 'cs-1' } } : null,
})),
),
},
} as unknown as PrismaService;
}
async function build(prisma: PrismaService): Promise<CohortAttributesService> {
const mod = await Test.createTestingModule({
providers: [CohortAttributesService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(CohortAttributesService);
}
const ENT = (patientId: string, types: string[]) => ({
patientId,
key: 'entitlement_status',
data: { types },
});
describe('画像圈人 —— noTag 的语义', () => {
test('⭐⭐ 有商保 2 / 共 10 人 → noTag=7,且⛔ 不产生任何"其余=反面"的字段', async () => {
const ids = Array.from({ length: 10 }, (_, i) => `p${i}`);
const prisma = makePrisma({
patientIds: ids,
features: [ENT('p0', ['high_insurance']), ENT('p1', ['high_insurance']), ENT('p2', ['medical'])],
});
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['entitlement_status'] });
const dim = r.dimensions.find((d) => d.id === 'entitlement_status')!;
expect(r.cohortSize).toBe(10);
expect(dim.options.find((o) => o.value === 'high_insurance')?.count).toBe(2);
expect(dim.options.find((o) => o.value === 'medical')?.count).toBe(1);
expect(dim.noTag).toBe(7); // p0/p1/p2 有标签,其余 7 人**没证据**
// ⛔ 结构里不许出现任何暗示"反面"的键
expect(Object.keys(dim)).toEqual(expect.not.arrayContaining(['without', 'others', 'negative']));
});
test('⭐ note 里必须写死「不是反面」这句 —— 提示词与工具返回双保险', async () => {
const prisma = makePrisma({ patientIds: ['p0'] });
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' });
expect(r.note).toContain('没有这条画像证据');
expect(r.note).toContain('不要说成反面');
});
});
describe('画像圈人 —— 多标签维度不能拿来算百分比', () => {
test('⭐ 一个人命中两项 → 各计一次,合计 > 人数,且必须标 multi', async () => {
const prisma = makePrisma({
patientIds: ['p0', 'p1'],
features: [ENT('p0', ['high_insurance', 'stored_value']), ENT('p1', ['high_insurance'])],
});
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['entitlement_status'] });
const dim = r.dimensions[0]!;
const sum = dim.options.reduce((a, o) => a + o.count, 0);
expect(sum).toBe(3); // > cohortSize=2
expect(dim.multi).toBe(true);
expect(dim.noTag).toBe(0);
expect(r.note).toContain('别拿它算百分比');
});
});
describe('画像圈人 —— 维度点名', () => {
test('⭐⭐ 隐藏维度可以点名:教条举的「排掉怕疼的」正落在 treatment_sensitivity 上', async () => {
// hidden 的语义是"前端面板不展示",不是"不能用"(见 persona-tag-filters 的 hidden 注释)。
const prisma = makePrisma({
patientIds: ['p0', 'p1'],
features: [{ patientId: 'p0', key: 'treatment_sensitivity', data: { types: ['dental_fear'] } }],
});
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['treatment_sensitivity'] });
expect(r.dimensions.map((d) => d.id)).toEqual(['treatment_sensitivity']);
expect(r.dimensions[0]!.options.find((o) => o.value === 'dental_fear')?.count).toBe(1);
});
test('⭐ 但**默认不给**隐藏维度 —— 一次推 16 个维度,模型会挑个不相干的开始发挥', async () => {
const prisma = makePrisma({ patientIds: ['p0'] });
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' });
expect(r.dimensions.map((d) => d.id)).not.toContain('treatment_sensitivity');
});
test('⭐ 工具描述里的维度清单**含**隐藏维度 —— 模型得能把「怕疼」映射到 id 上', () => {
expect(COHORT_DIM_CATALOG).toContain('treatment_sensitivity');
expect(COHORT_DIM_CATALOG).toContain('entitlement_status');
// 开集维度(医生姓名)不进清单:几百个取值,列出来只会撑爆
expect(COHORT_DIM_CATALOG).not.toContain('visit_doctor_last');
});
test('⭐ 库里的野值不往主管面前放(只出闭集里声明过的取值)', async () => {
const prisma = makePrisma({
patientIds: ['p0'],
features: [ENT('p0', ['high_insurance', '某天冒出来的新值'])],
});
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['entitlement_status'] });
expect(r.dimensions[0]!.options.map((o) => o.value)).toEqual(['high_insurance']);
});
});
describe('画像圈人 —— 边界', () => {
test('⭐ 空人群直接短路,不去查特征,并给一句能落地的话', async () => {
const prisma = makePrisma({ patientIds: [] });
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' });
expect(r.cohortSize).toBe(0);
expect(r.dimensions).toEqual([]);
expect(r.note).toContain('先放宽条件');
expect((prisma.patient.findMany as jest.Mock)).not.toHaveBeenCalled();
});
test('⭐ 专属客服只报有/无,⛔ 不返回在岗判断(六·已定取舍:助手不校验在岗)', async () => {
const prisma = makePrisma({ patientIds: ['p0', 'p1', 'p2'], dedicated: ['p0', 'p1'] });
const svc = await build(prisma);
const r = await svc.describe(SCOPE, { clinicId: 'c1' });
expect(r.dedicatedCs).toEqual({ withDedicated: 2, none: 1 });
expect(Object.keys(r.dedicatedCs)).not.toContain('stillActive');
expect(r.note).toContain('不判在岗');
});
});
import { Temperature } from '@pac/types';
import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter';
/**
* 人群取数口径回归。
*
* 这一层的错误全都是**静默**的:SQL 照跑、不报错,只是圈出来的人不对。
* 所以这里逐条锁"生成的 SQL 里必须/不许出现什么" —— 便宜,且拦得住最贵的那几种改法。
*/
const SCOPE = {
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
sourceUnits: [] as string[],
clinicIds: ['c1'],
userId: 'u1',
} as never;
const NOW = new Date('2026-08-02T00:00:00.000Z');
/** Prisma.Sql 的 strings 拼起来 = 去掉参数占位的 SQL 骨架 */
const sqlOf = (...args: Parameters<typeof cohortWhereSql>) => cohortWhereSql(...args).strings.join('?');
describe('人群取数 —— 池子基线', () => {
test('⭐ 只圈**未被认领**的活跃 plan,且冷静期已过', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1' }, NOW);
expect(s).toContain("fp.status = 'active'");
expect(s).toContain('fp.assignee_user_id IS NULL');
expect(s).toContain('fp.superseded_at IS NULL');
// ⚠️ 约好 6/10 回访的患者不能被卷进批次
expect(s).toContain('fp.snoozed_until');
});
test('⭐ 批次不跨诊所:诊所是硬条件不是可选过滤', () => {
expect(sqlOf(SCOPE, { clinicId: 'c1' }, NOW)).toContain('fp.target_clinic_id');
});
});
describe('人群取数 —— 画像必须按患者取当前版', () => {
const all = sqlOf(
SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT, personaTags: 'rfm:important_value' },
NOW,
);
test('⭐⭐ ⛔ 一处都不许出现 `pe.id = fp.persona_id`(升一次画像版本就静默归零)', () => {
// 教条 §4.38:plan.persona_id 指向建 plan 那刻的版本,画像升版本后该条件恒为假。
expect(all).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/);
});
test('⭐ 三处(治疗项/温度/画像标签)全部按 patient_id + 当前版关联', () => {
expect(all.match(/pe\.patient_id = fp\.patient_id/g)?.length).toBe(3);
expect(all.match(/pe\.superseded_at IS NULL/g)?.length).toBe(3);
});
});
describe('人群取数 —— 温度', () => {
test('⭐⭐ 温度必须挂在治疗项上:光给温度直接拒', () => {
// 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的"热"是无意义的。
// ⛔ 尤其不许"贴心地"给个默认治疗项 —— 那会让主管拿到一批他没要的人。
expect(() => assertCohortCriteria({ clinicId: 'c1', temperature: Temperature.HOT })).toThrow(/必须与潜在治疗项/);
expect(() => assertCohortCriteria({ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT })).not.toThrow();
});
test('⭐⭐ 老画像(无边界)⛔ 不许掉进任何一档 —— 尤其不许默认掉进冷', () => {
for (const t of [Temperature.HOT, Temperature.WARM, Temperature.COLD]) {
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: t }, NOW);
expect(s).toContain("e->>'hotUntil' IS NOT NULL");
}
});
test('⭐ 三档互斥且穷尽(有边界的人必进且只进一档)—— 否则矩阵加不出总数', () => {
const hot = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);
const warm = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.WARM }, NOW);
const cold = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.COLD }, NOW);
expect(hot).toContain("<= (e->>'hotUntil')::timestamptz");
expect(warm).toContain("> (e->>'hotUntil')::timestamptz");
expect(warm).toContain("<= (e->>'warmUntil')::timestamptz");
expect(cold).toContain("> (e->>'warmUntil')::timestamptz");
expect(cold).not.toContain('hotUntil\')::timestamptz'); // 冷只看 warmUntil
});
test('⛔ 温度**不做任何天数运算** —— 天数是时钟,边界才是事实', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);
expect(s).not.toContain('daysSince');
expect(s).not.toMatch(/EXTRACT\s*\(\s*DAY/i);
});
});
describe('人群取数 —— 画像标签', () => {
test('⭐ 跨维度 AND:两个维度各出一个 EXISTS,不合并成 OR', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'rfm:important_value,gender:male' }, NOW);
expect(s.match(/AND EXISTS \(/g)?.length).toBe(2);
});
test('⭐ 非法维度 / 非法取值静默丢弃(与列表页同行为,不报错)', () => {
const bogus = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'not_a_dim:x,rfm:not_a_value' }, NOW);
const none = sqlOf(SCOPE, { clinicId: 'c1' }, NOW);
expect(bogus).toBe(none);
});
test('⭐ 数组维度走 @> 包含,标量维度走等值 —— 用错索引吃不到,生产实测会退化到 20 秒', () => {
expect(sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'entitlement_status:medical' }, NOW)).toContain('@>');
expect(sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'gender:male' }, NOW)).toContain('pf.data ->>');
});
test('⭐ 「上次到诊」走 patient_profiles 的日期区间,不穿画像表', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'last_visit_bucket:0_3m' }, NOW);
expect(s).toContain('patient_profiles');
expect(s).toContain('pp.last_visit_at');
});
});
...@@ -612,7 +612,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH) ...@@ -612,7 +612,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
| P6.2 `GET /plans/matrix` + `temperature` 参数 | 1.0 | 口径必须是**去重患者数**且与 `buildListWhere``plan.service.ts:258`)同 scope,否则矩阵数与点进去的列表数对不上 → 违 T14 | | P6.2 `GET /plans/matrix` + `temperature` 参数 | 1.0 | 口径必须是**去重患者数**且与 `buildListWhere``plan.service.ts:258`)同 scope,否则矩阵数与点进去的列表数对不上 → 违 T14 |
| P6.3 矩阵组件 + 配色 + 视图切换 | 1.5 | 见下 | | P6.3 矩阵组件 + 配色 + 视图切换 | 1.5 | 见下 |
| P6.4 撤销(REST + 原生确认组件) | 1.25 | D-4 / D-10 / D-11 | | P6.4 撤销(REST + 原生确认组件) | 1.25 | D-4 / D-10 / D-11 |
| P6.5 `get_cohort_attributes`(T9-B 调整阶段画像圈人) | 1.0 | 合并 `getDedicatedCs` + `getPersonaFeatures`(T10:不为每个因素开新接口);⚠️ 不 join 名册、不返回 `stillActive`(六·已定取舍:不校验在岗) | | ✅ P6.5 `get_cohort_attributes`(T9-B 调整阶段画像圈人) | 1.0 | **已落地(2026-08-02)**。合并 `getDedicatedCs` + `getPersonaFeatures`(T10);不 join 名册、不返回 `stillActive`。⭐ 关键补充:人群取数抽成**共用** `cohort-filter.ts`,出确认单与看分布同一份 SQL —— 否则会出现「分布说商保 32 人、提案只有 28 人」。⭐ 每个维度单独返回 `noTag`(「没有这条画像证据」≠「反面」),提示词与工具返回双保险 |
| P6.6 福利进 prompt + 护栏 + 话术失效规则 | 1.25 | 见下 | | P6.6 福利进 prompt + 护栏 + 话术失效规则 | 1.25 | 见下 |
| P6.7 转化率 + 「因素 × 完成率」报表 | 1.5 | T20 的真正交付物 | | P6.7 转化率 + 「因素 × 完成率」报表 | 1.5 | T20 的真正交付物 |
......
...@@ -500,6 +500,22 @@ getAgentWorkload({ userIds? }) ⭐ 与分配阶段的负载查询**合并 ...@@ -500,6 +500,22 @@ getAgentWorkload({ userIds? }) ⭐ 与分配阶段的负载查询**合并
**T10** —— 画像是助手的一个**可触发动作**`getPersonaFeatures``keys`),不是预置维度。 **T10** —— 画像是助手的一个**可触发动作**`getPersonaFeatures``keys`),不是预置维度。
> ✅ **已落地为 `get_cohort_attributes`(2026-08-02)**:`getDedicatedCs` + `getPersonaFeatures`
> 合成一个口,维度全集直接取 `PERSONA_TAG_FILTER_DIMS`(与列表页筛选面板、`list_recall_queue`
> 的 personaTags **同一张表**)——「以后要加权益身份、治疗史、家庭结构」是往那张表加一行的事,
> 这个工具一个字都不用改,T10 是真的成立而不只是口号。
>
> 三条落地时补出来的纪律:
> 1. ⭐ **人群取数必须与出确认单共用同一份 SQL**(`cohort-filter.ts`)——
> 各写一遍就会出现「分布说商保 32 人、提案只有 28 人」,主管当场不信且分不清哪边错。
> 本地实测已对数:分布 22 → 收窄 22 → 提案 `candidateTotal` 22。
> 2. ⭐ **`noTag`(没有这条画像证据)≠ 反面**。「32 人有商保标签」剩下的**不是自费**,
> 是院内没留痕。模型极容易说成「其余 68 人自费」——那是凭空造事实(T14),
> 而主管会拿它去定价。返回结构里单列 `noTag`,提示词再写死一遍,双保险。
> 3. ⭐ **隐藏维度可以点名,但默认不给**。教条举的「排掉怕疼的」正落在
> `treatment_sensitivity`(`hidden: true`)上 —— hidden 的语义是「面板不展示」不是「不能用」;
> 但默认把 16 个维度全推给模型,它会挑一个不相干的开始发挥。
⚠️ `assignPlans`**第一个写工具**。MCP 端点为 `@Public()``PermissionsGuard` 短路), ⚠️ `assignPlans`**第一个写工具**。MCP 端点为 `@Public()``PermissionsGuard` 短路),
必须在 handler 内自查 `permissions.includes('plan:dispatch')` 必须在 handler 内自查 `permissions.includes('plan:dispatch')`
......
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