Commit 1d1b7636 by luoqi

feat(plan): S2.5 初选矩阵 —— 端点 + 组件 + 点格子直通助手

生产线第一环补齐:主管在矩阵上点一格 = 完成初选,人群随即交给助手出确认单。

 关键设计:温度边界改存**稳定路径** `data.temperature.<标签>.hotUntil`,
不再塞进 `detail[]` 数组。原因是数组下标因人而异 —— `detail.0.hotUntil` 对谁都不成立,
Prisma 的 json 路径过滤(以及任何索引)都用不上,于是**召回池列表永远对不上矩阵**。
挪成稳定路径后:矩阵走原生 SQL、列表走 Prisma,谓词字面等价。
本地实测 **24 格逐格与列表 total 相等**,这是 T14「口径对数」的硬要求 ——
格子里写 44、点进去列表 373,主管对整个功能的信任当场就没了。

后端:
· GET /plans/matrix(8×3,去重患者数,PLAN_DISPATCH 门控 ——  不是 PLAN_VIEW_OWN,
  那等于从后门把召回池露给客服,违 T16);路由必须声明在裸 @Get(':id') 之前
· ListPlansQuery 加 temperature + potentialTreatment;单给 temperature 直接 400
  (同一个人可能对种植是热、对补牙是冷,脱离治疗项的"热"没有意义)
· 「待重算」单独一列, 不并进「冷」—— 并进去行合计好看但那是假分布(T14)

前端:
· pool-matrix.tsx:底色**按列固定**、与数量完全无关(否则「这格橙是因为热还是因为人多」
  分不清);热用橙不用红; 不用 brand-*(品牌蓝比 blue-100 深太多,会让「冷」像选中态)
· 矩阵是召回池的**视图模式**(列表 ⇄ 矩阵),不新开路由;初选格子有回显 chip + 一键清除
· 三层动效架构在此得到验证:发射点从「移交助手」按钮换到格子上,只改调用处,
  pet-events / pet-brain / pet-body 一个字没动(只给 cohort_handoff 加了个温度文案字段)

浏览器实测(主管 康慧捧 / 北京朝阳公园诊所)抓到并修掉两处:
· 规划建议的「矩阵模式 rail 加宽到 420px」→ **撤销**,1280px 视口下会把中间
  「参考话术」列挤成竖排文字;8×3 在 320px 内完全够用
· 页脚文案里的 markdown `**` 被原样渲染 → 改成 <span>

 另修一句会让助手替系统撒谎的话:候选 44 人时 selectionNote 仍说「排序取前 100 人」,
主管会以为有 56 个人被系统悄悄丢了。改为「这批候选一共就 44 人,全部纳入(未做取舍)」。
它是要求助手**原话转述**的句子,措辞错等于让助手说假话(T14),已加回归。

端到端实测:点「根管治疗·🔥热」→ 列表 5 人 → 助手直出确认单「确认分配 5 条」,
三句依据原话转述、默认值字样都在;矩阵/列表/chip/按钮四处数字一致。
944 tests passing;service + web typecheck 干净;控制台与服务端零报错。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent cb56c1fd
......@@ -153,7 +153,6 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
const detail = keys.map((k) => {
const v = agg.get(k)!;
const teeth = [...v.teeth].sort();
const hot = hottestBounds(v.bounds);
return {
key: k,
label: v.zh,
......@@ -161,19 +160,33 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
daysSince: v.daysSince,
confidence: v.confidence,
source: v.hasDx && v.hasRec ? 'both' : v.hasRec ? 'recommendation' : 'diagnosis',
// ⭐ 窗口温度的两个边界时刻。**不是易变键** —— 同一批事实换个时刻重算值不变,
// 所以 ⛔ 别加进 persona-diff 的 VOLATILE_DATA_KEYS(加了就永远不会因为
// "新来一条诊断把温度顶热了"而升版本,等于把这次修的冻结原样搬到另一层)。
// 档位在读时由 classifyTemperature(detail, now) 现算。
...(hot ?? {}),
};
});
/**
* ⭐ 窗口温度的边界时刻,**按标签 key 建映射,不塞进 detail[]**。
*
* 为什么不放 detail[]:那是**数组**,同一个标签在不同患者身上的下标不一样,
* 于是筛选路径 `detail.0.hotUntil` 对谁都不成立 —— Prisma 的 json 路径过滤
* (以及任何索引)都要求**稳定路径**。挪到 `temperature.<key>.hotUntil` 之后,
* 召回池列表可以直接用现成的 Prisma where 按温度筛(`plan.service.buildListWhere`),
* 矩阵与列表因此共用同一个引擎、天然对得上数(T14 的「口径对数」)。
*
* **不是易变键** —— 同一批事实换个时刻重算值不变,所以 ⛔ 别加进
* persona-diff 的 VOLATILE_DATA_KEYS(加了就永远不会因为"新来一条诊断把温度顶热了"
* 而升版本,等于把这次修的冻结原样搬到另一层)。档位在**读时**由 classifyTemperature 现算。
*/
const temperature: Record<string, { hotUntil: string; warmUntil: string }> = {};
for (const k of keys) {
const hot = hottestBounds(agg.get(k)!.bounds);
if (hot) temperature[k] = hot;
}
return {
key: this.key,
description: labels.join(' / '),
score: null,
data: { types: keys, labels, detail },
data: { types: keys, labels, detail, temperature },
evidence: { factIds: [...factIds] },
};
}
......
......@@ -186,10 +186,15 @@ export class AssignmentProposalService {
`容量上限按每人在手 ${AGENT_CAPACITY_DEFAULT} 条计,这是**默认值** ——` +
`暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`,
selectionNote:
`按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` +
(sizeBasis === 'default'
// ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来,
// 句子却说「排序取前 100 人」,他会以为有 56 个人被系统丢了。
// 这是一句**要求助手原话转述**的话,措辞错了等于让助手替系统撒谎(T14)。
(ranked.length <= target
? `这批候选一共就 ${ranked.length} 人,**全部纳入**(未做取舍)。`
: `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${ranked.length} 位候选里取前 ${target} 人;`) +
(ranked.length > target && sizeBasis === 'default'
? `批次规模 ${target} 人为**默认值**(一批做透好过多批做浅),要改直接说个数字。`
: sizeBasis === 'capacity'
: sizeBasis === 'capacity' && ranked.length > target
? `⚠️ 只取到 ${target} 人 —— 在岗 ${agents.length} 位客服的剩余容量之和就这么多,不是候选不够。`
: '') +
(exploreN > 0
......
......@@ -9,7 +9,7 @@ import {
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { cohortWhereSql, type CohortCriteria } from './cohort-filter';
import { cohortWhereSql, poolBaseSql, type CohortCriteria } from './cohort-filter';
/**
* CohortAttributesService —— T9-B「调整阶段画像圈人」的取数层。**纯只读**。
......@@ -57,6 +57,87 @@ export interface CohortAttributeDim {
export class CohortAttributesService {
constructor(private readonly prisma: PrismaService) {}
/**
* 初选矩阵 —— 8 潜在治疗 × 3 温度,一次查完 24 格(T6a / T6′)。
*
* ⚠️ **口径是「去重患者数」不是「plan 条数」**:列表页按 plan 分页,矩阵按患者去重。
* 两者本来就不该相等(理论上一患者一条活动 plan,但那条 partial UNIQUE 实际不存在,见 R9)。
* → 前端 hover 必须写「N 位患者」而不是「N 条」,否则主管一对数就觉得系统在骗他。
*
* ⚠️ 池子条件走**共用**的 `poolBaseSql` —— 与出确认单、看属性分布同一份,
* 点进格子拿到的人必须与格子里的数一致(T6a 同源保证)。
*
* ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。
* ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 temperature.ts 第 ③ 条 / T14)。
*/
async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) {
const rows = await this.prisma.$queryRaw<
Array<{ label: string; temp: string; n: bigint }>
>(
Prisma.sql`
SELECT lbl AS label,
CASE
WHEN b->>'hotUntil' IS NULL THEN 'unknown'
WHEN ${now} <= (b->>'hotUntil')::timestamptz THEN 'hot'
WHEN ${now} <= (b->>'warmUntil')::timestamptz THEN 'warm'
ELSE 'cold'
END AS temp,
count(DISTINCT fp.patient_id) AS n
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
JOIN personas pe ON pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
-- ⚠️ 从 types 展开而不是从 temperature 展开:算不出边界的标签在 temperature 里**没有键**,
-- 只展开 temperature 会把那些人整个漏掉,「待重算」列就永远是 0(等于把问题藏起来)。
CROSS JOIN LATERAL jsonb_array_elements_text(pf.data -> 'types') lbl
LEFT JOIN LATERAL (SELECT pf.data #> ARRAY['temperature', lbl] AS b) tb ON TRUE
WHERE ${poolBaseSql(scope, clinicId)}
GROUP BY 1, 2
`,
);
const cells = new Map<string, Record<string, number>>();
for (const r of rows) {
const row = cells.get(r.label) ?? {};
row[r.temp] = Number(r.n);
cells.set(r.label, row);
}
// 行序照 PERSONA_TAG_FILTER_DIMS 的声明序(= 业务上「客服最先问什么」的排序),
// ⛔ 别按人数排 —— 那会让矩阵每天换一个样子,主管的肌肉记忆全废。
const labelDim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
const unknownTotal = rows
.filter((r) => r.temp === 'unknown')
.reduce((a, r) => a + Number(r.n), 0);
return {
clinicId,
rows: labelDim.options.map((o) => {
const c = cells.get(o.value) ?? {};
const hot = c.hot ?? 0;
const warm = c.warm ?? 0;
const cold = c.cold ?? 0;
const unknown = c.unknown ?? 0;
return {
key: o.value,
zh: o.zh,
hint: o.hint,
hot,
warm,
cold,
unknown,
// ⚠️ total 含 unknown:它就是「点这一行能拿到多少人」,少算了主管会以为丢了人
total: hot + warm + cold + unknown,
};
}),
unknownTotal,
note:
unknownTotal > 0
? `另有 ${unknownTotal} 人**温度待重算**(画像还没算出窗口边界),已单列在「待重算」列,` +
`⛔ 没有并进「冷」—— 并进去数字好看但那是假分布。这是重算进度,不是数据缺失。`
: '',
};
}
async describe(
scope: TenantScopeContext,
criteria: CohortCriteria,
......@@ -146,10 +227,8 @@ export class CohortAttributesService {
AND NOT 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 pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL
AND e->>'key' = ${criteria.potentialTreatment}
AND e->>'hotUntil' IS NOT NULL
AND pf.data #> ${['temperature', criteria.potentialTreatment]}::text[] IS NOT NULL
)
`,
);
......
......@@ -93,20 +93,22 @@ function temperatureSql(
temperature: TemperatureValue,
now: Date,
): Prisma.Sql {
// ⭐ 稳定路径 `temperature.<标签>.hotUntil`(不是 detail 数组下标)——
// 同一个谓词列表页用 Prisma 也能表达,两边因此天然对得上数。
const hotUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'hotUntil']}::text[])::timestamptz`;
const warmUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'warmUntil']}::text[])::timestamptz`;
const phase =
temperature === Temperature.HOT
? Prisma.sql`${now} <= (e->>'hotUntil')::timestamptz`
? Prisma.sql`${now} <= ${hotUntil}`
: temperature === Temperature.WARM
? Prisma.sql`${now} > (e->>'hotUntil')::timestamptz AND ${now} <= (e->>'warmUntil')::timestamptz`
: Prisma.sql`${now} > (e->>'warmUntil')::timestamptz`;
? Prisma.sql`${now} > ${hotUntil} AND ${now} <= ${warmUntil}`
: Prisma.sql`${now} > ${warmUntil}`;
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 pf.data #> ${['temperature', potentialTreatment]}::text[] IS NOT NULL
AND ${phase}
)`;
}
......
import {
BadRequestException,
Body,
Controller,
Get,
......@@ -33,6 +34,7 @@ import {
} from './dto/plan.dto';
import { PlanService } from './plan.service';
import { PlanEngineService } from './engine/plan-engine.service';
import { CohortAttributesService } from './cohort-attributes.service';
import { resolveScriptAgent } from '../ai/calls/draft-plan-script/shared/agent-identity';
@ApiTags('plan')
......@@ -42,6 +44,7 @@ export class PlanController {
constructor(
private readonly plans: PlanService,
private readonly engine: PlanEngineService,
private readonly cohorts: CohortAttributesService,
) {}
@Get()
......@@ -91,6 +94,28 @@ export class PlanController {
return this.plans.doctorOptions(scope);
}
/**
* 初选矩阵:8 潜在治疗 × 3 温度 + 「待重算」列。
*
* ⚠️ 同 `doctors`,必须放在 `@Get(':id')` **之前**,否则 'matrix' 会被当成 planId。
* ⚠️ 权限是 `PLAN_DISPATCH` 不是 `PLAN_VIEW_OWN` —— 矩阵是**召回池**的视图,
* 而 T16 明写客服看不到池子。给成 VIEW_OWN 等于从后门把池子露给客服。
*/
@Get('matrix')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
summary: '初选矩阵(潜在治疗 × 窗口温度),按患者去重',
description:
'口径是**去重患者数**,不是 plan 条数(列表页按 plan 分页)。' +
'温度定义见 packages/types/temperature.ts:逐条 gap 用自己 K 码的窗口判档、取最热。',
})
matrix(@TenantScope() scope: TenantScopeContext, @Query('clinicId') clinicId?: string) {
// 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的
const cid = clinicId ?? scope.clinicIds[0];
if (!cid) throw new BadRequestException('当前登录人没有绑定诊所,无法出矩阵');
return this.cohorts.matrix(scope, cid);
}
@Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
......
......@@ -295,6 +295,11 @@ export class PlanService {
query: ListPlansQueryDto,
permissions: readonly string[],
): Prisma.FollowupPlanWhereInput {
// 温度必须挂在治疗项上 —— 光给 temperature 会静默筛出一个**没人要的人群**
// (同一个人可能对种植是热、对补牙是冷)。⛔ 不许"贴心地"给个默认治疗项。
if (query.temperature && !query.potentialTreatment) {
throw new BadRequestException('temperature 必须与 potentialTreatment 一起传');
}
const where: Prisma.FollowupPlanWhereInput = {
hostId: scope.hostId,
tenantId: scope.tenantId,
......@@ -421,6 +426,46 @@ export class PlanService {
}
}
// ── 初选矩阵的两根轴(治疗项 × 窗口温度)────────────────────────
// ⭐ 点矩阵格子进来的就是这条路。**必须与矩阵端点算出同一批人**,
// 否则「格子里写 44,点进去列表 373」—— 主管对整个功能的信任当场没了(T14 口径对数)。
// 两边能对上是因为温度边界存成了**稳定路径** `temperature.<标签>.hotUntil`:
// 矩阵走原生 SQL、这里走 Prisma,但谓词字面等价(见 temperature.ts 与 cohort-filter.ts)。
if (query.potentialTreatment) {
const nowIso = new Date().toISOString();
const path = ['temperature', query.potentialTreatment];
// ISO-8601 UTC 串的字典序 == 时间序,所以 json 字符串直接比大小即可 —— 不需要 cast。
const phase: Prisma.PersonaFeatureWhereInput[] =
query.temperature === 'hot'
? [{ data: { path: [...path, 'hotUntil'], gte: nowIso } }]
: query.temperature === 'warm'
? [
{ data: { path: [...path, 'hotUntil'], lt: nowIso } },
{ data: { path: [...path, 'warmUntil'], gte: nowIso } },
]
: query.temperature === 'cold'
? [{ data: { path: [...path, 'warmUntil'], lt: nowIso } }]
: []; // 只给治疗项不给温度 → 整行(三档 + 待重算)
patientWhere.AND = [
...((patientWhere.AND as Prisma.PatientWhereInput[]) ?? []),
{
personas: {
some: {
supersededAt: null,
features: {
some: {
key: 'potential_treatment',
// X 轴用 types 数组命中(与 persona-tag-filters 同口径)
data: { path: ['types'], array_contains: query.potentialTreatment },
...(phase.length ? { AND: phase } : {}),
},
},
},
},
},
];
}
if (Object.keys(patientWhere).length > 0) {
where.patient = patientWhere;
}
......
......@@ -158,3 +158,49 @@ describe('placeAgents —— 确定性', () => {
expect(placed.find((p) => p.planId === 'p1')!.selectionMode).toBe('rank');
});
});
/**
* 「取前 N 人」这句话的准确性。
*
* 🔴 它是**要求助手原话转述**的句子(T14 双保险的工具返回值那一半)——
* 措辞错了等于让助手替系统撒谎,而且主管没法自己发现。
* 真实场景:主管从矩阵点了一个 44 人的格子进来,句子却说「排序取前 100 人」,
* 他会以为有 56 个人被系统悄悄丢了。
*/
describe('selectionNote —— 候选不够时的措辞', () => {
const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
function svcWith(candidateCount: number, agentCount: number) {
const prisma = {
$queryRaw: jest.fn(async () =>
Array.from({ length: candidateCount }, (_, i) => ({
planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
})),
),
patient: { findMany: jest.fn(async () => []) },
};
const roster = {
list: jest.fn(async () => ({
agents: Array.from({ length: agentCount }, (_, i) => ({ userId: `a${i}`, name: `客服${i}`, inHand: 0 })),
rosterNote: '名册说明',
})),
};
return new AssignmentProposalService(prisma, roster);
}
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
test('⭐⭐ 候选 44 < 默认批次 100 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 100 人」', async () => {
const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.selectionNote).toContain('一共就 44 人');
expect(r.selectionNote).toContain('全部纳入');
expect(r.selectionNote).not.toContain('取前 100 人');
// ⛔ 候选不够时不该再吹「批次规模 100 为默认值」—— 那个默认值根本没起作用
expect(r.selectionNote).not.toContain('批次规模 100');
});
test('⭐ 候选 500 > 默认批次 100 → 照实说「从 500 位候选里取前 100 人」+ 标默认值', async () => {
const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.selectionNote).toContain('从 500 位候选里取前 100 人');
expect(r.selectionNote).toContain('默认值');
});
});
......@@ -147,6 +147,65 @@ describe('画像圈人 —— 维度点名', () => {
});
});
describe('初选矩阵', () => {
const matrixPrisma = (rows: Array<{ label: string; temp: string; n: number }>) =>
({
$queryRaw: jest.fn(async () => rows.map((r) => ({ ...r, n: BigInt(r.n) }))),
}) as unknown as PrismaService;
test('⭐⭐ 「待重算」单独一列,⛔ 不许并进「冷」', async () => {
// 并进去行合计好看,但那是**假分布** —— 主管会以为那些人真的超窗了。
// 上线到全量重算跑完之间这一列一定非零,不能等出了事再补。
const svc = await build(
matrixPrisma([
{ label: 'implant', temp: 'hot', n: 10 },
{ label: 'implant', temp: 'cold', n: 5 },
{ label: 'implant', temp: 'unknown', n: 3 },
]),
);
const m = await svc.matrix(SCOPE, 'c1');
const implant = m.rows.find((r) => r.key === 'implant')!;
expect(implant.cold).toBe(5); // ⛔ 不是 8
expect(implant.unknown).toBe(3);
expect(m.unknownTotal).toBe(3);
expect(m.note).toContain('温度待重算');
expect(m.note).toContain('没有并进');
});
test('⭐⭐ total 含 unknown —— 它就是「点这一行能拿到多少人」', async () => {
// 少算了主管会以为系统丢了人;逐行对数(矩阵行合计 vs 圈人结果)靠的就是这条。
const svc = await build(
matrixPrisma([
{ label: 'implant', temp: 'hot', n: 10 },
{ label: 'implant', temp: 'warm', n: 2 },
{ label: 'implant', temp: 'cold', n: 5 },
{ label: 'implant', temp: 'unknown', n: 3 },
]),
);
const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!;
expect(implant.total).toBe(20);
});
test('⭐ 8 行恒定输出(没人的格子给 0),⛔ 不按人数排序', async () => {
// 空行也要在:主管需要看到"这一格是 0",而不是这一行凭空消失。
// 按人数排会让矩阵每天换个样子,肌肉记忆全废 —— 行序照业务声明序固定。
const svc = await build(matrixPrisma([{ label: 'perio', temp: 'hot', n: 1 }]));
const m = await svc.matrix(SCOPE, 'c1');
expect(m.rows).toHaveLength(8);
expect(m.rows.map((r) => r.key)).toEqual([
'implant', 'ortho', 'early_ortho', 'endo', 'perio', 'filling', 'restoration', 'extraction',
]);
expect(m.rows.find((r) => r.key === 'implant')).toMatchObject({ hot: 0, warm: 0, cold: 0, total: 0 });
});
test('⭐ 没有待重算的人时 note 为空 —— 不制造无谓的告警噪音', async () => {
const svc = await build(matrixPrisma([{ label: 'implant', temp: 'hot', n: 4 }]));
const m = await svc.matrix(SCOPE, 'c1');
expect(m.unknownTotal).toBe(0);
expect(m.note).toBe('');
});
});
describe('画像圈人 —— 边界', () => {
test('⭐ 空人群直接短路,不去查特征,并给一句能落地的话', async () => {
const prisma = makePrisma({ patientIds: [] });
......
......@@ -64,19 +64,32 @@ describe('人群取数 —— 温度', () => {
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");
// 边界键整个不存在时 `#>` 返回 NULL,这条把它挡在三档之外
expect(s).toContain('IS NOT NULL');
}
});
test('⭐⭐ 走**稳定路径** temperature.<标签>,⛔ 不走 detail 数组下标', () => {
// detail 是数组,同一标签在不同患者身上下标不同 → `detail.0.hotUntil` 对谁都不成立,
// 于是列表页(Prisma json 路径过滤)根本表达不了这个谓词,矩阵与列表就再也对不上数。
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);
expect(s).not.toContain('jsonb_array_elements');
expect(s).toContain("pf.data #>>");
});
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
const of = (t: (typeof Temperature)[keyof typeof Temperature]) =>
cohortWhereSql(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: t }, NOW);
// 参数里能看出各档比的是哪个边界:热/冷各一个比较,温两个
const cmp = (s: ReturnType<typeof of>) => s.strings.join('?').match(/<=|>/g)?.length ?? 0;
expect(cmp(of(Temperature.HOT))).toBeGreaterThanOrEqual(1);
expect(cmp(of(Temperature.WARM))).toBeGreaterThan(cmp(of(Temperature.COLD)));
// 各档引用的边界键:hot→hotUntil / cold→warmUntil / warm→两者
const keysOf = (t: (typeof Temperature)[keyof typeof Temperature]) =>
of(t).values.flat().filter((v) => v === 'hotUntil' || v === 'warmUntil');
expect(keysOf(Temperature.HOT)).toEqual(['hotUntil']);
expect(keysOf(Temperature.COLD)).toEqual(['warmUntil']);
expect(keysOf(Temperature.WARM).sort()).toEqual(['hotUntil', 'warmUntil']);
});
test('⛔ 温度**不做任何天数运算** —— 天数是时钟,边界才是事实', () => {
......
......@@ -133,10 +133,17 @@ export function usePetBrain(opts: { calm?: boolean } = {}) {
if (e.type === 'cohort_handoff') {
// ⭐ **这里是唯一决定「怎么演」的地方**(三层架构的大脑层)。
// 业务侧只发语义事件,换演法只改这一处。
const { count, treatment } = e.payload;
const { count, treatment, temperature } = e.payload;
// 从矩阵点进来会带温度档,气泡就说得更具体一点(「正在挑 44 位种植治疗·热…」);
// 从列表侧「移交助手」进来没有温度,退回到只说治疗项。⛔ 别在这里编一个默认档位。
const what = treatment
? temperature
? `${treatment}·${temperature}`
: treatment
: '';
play({
gesture: 'absorb',
bubble: treatment ? `正在挑 ${count}${treatment}…` : `正在挑 ${count} 位…`,
bubble: what ? `正在挑 ${count}${what}…` : `正在挑 ${count} 位…`,
ttlMs: 2400,
});
return;
......
......@@ -26,6 +26,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { emitPetEvent } from '@/lib/pet-events';
import { usePatientPicker, type PickerFilters } from './use-patient-picker';
import { PoolMatrix } from './pool-matrix';
/**
* PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。
......@@ -80,6 +81,13 @@ export function PatientPickerRail({
const hasPatientArchive = !!actionTemplate('VIEW_PATIENT');
const [clinics, setClinics] = useState<string[]>([]);
const [tags, setTags] = useState<Set<string>>(new Set()); // "key:value"
/**
* ⭐ 矩阵是召回池的一个**视图模式**,不是新路由(T16:主管本质也是客服,不能劈成两个身份)。
* `cell` = 已初选的那一格;它同时驱动列表筛选,所以「矩阵上写 44」与
* 「点进去列表 44 条」天然一致(口径对数,见后端 plan.service.buildListWhere 的注释)。
*/
const [matrixMode, setMatrixMode] = useState(false);
const [cell, setCell] = useState<{ treatment: string; treatmentZh: string; temperature: 'hot' | 'warm' | 'cold' } | null>(null);
const user = useAuthStore((st) => st.user);
const clinicDict = user?.dictionary?.clinics; // 显示 plan 诊所名用(字典查表,非 scope)
......@@ -113,8 +121,11 @@ export function PatientPickerRail({
phoneVerified: realPhoneOnly ? true : undefined,
targetClinicIds: clinics.length ? clinics : undefined,
personaTags: tags.size ? [...tags].join(',') : undefined,
// 初选矩阵两轴 —— 只在召回池视图下生效(「我的」不做初选)
potentialTreatment: view === 'pool' && cell ? cell.treatment : undefined,
temperature: view === 'pool' && cell ? cell.temperature : undefined,
}),
[view, keyword, sort, realPhoneOnly, clinics, tags],
[view, keyword, sort, realPhoneOnly, clinics, tags, cell],
);
const { items, total, loading, hasMore, error, loadMore, patchItem, removeItem } = usePatientPicker(filters);
......@@ -181,6 +192,33 @@ export function PatientPickerRail({
: '帮我给当前召回池筛出来的这批患者出一份分配方案',
);
};
/**
* 点矩阵格子 = 完成「初选」,人群随即交给助手(生产线 ① → ②)。
*
* ⚠️ 三件事一起做,少一件都会出问题:
* ① 记下这一格 → 驱动**列表**筛选,主管切回列表能看到这批人是谁(证据在,T14)
* ② 切回列表视图 —— 否则确认单出来了,他还盯着矩阵,不知道刚才那一下做了什么
* ③ 发语义事件 + 跟助手说一句 —— 「怎么演」归 pet-brain,「怎么圈人」归后端,
* 这里**不传 planId 列表**:传了等于把收敛规则搬到前端,而那是会漂的。
*/
const pickCell = (c: {
treatment: string;
treatmentZh: string;
temperature: 'hot' | 'warm' | 'cold';
count: number;
}) => {
const tempZh = { hot: '热', warm: '温', cold: '冷' }[c.temperature];
setCell({ treatment: c.treatment, treatmentZh: c.treatmentZh, temperature: c.temperature });
setMatrixMode(false);
emitPetEvent({
type: 'cohort_handoff',
payload: { count: c.count, treatment: c.treatmentZh, temperature: tempZh },
});
useAssistantStore
.getState()
.ask(`帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`);
};
// 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。
// 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。
// 不重拉,保滚动/筛选。
......@@ -232,23 +270,65 @@ export function PatientPickerRail({
{t.label}
</button>
))}
<span className="ml-auto self-center pr-1 text-[10.5px] tabular-nums text-slate-400">
{/* ⭐ 列表 ⇄ 矩阵:矩阵是召回池的**视图模式**,不新开路由(T16)。
⚠️ `<lg` 不提供 —— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。 */}
{canDispatch && view === 'pool' && (
<button
type="button"
onClick={() => setMatrixMode((v) => !v)}
className="ml-auto hidden self-center rounded border border-slate-200 px-2 py-0.5 text-[11px] text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-800 lg:block"
>
{matrixMode ? '列表' : '矩阵'}
</button>
)}
<span
className={cn(
'self-center pr-1 text-[10.5px] tabular-nums text-slate-400',
canDispatch && view === 'pool' ? 'pl-2' : 'ml-auto',
)}
>
{total}
</span>
</div>
{/* ⭐ 移交助手 —— 主管在召回池里筛好人群后,把这批交给助手出确认单。
MVS 阶段挂在这里;矩阵(第二刀)来了只换调用处,三层动效架构不用动。 */}
{canDispatch && view === 'pool' && total > 0 && (
<button
type="button"
onClick={handoffToAssistant}
className="flex flex-none items-center justify-center gap-1.5 border-b border-slate-100 bg-brand-50/60 px-2 py-2 text-[12px] font-medium text-brand-700 transition-colors hover:bg-brand-50"
>
<Sparkles className="h-3.5 w-3.5" />
把这 {total} 人移交助手,出分配方案
</button>
)}
{/* ⭐ 矩阵视图 —— 生产线第一环。点一格 = 完成初选,人群随即交给助手。
三层动效架构在这里得到验证:发射点从「移交助手」按钮换到格子上,
只改了调用处,pet-events / pet-brain / pet-body 一个字没动。 */}
{matrixMode && canDispatch && view === 'pool' ? (
<PoolMatrix selected={cell} onPick={pickCell} />
) : (
<>
{/* 已初选的那一格 —— 必须能一眼看见、且能一键撤掉。
不给撤销入口的话,主管点错一格就只能刷新页面(而刷新会丢掉整个对话)。 */}
{cell && view === 'pool' && (
<div className="flex flex-none items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-2 py-1.5 text-[11.5px] text-slate-600">
<span className="text-slate-400">初选</span>
<span className="font-medium text-slate-800">
{cell.treatmentZh} · {{ hot: '🔥 热', warm: '🌡 温', cold: '❄️ 冷' }[cell.temperature]}
</span>
<button
type="button"
onClick={() => setCell(null)}
title="清除初选,回到整池"
className="ml-auto rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-700"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{/* ⭐ 移交助手 —— 主管在召回池里筛好人群后,把这批交给助手出确认单。
与矩阵是两条并行的入口:矩阵按「治疗项 × 温度」初选,这里按筛选标签自由圈。 */}
{canDispatch && view === 'pool' && total > 0 && (
<button
type="button"
onClick={handoffToAssistant}
className="flex flex-none items-center justify-center gap-1.5 border-b border-slate-100 bg-brand-50/60 px-2 py-2 text-[12px] font-medium text-brand-700 transition-colors hover:bg-brand-50"
>
<Sparkles className="h-3.5 w-3.5" />
把这 {total} 人移交助手,出分配方案
</button>
)}
{/* 搜索 + 工具行 */}
<div className="flex-none space-y-1.5 border-b border-slate-100 p-2">
......@@ -377,7 +457,9 @@ export function PatientPickerRail({
加载更多({items.length}/{total})
</button>
)}
</div>
</div>
</>
)}
</aside>
);
}
......
......@@ -10,6 +10,27 @@ import type {
import { api } from '@/lib/api-client';
import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types';
export interface PoolMatrixRow {
key: string;
zh: string;
hint?: string;
hot: number;
warm: number;
cold: number;
/** 温度待重算(画像还没算出窗口边界)。⛔ 服务端刻意没把它并进 cold */
unknown: number;
/** 含 unknown —— 它就是「点这一行能拿到多少人」 */
total: number;
}
export interface PoolMatrix {
clinicId: string;
rows: PoolMatrixRow[];
unknownTotal: number;
/** 有待重算的人时才非空;成品句子,直接展示 */
note: string;
}
export const plansApi = {
list: (q: Partial<ListPlansQuery>) =>
api.get<ListPlansResponse>('/pac/v1/plans', {
......@@ -24,6 +45,9 @@ export const plansApi = {
keyword: q.keyword,
// 画像标签筛选("key:value" 逗号串,维度见 PERSONA_TAG_FILTER_DIMS)
personaTags: q.personaTags,
// 初选矩阵两轴 —— temperature 必须与 potentialTreatment 同时给(服务端会拒单给温度的)
potentialTreatment: q.potentialTreatment,
temperature: q.temperature,
// 只看真实号码:布尔上线成 'true'(后端 preprocess 还原);undefined 不带
phoneVerified: q.phoneVerified === undefined ? undefined : String(q.phoneVerified),
sort: q.sort,
......@@ -32,6 +56,13 @@ export const plansApi = {
},
}),
/**
* 初选矩阵(8 潜在治疗 × 3 窗口温度)。
* ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。
*/
matrix: (clinicId?: string) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId } }),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
......
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { potentialTreatmentCardLabel } from '@pac/types';
import { cn } from '@/lib/utils';
import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
/**
* PoolMatrix — 初选矩阵(8 潜在治疗 × 3 窗口温度)。
*
* 生产线的**第一环**:主管在这里点一格,就完成了「初选」,人群随即交给助手出确认单。
* 矩阵是召回池的一个**视图模式**(列表 ⇄ 矩阵),⛔ 不新开路由 ——
* 主管本质也是客服、也要执行,割裂成两个页面会把他劈成两个身份(T16)。
*
* ═══ 三条配色纪律(五之三)═══════════════════════════════════════
* ⛔ **数量不参与配色**,底色**按列固定**。
* 否则「这格橙是因为热、还是因为人多」分不清 —— 实现上列头决定 class,
* cell 一个字都不参与计算。⛔ 别写任何 `count > N ? ... : ...`。
* ⛔ **热不用红**:红太冲,是"出事了";橙才是"该动手了"。
* ⛔ **别用 brand-***:品牌蓝 #0032A0 比冷档的 blue-100 深太多,混用会让「冷」看着像选中态。
*
* ⚠️ 三档之外还有一列**「待重算」** —— 上线到全量重算跑完之间,老画像没有窗口边界。
* 把它们并进「冷」能让行合计好看,但那是**假分布**(T14)。宁可多一列让主管问一句。
*/
/** 列定义:底色写死在这里,**与任何数字无关**(见文件头配色纪律) */
const COLUMNS = [
{ key: 'hot', icon: '🔥', zh: '热', cell: 'bg-orange-400 text-white hover:bg-orange-500', hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得' },
{ key: 'warm', icon: '🌡', zh: '温', cell: 'bg-amber-100 text-amber-900 hover:bg-amber-200', hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由' },
{ key: 'cold', icon: '❄️', zh: '冷', cell: 'bg-blue-100 text-blue-900 hover:bg-blue-200', hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况' },
] as const;
type TempKey = (typeof COLUMNS)[number]['key'];
export function PoolMatrix({
clinicId,
selected,
onPick,
}: {
clinicId?: string;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
/** 点格子 → 初选完成,把这一格的人群交出去 */
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
}) {
const [data, setData] = useState<PoolMatrix | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setError(null);
plansApi
.matrix(clinicId)
.then((d) => alive && setData(d))
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [clinicId]);
if (error) {
return <div className="p-3 text-[12px] text-rose-600">矩阵加载失败:{error}</div>;
}
if (!data) {
return (
<div className="flex items-center justify-center gap-2 p-6 text-[12px] text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在数人…
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-2">
<table className="w-full border-separate border-spacing-[3px] text-[12px]">
<thead>
<tr>
<th className="w-[66px] text-left font-normal text-slate-400">潜在治疗</th>
{COLUMNS.map((c) => (
<th key={c.key} title={c.hint} className="font-medium text-slate-600">
{c.icon} {c.zh}
</th>
))}
{data.unknownTotal > 0 && (
<th
className="font-normal text-slate-400"
title="画像还没算出窗口边界,温度待重算。⛔ 没有并进「冷」——并进去数字好看,但那是假分布。"
>
待重算
</th>
)}
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<MatrixRow
key={row.key}
row={row}
showUnknown={data.unknownTotal > 0}
selected={selected}
onPick={onPick}
/>
))}
</tbody>
</table>
{/* ⚠️ 口径必须写在脸上:矩阵按**患者**去重,列表按 plan 分页。
不写的话主管一对数就觉得系统在骗他(验证策略「口径对数」那一条)。 */}
<p className="px-1 pt-2 text-[10.5px] leading-relaxed text-slate-400">
数字是<span className="font-medium text-slate-500">位患者</span>(已去重),不是任务条数。
点一格即把这批人交给助手出分配方案。
</p>
{data.note && (
<p className="mt-1 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
{data.note}
</p>
)}
</div>
);
}
function MatrixRow({
row,
showUnknown,
selected,
onPick,
}: {
row: PoolMatrixRow;
showUnknown: boolean;
selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
}) {
// ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处
const zh = potentialTreatmentCardLabel(row.key);
return (
<tr>
<th
scope="row"
title={row.hint}
className="truncate text-left text-[11.5px] font-normal text-slate-600"
>
{zh}
</th>
{COLUMNS.map((c) => {
const n = row[c.key];
const isSelected = selected?.treatment === row.key && selected.temperature === c.key;
return (
<td key={c.key} className="p-0">
<button
type="button"
disabled={n === 0}
onClick={() => onPick({ treatment: row.key, treatmentZh: zh, temperature: c.key, count: n })}
title={n === 0 ? `${zh}·${c.zh}:暂无人` : `${zh}·${c.zh}:${n} 位患者 —— 点击交给助手`}
className={cn(
'h-7 w-full rounded text-[12px] tabular-nums transition-colors',
// ⭐ 底色只看列(c.cell),**不看 n** —— 见文件头配色纪律
n === 0
? 'cursor-default bg-slate-50 text-slate-300'
: cn(c.cell, 'cursor-pointer font-medium'),
isSelected && 'ring-2 ring-slate-800 ring-offset-1',
)}
>
{n}
</button>
</td>
);
})}
{showUnknown && (
<td className="p-0">
<div
className="flex h-7 items-center justify-center rounded bg-slate-50 text-[11.5px] tabular-nums text-slate-400"
title="温度待重算 —— 画像还没算出窗口边界。这是重算进度,不是数据缺失。"
>
{row.unknown || '—'}
</div>
</td>
)}
</tr>
);
}
......@@ -16,6 +16,15 @@ export interface PickerFilters {
targetClinicIds?: string[];
/** "key:value" 逗号串(PERSONA_TAG_FILTER_DIMS 维度) */
personaTags?: string;
/**
* 初选矩阵的两根轴 —— 点格子进来的就是这一对。
* ⚠️ `temperature` **必须与 `potentialTreatment` 同时给**(服务端会拒单给温度的请求):
* 同一个人可能「潜在种植·热」而「潜在补牙·冷」,脱离治疗项的"热"没有意义。
* ⚠️ 温度刻意**不进 PERSONA_TAG_FILTER_DIMS** —— 那张表是客服筛选面板的渲染源,
* 加进去温度就会出现在客服面板(违 T16),且温度是初选轴不是精选标签。
*/
potentialTreatment?: string;
temperature?: 'hot' | 'warm' | 'cold';
}
export interface UsePatientPicker {
......@@ -64,6 +73,8 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
phoneVerified: filters.phoneVerified,
targetClinicIds: filters.targetClinicIds?.length ? filters.targetClinicIds : undefined,
personaTags: filters.personaTags || undefined,
potentialTreatment: filters.potentialTreatment || undefined,
temperature: filters.temperature || undefined,
page: p,
pageSize: PAGE_SIZE,
};
......
......@@ -44,7 +44,11 @@ export type PetEvent =
* ⚠️ 业务代码只发这个**语义**事件,不描述"怎么演" —— 演法归 pet-brain。
* 将来换演法(比如改成传送带动画)不用动分配功能的任何一行。
*/
| { type: 'cohort_handoff'; payload: { count: number; treatment?: string } };
| {
type: 'cohort_handoff';
/** temperature 是**中文档位**('热'/'温'/'冷'),不是 code —— 它只进气泡文案,不进逻辑 */
payload: { count: number; treatment?: string; temperature?: string };
};
/** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */
export interface DirectorScript {
......
......@@ -609,8 +609,8 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
| 任务 | 人日 | 说明 |
|---|---:|---|
| **P6.1 🔴 温度轴口径 + 数据改造** | 2.5 | 见下,**链条最长且含跑批挂钟** |
| P6.2 `GET /plans/matrix` + `temperature` 参数 | 1.0 | 口径必须是**去重患者数**且与 `buildListWhere``plan.service.ts:258`)同 scope,否则矩阵数与点进去的列表数对不上 → 违 T14 |
| P6.3 矩阵组件 + 配色 + 视图切换 | 1.5 | 见下 |
| ✅ P6.2 `GET /plans/matrix` + `temperature` 参数 | 1.0 | **已落地(2026-08-02)**。⭐ 关键设计:温度边界存成**稳定路径** `temperature.<标签>.hotUntil`(而不是 `detail[]` 数组下标)—— 数组下标因人而异,Prisma 的 json 路径过滤根本表达不了,列表就永远对不上矩阵。挪成稳定路径后矩阵走原生 SQL、列表走 Prisma,谓词字面等价。**本地实测 24 格逐格与列表 total 相等** |
| ✅ P6.3 矩阵组件 + 配色 + 视图切换 | 1.5 | **已落地**。见下;⚠️ 规划建议的「矩阵模式把 rail 加宽到 420px」**实测撤销** —— 1280px 视口下会把中间「参考话术」列挤成竖排文字。8×3 在 320px 内完全够用 |
| P6.4 撤销(REST + 原生确认组件) | 1.25 | D-4 / D-10 / D-11 |
| ✅ 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 | 见下 |
......
......@@ -132,6 +132,12 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 同一套路本仓已用过两次:`visitRecencyRange`(存日期读时分档)、
> `applyLiveDays`(存 `signalOccurredAt` 读时算天数)。这是第三次,别再交一次学费。
> ⭐ **边界必须存在「稳定路径」上**:`data.temperature.<标签>.hotUntil`,
> ⛔ **不能塞进 `detail[]` 数组** —— 同一个标签在不同患者身上的下标不一样,
> 于是 `detail.0.hotUntil` 对谁都不成立,Prisma 的 json 路径过滤(以及任何索引)都用不上,
> **召回池列表就永远对不上矩阵**。挪成稳定路径之后:矩阵走原生 SQL、列表走 Prisma,
> 谓词字面等价,本地实测 **24 格逐格与列表 total 相等**。
**③ 「不知道」不等于「冷」。** 本次改动之前算出来的画像没有边界字段 →
`classifyTemperature` 返回 `null`,调用方必须显式呈现「温度未知」或排除出矩阵。
静默判成冷会让老数据塞满冷格子,主管看到一个**假的**分布还看不出哪里假(T14)。
......
......@@ -151,6 +151,17 @@ export const ListPlansQuerySchema = z.object({
/// 画像标签筛选:"key:value" 逗号串(如 "rfm:important_value,urgency_level:high")。
/// 维度/取值见 PERSONA_TAG_FILTER_DIMS;同维多选 OR,跨维 AND,匹配当前版画像。
personaTags: z.string().trim().min(1).optional(),
/**
* 初选矩阵的 Y 轴:窗口温度。**必须与 `potentialTreatment` 一起给** ——
* 同一个人可能「潜在种植·热」而「潜在补牙·冷」,脱离治疗项的"热"没有意义。
*
* ⚠️ 刻意**不进 `PERSONA_TAG_FILTER_DIMS`**:那张表是客服筛选面板的渲染源
* (`patient-picker-rail` 直接遍历它),加进去温度就会出现在客服面板 —— 违 T16;
* 且温度是**初选轴**不是精选标签(六·已定取舍:精选不再切窗口)。
*/
temperature: z.enum(['hot', 'warm', 'cold']).optional(),
/// 与 temperature 配套的治疗项(矩阵 X 轴)。单给 temperature 会被服务端拒。
potentialTreatment: z.string().trim().min(1).optional(),
/// 只看真实号码(patient.phoneVerified=true,外部对照表核实过的)。query 串传 'true'。
phoneVerified: z.preprocess(
(v) => (v === 'true' ? true : v === 'false' ? false : v),
......
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