Commit 6e79b48d by luoqi

fix: 画像圈人性别返回 0 + 列表姓名搜索失效 + jest 吃满 CPU

- 🔴 姓名搜索/phoneVerified/画像标签全部静默失效:温度重构时把
  `where.patient = {...}` 的挂载整段删掉了,tsc 绿、单测绿、界面无报错,
  只是筛选条件从此不生效。改回合并式挂载(顺带修掉 sourceUnit 被覆盖的隐患),
  补了会在缺这行时变红的回归测试
- 画像圈人性别男女都返回 0
- jest 吃满 CPU:transform 里的 isolatedModules 被挪进 tsconfig 后**更慢**
  (18.1s/165s vs 6.9s/43s),因为 pac-service 的 tsconfig 把它设成了 false。
  按弃用警告改反而变慢 —— 以实测为准。配 maxWorkers 50%
- 新增 tsconfig.typecheck.json(单测不覆盖类型,提交前得单独跑)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 3b735314
Pipeline #3542 failed in 0 seconds
...@@ -23,6 +23,43 @@ module.exports = { ...@@ -23,6 +23,43 @@ module.exports = {
'^@pac/types/(.*)$': '<rootDir>/../../packages/types/src/$1', '^@pac/types/(.*)$': '<rootDir>/../../packages/types/src/$1',
}, },
transform: { transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }], /**
* 🔴 `isolatedModules: true` —— **jest 不再做类型检查**,只做转译。
*
* ── 为什么(2026-08-06 实测)────────────────────────────────────
* 默认 ts-jest 会在**每个 worker 里各跑一个完整的 TypeScript program**,
* 而 `@pac/types` 在下面被映射到**源码**,于是每个 worker 都要把整个 types 包
* 连同 src 一起类型检查一遍。16 核机器上 jest 默认开 15 个 worker,
* 实测:15 个进程 × ~250MB,每个 75~100% CPU,**整机 16 核吃满**。
*
* 实测对比(同为热缓存,同一台 16 核 / 16GB):
* 默认 27.8s / CPU 时间 251s
* +isolatedModules ~10s
* +maxWorkers 50% 6.9s / CPU 时间 43s ← 现在这套
* 墙钟 4 倍,**CPU 时间 5.8 倍** —— 后者才是"跑测试时电脑卡"的直接原因。
*
* ⚠️⚠️ **代价:类型错误不会再让 jest 变红。**
* 类型安全**只能**靠单独那一趟:
* pnpm exec tsc --noEmit -p tsconfig.typecheck.json
* ⛔ 谁要把这一趟从流程里去掉,必须先把这里改回来 —— 否则两道闸同时没了。
* (那份 typecheck 配置本身就是为"tests/ 从来没被类型检查过"补的,见其注释。)
*/
/**
* ⚠️ ts-jest 会提示「isolatedModules 已废弃,请写到 tsconfig 里」—— **这里不能照做**。
* `apps/pac-service/tsconfig.json` 明确把 `isolatedModules` 设成了 **false**
* (它同时是 nest build / swc 用的那份,那个 flag 会限制 const enum 等写法),
* ts-jest 读到 false 就退回全量类型检查。
* 实测(2026-08-06):按提示挪进 tsconfig 后 → 18.1s / CPU 165s;
* 保留在这里 → **6.0s / CPU 43s**。⇒ 以实测为准,留在这里,忍受那条 WARN。
* ⛔ 想消 WARN 的话别去动 tsconfig 的 isolatedModules —— 那会改到编译产物。
*/
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json', isolatedModules: true }],
}, },
/**
* ⚠️ 默认是 `cores - 1`(16 核 → 15 个 worker),本地开发时**整机没有余量**:
* 还并行跑着 nest --watch、next dev、docker 里的 Postgres/Redis。
* 50% 让出一半的核,墙钟只慢一点点,但机器还能用。
* ⚠️ CI 上单独跑没有别的负载,可以用 `--maxWorkers=100%` 覆盖回来。
*/
maxWorkers: '50%',
}; };
...@@ -156,7 +156,9 @@ async function main(): Promise<void> { ...@@ -156,7 +156,9 @@ async function main(): Promise<void> {
select: { id: true }, select: { id: true },
}); });
const releaseOptions = releaseReasonsForForm().filter((r) => r !== ReleaseReason.OTHER); // ⚠️ 2026-08-05 起 releaseReasonsForForm() 本身已不含 'other'(它退成历史值),
// 所以这里不用再过滤 —— 留着 filter 反而会让人以为 other 还在候选里。
const releaseOptions = releaseReasonsForForm();
let released = 0; let released = 0;
let expired = 0; let expired = 0;
let viewed = 0; let viewed = 0;
......
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
EXTRACTION_NAME_KEYWORDS,
PersonaFeatureKey, PersonaFeatureKey,
gapTemperatureBounds, gapTemperatureBounds,
hottestBounds, hottestBounds,
...@@ -37,7 +38,9 @@ import { nextAgeBoundary } from './time-boundary'; ...@@ -37,7 +38,9 @@ import { nextAgeBoundary } from './time-boundary';
* - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。 * - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。
* - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。 * - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。
*/ */
const EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留']; /// ⚠️ 单一定义在 @pac/types —— 初选矩阵的 SQL 侧要用同一份(见 potential-label-rules.ts)。
/// ⛔ 别在这里另写一份:改了关键词而 SQL 没跟上,会出现「矩阵有这人、列表没有」且不报错。
const EXTRACTION_NAME_KW = EXTRACTION_NAME_KEYWORDS;
/** /**
* classifyGapToLabel 里的年龄闸(满这些岁数时,同一批 gap 的标签映射会变): * classifyGapToLabel 里的年龄闸(满这些岁数时,同一批 gap 的标签映射会变):
...@@ -164,29 +167,25 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -164,29 +167,25 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
}); });
/** /**
* ⭐ 窗口温度的边界时刻,**按标签 key 建映射,不塞进 detail[]**。 * ⛔ **不再写 `temperature`(2026-08 停写)** —— 初选矩阵的温度已改成
* 「从召回单证据的锚点**读时**算」(见 plan/reason-temperature.sql.ts)。
* *
* 为什么不放 detail[]:那是**数组**,同一个标签在不同患者身上的下标不一样, * 为什么停:边界烤进画像 JSON 有两个硬伤 ——
* 于是筛选路径 `detail.0.hotUntil` 对谁都不成立 —— Prisma 的 json 路径过滤 * ① 改一次 `DiagnosisTreatmentMap` 的窗口天数就要全量重算画像(实测 4 小时 54 分),
* (以及任何索引)都要求**稳定路径**。挪到 `temperature.<key>.hotUntil` 之后, * 而**不重算不会报错**,只是全按旧窗口判,静默失效。
* 召回池列表可以直接用现成的 Prisma where 按温度筛(`plan.service.buildListWhere`), * ② 画像回答「这个人有哪些潜在治疗」,矩阵要回答「引擎为什么召回他」——
* 矩阵与列表因此共用同一个引擎、天然对得上数(T14 的「口径对数」)。 * 两者本就不是一回事(实测 161 个格位画像有、召回单没有)。
* *
* **不是易变键** —— 同一批事实换个时刻重算值不变,所以 ⛔ 别加进 * ⚠️ 存量 JSON 里的 `temperature` 键**暂时留着不清**:它已经没有读取方,
* 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 { return {
key: this.key, key: this.key,
description: labels.join(' / '), description: labels.join(' / '),
score: null, score: null,
data: { types: keys, labels, detail, temperature }, data: { types: keys, labels, detail },
evidence: { factIds: [...factIds] }, evidence: { factIds: [...factIds] },
}; };
} }
......
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils'; import { calcAge, maskName, maskPhone } from '@pac/utils';
import { applyLiveDays, ApiCode, KIN_RELATIONSHIPS, resolveKinRelationship } from '@pac/types'; import {
applyLiveDays,
ApiCode,
focusOrderReasons,
KIN_RELATIONSHIPS,
resolveKinRelationship,
} from '@pac/types';
import { BizError } from '../../common/errors/biz-error'; import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
...@@ -41,7 +47,13 @@ export class PlanAggregateService { ...@@ -41,7 +47,13 @@ export class PlanAggregateService {
id: planId, id: planId,
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}), ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
}, },
include: { reasons: { orderBy: { priorityScore: 'desc' } } }, include: {
reasons: { orderBy: { priorityScore: 'desc' } },
// ⭐ 批次意图:聚焦哪条 reason 要看"这单是通过哪一格分下来的"(见下方 focusOrderReasons)。
// 只取 status/criteria 两列 —— 不要 include 整个 assignment,它带 attributes(福利文案)等
// 与本处无关的字段,白白进内存。
assignment: { select: { status: true, criteria: true } },
},
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
...@@ -108,7 +120,20 @@ export class PlanAggregateService { ...@@ -108,7 +120,20 @@ export class PlanAggregateService {
} }
} }
const assembled = await this.assemble(scope, patient, plan, agent); /**
* ⭐ 聚焦项按**批次意图**重排 —— 主管点「补牙」这一格分下来的单,客服打开就该先看补牙,
* 哪怕这人身上缺牙的分更高(实测补牙格 49% 的单不是以补牙为主因)。
* ⚠️ 判据是 `status === 'confirmed'`:撤销后批次意图作废,回落"分最高"。
* 与话术带不带福利同一个判据(plan-script.orchestrator 的 benefit),⛔ 别另立标准。
* ⚠️ 前端拿到后**还会再排一次**,那边必须走同一个函数,否则这里白改(见 plan-detail-app)。
*/
const focusLabel =
plan.assignment?.status === 'confirmed'
? ((plan.assignment.criteria as { potentialTreatment?: string } | null)?.potentialTreatment ?? null)
: null;
const focused = { ...plan, reasons: focusOrderReasons(plan.reasons, focusLabel), focusLabel };
const assembled = await this.assemble(scope, patient, focused, agent);
return { ...assembled, currentPlanId }; return { ...assembled, currentPlanId };
} }
...@@ -602,6 +627,8 @@ function serializePlan(plan: { ...@@ -602,6 +627,8 @@ function serializePlan(plan: {
snoozedUntil: Date | null; snoozedUntil: Date | null;
recallFeedback: string | null; recallFeedback: string | null;
recallFeedbackNote: string | null; recallFeedbackNote: string | null;
/// 批次意图(主管点的那一格)——⚠️ reasons **已按它排好序**,前端重排必须走同一个 focusOrderReasons
focusLabel?: string | null;
updatedAt: Date; updatedAt: Date;
reasons: Array<{ reasons: Array<{
id: string; id: string;
...@@ -640,6 +667,8 @@ function serializePlan(plan: { ...@@ -640,6 +667,8 @@ function serializePlan(plan: {
/// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null /// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null
recallFeedback: plan.recallFeedback ?? null, recallFeedback: plan.recallFeedback ?? null,
recallFeedbackNote: plan.recallFeedbackNote ?? null, recallFeedbackNote: plan.recallFeedbackNote ?? null,
/// 批次意图:这单是主管点哪一格分下来的;null=自助认领或批次已撤销(回落"分最高")
focusLabel: plan.focusLabel ?? null,
reasons: plan.reasons.map((r) => ({ reasons: plan.reasons.map((r) => ({
id: r.id, id: r.id,
scenario: r.scenario, scenario: r.scenario,
...@@ -710,7 +739,7 @@ function extractFactIds(evidence: unknown): string[] { ...@@ -710,7 +739,7 @@ function extractFactIds(evidence: unknown): string[] {
* *
* 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成: * 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成:
* > 患者:xxx · 语气:xxx * > 患者:xxx · 语气:xxx
* ## 开场白\n{opening}\n## 告知应治未治\n{informMissed}\n## 复查建议\n{reviewAdvice}\n## 结束回访语\n{closing} * ## 开场白\n{opening}\n## 告知潜在治疗\n{informMissed}\n## 复查建议\n{reviewAdvice}\n## 结束回访语\n{closing}
* 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。 * 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。
* *
* 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape), * 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape),
...@@ -737,15 +766,15 @@ function serializeScript(s: { ...@@ -737,15 +766,15 @@ function serializeScript(s: {
/** /**
* markdown H2 标题 → 段 id 的**解析键**。 * markdown H2 标题 → 段 id 的**解析键**。
* *
* ⚠️ 这里必须保持「告知应治未治」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」—— * ⚠️ 这里必须保持「告知潜在治疗」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」——
* 它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知应治未治`), * 它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知潜在治疗`),
* 以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段, * 以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段,
* 打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。 * 打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。
* 哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。 * 哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。
*/ */
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = { const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = {
开场白: 'opening', 开场白: 'opening',
告知应治未治: 'informMissed', 告知潜在治疗: 'informMissed',
复查建议: 'reviewAdvice', 复查建议: 'reviewAdvice',
结束回访语: 'closing', 结束回访语: 'closing',
}; };
...@@ -761,7 +790,7 @@ const SECTION_META: Record< ...@@ -761,7 +790,7 @@ const SECTION_META: Record<
function parseScriptMarkdownToSections(md: string) { function parseScriptMarkdownToSections(md: string) {
// ⭐ 通用 H2 切分:每个 `## 标题` 起一段,到下一个 H2 之间为内容。 // ⭐ 通用 H2 切分:每个 `## 标题` 起一段,到下一个 H2 之间为内容。
// - 稳健档:4 个固定标题(开场白/告知应治未治/复查建议/结束回访语)→ 映射到已知 id + 固定 label。 // - 稳健档:4 个固定标题(开场白/告知潜在治疗/复查建议/结束回访语)→ 映射到已知 id + 固定 label。
// - 标准/深度档:自由标题(段数不定)→ id=`s{序号}`、label=原标题。 // - 标准/深度档:自由标题(段数不定)→ id=`s{序号}`、label=原标题。
// 旧实现只认稳健 4 固定标题 → 深度/标准的自由标题全 currentId=null、内容丢弃 → 刷新后空白 // 旧实现只认稳健 4 固定标题 → 深度/标准的自由标题全 currentId=null、内容丢弃 → 刷新后空白
// (plan_scripts 存了内容,但反 parse 解不出 → 前端"尚未生成参考话术")。本版按任意 H2 解析,三档通用。 // (plan_scripts 存了内容,但反 parse 解不出 → 前端"尚未生成参考话术")。本版按任意 H2 解析,三档通用。
......
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
PERSONA_TAG_FILTER_DIMS, PERSONA_TAG_FILTER_DIMS,
personaTagDimId, personaTagDimId,
visitRecencyRange, visitRecencyRange,
VISIT_RECENCY_BUCKETS, VISIT_RECENCY_BUCKETS,
TEMPERATURE_ORDER,
type PersonaTagFilterDim, type PersonaTagFilterDim,
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 { cohortWhereSql, poolBaseSql, type CohortCriteria } from './cohort-filter'; import { cohortWhereSql, poolBaseSql, type CohortCriteria } from './cohort-filter';
import {
AGE_YEARS_SQL,
labelCaseSql,
planLabelAnchorsSql,
temperatureBucketCaseSql,
} from './reason-temperature.sql';
/** /**
* CohortAttributesService —— T9-B「调整阶段画像圈人」的取数层。**纯只读**。 * CohortAttributesService —— T9-B「调整阶段画像圈人」的取数层。**纯只读**。
...@@ -55,6 +63,8 @@ export interface CohortAttributeDim { ...@@ -55,6 +63,8 @@ export interface CohortAttributeDim {
@Injectable() @Injectable()
export class CohortAttributesService { export class CohortAttributesService {
private readonly logger = new Logger(CohortAttributesService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** /**
...@@ -71,27 +81,22 @@ export class CohortAttributesService { ...@@ -71,27 +81,22 @@ export class CohortAttributesService {
* ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 temperature.ts 第 ③ 条 / T14)。 * ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 temperature.ts 第 ③ 条 / T14)。
*/ */
async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) { async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) {
void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂)
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ label: string; temp: string; n: bigint }> Array<{ label: string; temp: string | null; n: bigint }>
>( >(
Prisma.sql` Prisma.sql`
SELECT lbl AS label, WITH la AS (
CASE ${planLabelAnchorsSql(poolBaseSql(scope, clinicId))}
WHEN b->>'hotUntil' IS NULL THEN 'unknown' )
WHEN ${now} <= (b->>'hotUntil')::timestamptz THEN 'hot' SELECT label,
WHEN ${now} <= (b->>'warmUntil')::timestamptz THEN 'warm' ${temperatureBucketCaseSql(
ELSE 'cold' Prisma.raw('hot_until'),
END AS temp, Prisma.raw('warm_until'),
count(DISTINCT fp.patient_id) AS n Prisma.raw('anchor_at'),
FROM followup_plans fp )} AS temp,
JOIN patients p ON p.id = fp.patient_id count(DISTINCT patient_id) AS n
JOIN personas pe ON pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL FROM la
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 GROUP BY 1, 2
`, `,
); );
...@@ -99,41 +104,39 @@ export class CohortAttributesService { ...@@ -99,41 +104,39 @@ export class CohortAttributesService {
const cells = new Map<string, Record<string, number>>(); const cells = new Map<string, Record<string, number>>();
for (const r of rows) { for (const r of rows) {
const row = cells.get(r.label) ?? {}; const row = cells.get(r.label) ?? {};
row[r.temp] = Number(r.n); row[r.temp ?? 'unknown'] = Number(r.n);
cells.set(r.label, row); cells.set(r.label, row);
} }
// 行序照 PERSONA_TAG_FILTER_DIMS 的声明序(= 业务上「客服最先问什么」的排序), // 行序照 PERSONA_TAG_FILTER_DIMS 的声明序(= 业务上「客服最先问什么」的排序),
// ⛔ 别按人数排 —— 那会让矩阵每天换一个样子,主管的肌肉记忆全废。 // ⛔ 别按人数排 —— 那会让矩阵每天换一个样子,主管的肌肉记忆全废。
const labelDim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!; const labelDim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
const unknownTotal = rows const unknownTotal = rows.filter((r) => r.temp == null).reduce((a, r) => a + Number(r.n), 0);
.filter((r) => r.temp === 'unknown')
.reduce((a, r) => a + Number(r.n), 0);
return { return {
clinicId, clinicId,
rows: labelDim.options.map((o) => { rows: labelDim.options.map((o) => {
const c = cells.get(o.value) ?? {}; const c = cells.get(o.value) ?? {};
const hot = c.hot ?? 0; // ⚠️ counts 走 map 不平铺 —— 档位会变(3→6),平铺每加一档就要改一圈类型和前端
const warm = c.warm ?? 0; const counts = Object.fromEntries(TEMPERATURE_ORDER.map((t) => [t, c[t] ?? 0])) as Record<
const cold = c.cold ?? 0; TemperatureValue,
number
>;
const unknown = c.unknown ?? 0; const unknown = c.unknown ?? 0;
return { return {
key: o.value, key: o.value,
zh: o.zh, zh: o.zh,
hint: o.hint, hint: o.hint,
hot, counts,
warm,
cold,
unknown, unknown,
// ⚠️ total 含 unknown:它就是「点这一行能拿到多少人」,少算了主管会以为丢了人 // ⚠️ total 含 unknown:它就是「点这一行能拿到多少人」,少算了主管会以为丢了人
total: hot + warm + cold + unknown, total: TEMPERATURE_ORDER.reduce((a, t) => a + counts[t], 0) + unknown,
}; };
}), }),
unknownTotal, unknownTotal,
note: note:
unknownTotal > 0 unknownTotal > 0
? `另有 ${unknownTotal} 人**温度待重算**(画像还没算出窗口边界),已单列在「待重算」列,` + ? `另有 ${unknownTotal} 人**算不出温度**(召回证据里没有可用的诊断日),已单列,` +
`⛔ 没有并进「冷」—— 并进去数字好看但那是假分布。这是重算进度,不是数据缺失。` `⛔ 没有并进任何冷档 —— 并进去数字好看但那是假分布。`
: '', : '',
}; };
} }
...@@ -156,6 +159,10 @@ export class CohortAttributesService { ...@@ -156,6 +159,10 @@ export class CohortAttributesService {
); );
const patientIds = rows.map((r) => r.patientId); const patientIds = rows.map((r) => r.patientId);
const cohortSize = patientIds.length; const cohortSize = patientIds.length;
// ⭐ 与 assignment-proposal 那行**成对**存在:一个记"看分布时按什么条件、多少人",
// 一个记"出确认单时按什么条件、多少人"。两个数对不上时,这两行是唯一的证据。
// ⛔ 别删其中任何一行 —— 只留一半就又变成"只能猜是哪边错了"。
this.logger.log(`分布:条件=${JSON.stringify(criteria)} 人数=${cohortSize}`);
const dims = resolveDims(opts.keys); const dims = resolveDims(opts.keys);
if (cohortSize === 0) { if (cohortSize === 0) {
...@@ -194,7 +201,7 @@ export class CohortAttributesService { ...@@ -194,7 +201,7 @@ export class CohortAttributesService {
`标了 multi 的维度一个人可命中多项,合计会大于 ${cohortSize},别拿它算百分比。` + `标了 multi 的维度一个人可命中多项,合计会大于 ${cohortSize},别拿它算百分比。` +
`专属客服覆盖只报有/无,**不判在岗** —— 谁在休假、谁离职了,以主管说的为准。` + `专属客服覆盖只报有/无,**不判在岗** —— 谁在休假、谁离职了,以主管说的为准。` +
(temperatureUnknown (temperatureUnknown
? `⚠️ 另有 ${temperatureUnknown} 人**温度待重算**(画像还没算出窗口边界),` + ? `⚠️ 另有 ${temperatureUnknown} 人**算不出温度**(召回证据里没有可用的诊断日),` +
`所以热/温/冷三档加起来会少这些人。这是重算进度,不是数据缺失,如实说明即可。` `所以热/温/冷三档加起来会少这些人。这是重算进度,不是数据缺失,如实说明即可。`
: ''), : ''),
}; };
...@@ -203,8 +210,9 @@ export class CohortAttributesService { ...@@ -203,8 +210,9 @@ export class CohortAttributesService {
/** /**
* 「温度未知」的条数 —— 三档之和对不上总数时,差额在这里,**必须报出来**。 * 「温度未知」的条数 —— 三档之和对不上总数时,差额在这里,**必须报出来**。
* *
* 谁会是未知:本次改动**之前**算出来的画像还没有边界字段。上线到全量重算跑完之间 * 谁会是未知(2026-08 换源后):召回证据里**没有可用的诊断日**(锚点缺失)。
* 一定有一段窗口,那期间矩阵三格加起来会少于该行总数。 * 换源前的语义是"画像还没算出边界",那个原因已经不存在了 —— 温度改成读时从锚点算,
* 不再依赖画像里存好的边界,所以也**不再有"等重算"这回事**。
* ⛔ 把它们塞进「冷」能让数字对上,但那是**假的分布**(见 temperature.ts 的第 ③ 条 / T14)。 * ⛔ 把它们塞进「冷」能让数字对上,但那是**假的分布**(见 temperature.ts 的第 ③ 条 / T14)。
* ✅ 报出来,主管看到「另有 N 人温度待重算」,知道这是进度问题不是数据问题。 * ✅ 报出来,主管看到「另有 N 人温度待重算」,知道这是进度问题不是数据问题。
* *
...@@ -216,7 +224,7 @@ export class CohortAttributesService { ...@@ -216,7 +224,7 @@ export class CohortAttributesService {
now: Date, now: Date,
): Promise<number | null> { ): Promise<number | null> {
if (!criteria.potentialTreatment) return null; if (!criteria.potentialTreatment) return null;
// 去掉温度条件,只问「这个治疗项下,有几个人算不出边界 // 去掉温度条件,只问「这个治疗项下,有几个人算不出
const { temperature: _drop, ...noTemp } = criteria; const { temperature: _drop, ...noTemp } = criteria;
const rows = await this.prisma.$queryRaw<Array<{ n: bigint }>>( const rows = await this.prisma.$queryRaw<Array<{ n: bigint }>>(
Prisma.sql` Prisma.sql`
...@@ -225,10 +233,13 @@ export class CohortAttributesService { ...@@ -225,10 +233,13 @@ export class CohortAttributesService {
JOIN patients p ON p.id = fp.patient_id JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, noTemp, now)} WHERE ${cohortWhereSql(scope, noTemp, now)}
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM personas pe SELECT 1
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment' FROM plan_reasons pr
WHERE pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
AND pf.data #> ${['temperature', criteria.potentialTreatment]}::text[] IS NOT NULL JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL)} = ${criteria.potentialTreatment}
AND COALESCE(f.occurred_at, f.planned_for) IS NOT NULL
) )
`, `,
); );
......
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
PERSONA_TAG_FILTER_DIMS, PERSONA_TAG_FILTER_DIMS,
expandTemperatureFilter,
Temperature, Temperature,
parsePersonaTags, parsePersonaTags,
personaTagDimId, personaTagDimId,
...@@ -8,6 +9,7 @@ import { ...@@ -8,6 +9,7 @@ import {
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature.sql';
/** /**
* 「这批人是谁」的**唯一**取数口径 —— 出确认单(assignment-proposal)与看属性分布 * 「这批人是谁」的**唯一**取数口径 —— 出确认单(assignment-proposal)与看属性分布
...@@ -71,13 +73,7 @@ const CURRENT_PERSONA = Prisma.sql`pe.patient_id = fp.patient_id AND pe.supersed ...@@ -71,13 +73,7 @@ const CURRENT_PERSONA = Prisma.sql`pe.patient_id = fp.patient_id AND pe.supersed
/** X 轴:潜在治疗(data->'types' 是字符串数组,与 persona-tag-filters 同口径用 @> 命中) */ /** X 轴:潜在治疗(data->'types' 是字符串数组,与 persona-tag-filters 同口径用 @> 命中) */
function treatmentSql(potentialTreatment: string): Prisma.Sql { function treatmentSql(potentialTreatment: string): Prisma.Sql {
return Prisma.sql` return labelExistsSql(potentialTreatment);
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
)`;
} }
/** /**
...@@ -93,24 +89,9 @@ function temperatureSql( ...@@ -93,24 +89,9 @@ function temperatureSql(
temperature: TemperatureValue, temperature: TemperatureValue,
now: Date, now: Date,
): Prisma.Sql { ): Prisma.Sql {
// ⭐ 稳定路径 `temperature.<标签>.hotUntil`(不是 detail 数组下标)—— void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂)
// 同一个谓词列表页用 Prisma 也能表达,两边因此天然对得上数。 // ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档
const hotUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'hotUntil']}::text[])::timestamptz`; return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature));
const warmUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'warmUntil']}::text[])::timestamptz`;
const phase =
temperature === Temperature.HOT
? Prisma.sql`${now} <= ${hotUntil}`
: temperature === Temperature.WARM
? 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'
WHERE ${CURRENT_PERSONA}
AND pf.data #> ${['temperature', potentialTreatment]}::text[] IS NOT NULL
AND ${phase}
)`;
} }
/** /**
......
...@@ -5,7 +5,7 @@ import { ...@@ -5,7 +5,7 @@ import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
Permission, Permission,
PlanEventType, PlanEventType,
...@@ -22,6 +22,7 @@ import { ...@@ -22,6 +22,7 @@ import {
personaTagDimId, personaTagDimId,
type PersonaTagFilterDim, type PersonaTagFilterDim,
visitRecencyRange, visitRecencyRange,
expandTemperatureFilter,
} from '@pac/types'; } from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils'; import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
...@@ -38,6 +39,7 @@ import type { ...@@ -38,6 +39,7 @@ import type {
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity'; import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity'; import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperature.sql';
/** /**
* PlanService — Plan 维度 CRUD(list / detail / assign / recycle / recompute / submitExecution) * PlanService — Plan 维度 CRUD(list / detail / assign / recycle / recompute / submitExecution)
...@@ -120,7 +122,7 @@ export class PlanService { ...@@ -120,7 +122,7 @@ export class PlanService {
query: ListPlansQueryDto, query: ListPlansQueryDto,
permissions: readonly string[], permissions: readonly string[],
): Promise<ListPlansResponse> { ): Promise<ListPlansResponse> {
const where = this.buildListWhere(scope, query, permissions); const where = await this.buildListWhere(scope, query, permissions);
// W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确 // W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] = const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] =
...@@ -290,14 +292,53 @@ export class PlanService { ...@@ -290,14 +292,53 @@ export class PlanService {
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
/**
* 矩阵某一格(治疗项 × 温度)对应的 planId —— 与矩阵端点**同一份 SQL**。
*
* ⭐ 存在的理由:换成 reason 驱动后,两根轴的判定要 lateral 展开证据 + 按标签聚合再判档,
* Prisma 的 JSON 谓词表达不了。与其在列表侧写一套"近似等价"的 Prisma 版(必然漂),
* 不如直接复用同一段 SQL 取 id —— 口径**字面同源**,不需要靠测试去守等价性。
*
* ⚠️ 不传 temperature = 整行(六档全要),此时只按标签过滤。
*/
private async cellPlanIds(
scope: TenantScopeContext,
query: ListPlansQueryDto,
): Promise<string[]> {
const rows = await this.prisma.$queryRaw<Array<{ plan_id: string }>>(
Prisma.sql`
WITH la AS (
${planLabelAnchorsSql(
Prisma.sql`fp.host_id = ${scope.hostId}::uuid AND fp.tenant_id = ${scope.tenantId} AND fp.superseded_at IS NULL`,
)}
)
SELECT plan_id FROM la
WHERE label = ${query.potentialTreatment}
${
query.temperature
? Prisma.sql`AND ${temperatureBucketCaseSql(
Prisma.raw('hot_until'),
Prisma.raw('warm_until'),
Prisma.raw('anchor_at'),
)} IN (${Prisma.join(
expandTemperatureFilter(query.temperature).map((t) => Prisma.sql`${t}`),
', ',
)})`
: Prisma.empty
}
`,
);
return rows.map((r) => r.plan_id);
}
// buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人) // buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
private buildListWhere( private async buildListWhere(
scope: TenantScopeContext, scope: TenantScopeContext,
query: ListPlansQueryDto, query: ListPlansQueryDto,
permissions: readonly string[], permissions: readonly string[],
): Prisma.FollowupPlanWhereInput { ): Promise<Prisma.FollowupPlanWhereInput> {
// 温度必须挂在治疗项上 —— 光给 temperature 会静默筛出一个**没人要的人群** // 温度必须挂在治疗项上 —— 光给 temperature 会静默筛出一个**没人要的人群**
// (同一个人可能对种植是热、对补牙是冷)。⛔ 不许"贴心地"给个默认治疗项。 // (同一个人可能对种植是热、对补牙是冷)。⛔ 不许"贴心地"给个默认治疗项。
if (query.temperature && !query.potentialTreatment) { if (query.temperature && !query.potentialTreatment) {
...@@ -429,48 +470,31 @@ export class PlanService { ...@@ -429,48 +470,31 @@ export class PlanService {
} }
} }
/**
* 🔴🔴 **把 patientWhere 挂上去** —— 上面攒的 keyword / phoneVerified / 画像标签
* 全都写在这个局部对象里,**不挂就等于一条都没生效**。
*
* ⚠️ 实测事故(2026-08-06):换初选口径时,旧的温度谓词块紧贴在这三行前面,
* 删旧块时把这里**一起删掉了**。后果是列表页的姓名搜索、"真号码"筛选、
* 画像圈人**同时静默失效** —— 搜「黄」照样返回全部 1729 人,不报错、不空、
* 看起来只是"没搜着"。走查时才被发现。
* ⛔ 任何时候改这一段,先确认这三行还在。
*/
if (Object.keys(patientWhere).length > 0) {
where.patient = { ...(where.patient as Prisma.PatientWhereInput), ...patientWhere };
}
// ── 初选矩阵的两根轴(治疗项 × 窗口温度)──────────────────────── // ── 初选矩阵的两根轴(治疗项 × 窗口温度)────────────────────────
// ⭐ 点矩阵格子进来的就是这条路。**必须与矩阵端点算出同一批人**, // ⭐ 点矩阵格子进来的就是这条路。**必须与矩阵端点算出同一批人**,
// 否则「格子里写 44,点进去列表 373」—— 主管对整个功能的信任当场没了(T14 口径对数)。 // 否则「格子里写 44,点进去列表 373」—— 主管对整个功能的信任当场没了(T14 口径对数)。
// 两边能对上是因为温度边界存成了**稳定路径** `temperature.<标签>.hotUntil`: //
// 矩阵走原生 SQL、这里走 Prisma,但谓词字面等价(见 temperature.ts 与 cohort-filter.ts)。 // ⚠️ 2026-08 换源后**不能再用 Prisma 谓词**表达:两根轴改从召回单证据算
// (plan_reasons → patient_facts,要 lateral 展开 + 按标签聚合再判档),Prisma 表达不了。
// 所以这里**先用同一份 SQL 取出 planId**,再交给 Prisma 分页排序 ——
// ⛔ 别在这里另写一套 Prisma 版口径,那正是"两套实现"漂移的起点。
// 量级可控:一格最多几百人(实测本地库最大格 222),`id IN (...)` 完全够用。
if (query.potentialTreatment) { if (query.potentialTreatment) {
const nowIso = new Date().toISOString(); where.id = { in: await this.cellPlanIds(scope, query) };
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;
} }
return where; return where;
...@@ -489,7 +513,7 @@ export class PlanService { ...@@ -489,7 +513,7 @@ export class PlanService {
permissions: readonly string[], permissions: readonly string[],
): Promise<PlanQueueStatsResponse> { ): Promise<PlanQueueStatsResponse> {
// 默认 pool 视图(今日推荐场景);调用方可显式传 mine。 // 默认 pool 视图(今日推荐场景);调用方可显式传 mine。
const where = this.buildListWhere( const where = await this.buildListWhere(
scope, scope,
{ ...query, view: query.view ?? 'pool' } as ListPlansQueryDto, { ...query, view: query.view ?? 'pool' } as ListPlansQueryDto,
permissions, permissions,
......
import { Prisma } from '@prisma/client';
import {
COLD_BUCKET_YEARS,
COLD_TEMPERATURES,
DiagnosisTreatmentMap,
POTENTIAL_LABEL_RULES,
Temperature,
type TemperatureValue,
} from '@pac/types';
/**
* 初选两根轴的**取数口径** —— 从召回单自己的证据算,不经过画像。
*
* ═══ 为什么换源(2026-08)═════════════════════════════════════════════
* 原来 X 轴读画像 `potential_treatment.types`、Y 轴读画像里存好的 `hotUntil/warmUntil`。
* 问题:画像回答的是「**这个人有哪些潜在治疗**」,而矩阵是**召回池的视图**,
* 该回答「**引擎为什么要召回他**」。两者不是一回事 ——
* 实测:161 个「患者×标签」格位在画像里有、但召回单根本没为这个标签立 reason。
* 主管点「补牤」那一格捞到这批人,客服打开一看 plan 讲的是缺牙 —— 格子和人对不上。
*
* ✅ 换成:两根轴都从 `plan_reasons.evidence.factIds → patient_facts` 取。
* 实测 100% 的在跑单都能追到带锚点和诊断码的事实,不存在取不到的情况。
*
* ═══ 顺带解决的 ═══════════════════════════════════════════════════
* ⭐ **改窗口配置不再需要重算画像**。边界从锚点**读时**算,`DiagnosisTreatmentMap`
* 一改立刻生效。原来边界烤在 44 万行 JSON 里,调一次窗口要跑 4.9 小时全量重算,
* 而且不重算**不会报错**,只是全按旧窗口判。
*
* ⚠️ 本文件的 SQL 全部由 `@pac/types` 的常量**生成**,⛔ 一个数字都不许手抄:
* `POTENTIAL_LABEL_RULES`(码→标签,含年龄闸/关键词)、`DiagnosisTreatmentMap`(窗口天数)、
* `COLD_BUCKET_YEARS`(冷端年数)。手抄一份 = 两份真理源,分叉了也不报错。
*/
/** 患者周岁 —— 与 `ageYearsAt` 同语义(Postgres 的 age() 是生日感知的完整年数) */
export const AGE_YEARS_SQL = Prisma.sql`date_part('year', age(NOW(), p.birth_date))::int`;
/** 同上,给**外层别名是 `p`** 的关联片段用(两处别名一致,留两个常量只为可读) */
export const AGE_YEARS_SQL_P = AGE_YEARS_SQL;
/**
* 事实 → 业务标签的 CASE,**从 POTENTIAL_LABEL_RULES 生成**。
*
* @param factAlias 事实表别名(需有 content jsonb)
* @param ageSql 患者周岁表达式
*
* ⚠️ 规则表是**顺序敏感**的(K03 含关键词→拔牙 必须在 K03→修复 之前),
* CASE 的 WHEN 天然按顺序求值,与 TS 侧 `classifyCodeToLabel` 的 for-return 一致。
* ⚠️ 年龄为 NULL 时,带年龄条件的 WHEN 求值为 NULL(非真)→ 不命中 → 落 NULL。
* 这与 TS 侧「无生日一律不命中」一致,⛔ 别加 COALESCE 兜底。
*/
export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql {
const code = Prisma.raw(`${factAlias}.content->>'code'`);
const name = Prisma.raw(`COALESCE(${factAlias}.content->>'name_zh', '')`);
const whens = POTENTIAL_LABEL_RULES.map((r) => {
const conds: Prisma.Sql[] = [Prisma.sql`${code} = ${r.code}`];
if (r.ageGt !== undefined) conds.push(Prisma.sql`${ageSql} > ${r.ageGt}`);
if (r.ageMin !== undefined) conds.push(Prisma.sql`${ageSql} >= ${r.ageMin}`);
if (r.ageMax !== undefined) conds.push(Prisma.sql`${ageSql} <= ${r.ageMax}`);
if (r.nameAny?.length) {
// 用 position() 逐词判包含 —— ⛔ 别用正则:关键词是业务配置,可能含正则元字符
const anyOf = r.nameAny.map((k) => Prisma.sql`position(${k} in ${name}) > 0`);
conds.push(Prisma.sql`(${Prisma.join(anyOf, ' OR ')})`);
}
return Prisma.sql`WHEN ${Prisma.join(conds, ' AND ')} THEN ${r.label}`;
});
return Prisma.sql`CASE ${Prisma.join(whens, ' ')} ELSE NULL END`;
}
/**
* 窗口配置表(code, urgencyDays, windowDays)—— 从 `DiagnosisTreatmentMap` 生成的 VALUES。
* 只含 8 类标签实际用到的组主码;⛔ 别手写数字。
*/
export function windowValuesSql(): Prisma.Sql {
const codes = [...new Set(POTENTIAL_LABEL_RULES.map((r) => r.code))];
const rows = codes
.map((c) => ({ c, rule: (DiagnosisTreatmentMap as Record<string, { urgencyDayThreshold: number; windowDays: number }>)[c] }))
.filter((x) => !!x.rule)
.map((x) => Prisma.sql`(${x.c}, ${x.rule!.urgencyDayThreshold}, ${x.rule!.windowDays})`);
return Prisma.sql`(VALUES ${Prisma.join(rows, ', ')}) AS w(code, urg, wnd)`;
}
/**
* 六档判定的 CASE。
*
* ⭐ **一个锚点走到底**:`anchor` = 该标签最新的未治疗诊断。
* 前两档按该治疗项自己的临床窗口、后四档按绝对年数,量的都是「距同一个锚点多久」。
* ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义,
* 365 天算法在闰年会漂,两边就对不上数。
* ⚠️ 锚点/边界为 NULL → 落 NULL(调用方显式呈现"未知"),⛔ 不许默认塞进某个冷档。
*/
export function temperatureBucketCaseSql(
hotUntil: Prisma.Sql,
warmUntil: Prisma.Sql,
anchor: Prisma.Sql,
): Prisma.Sql {
const colds = COLD_TEMPERATURES.filter((t) => COLD_BUCKET_YEARS[t] != null).map(
(t) =>
Prisma.sql`WHEN NOW() <= ${anchor} + ${Prisma.raw(`interval '${COLD_BUCKET_YEARS[t]} years'`)} THEN ${t}`,
);
return Prisma.sql`
CASE
WHEN ${hotUntil} IS NULL OR ${anchor} IS NULL THEN NULL
WHEN NOW() <= ${hotUntil} THEN ${Temperature.HOT}
WHEN NOW() <= ${warmUntil} THEN ${Temperature.WARM}
${Prisma.join(colds, ' ')}
ELSE ${Temperature.COLD_OVER}
END`;
}
/**
* 每个 (plan, 标签) 一行:两个边界 + 锚点。**独立查询**,给矩阵用。
*
* ⚠️ 别名固定 `fp`(followup_plans)/ `p`(patients)—— 因为 `planFilter` 传进来的是
* `poolBaseSql`,它引用的就是这两个别名。改名就得同步改那边,⛔ 别只改一处。
* ⚠️ 关联子查询场景**不要用本函数**(内部的 `fp` 会遮蔽外层同名别名,
* 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。
*
* ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当锚 =
* 对着一个已经做完的诊断说"您还没做"。当前数据 5,034 条证据全是 active,
* 这条过滤是**不变量守卫** —— 治疗落库后引擎还没重算的窗口期里,它就是唯一防线。
* ⚠️ 三个聚合都用 max(取最热 / 取最新)—— 见 temperature.ts:统一成"最早"会让
* hot/warm 反单调(多一条旧需求反而更冷)。
*/
export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
return Prisma.sql`
SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
max(anc.at + (w.urg || ' days')::interval) AS hot_until,
max(anc.at + (w.wnd || ' days')::interval) AS warm_until,
max(anc.at) AS anchor_at
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab
JOIN ${windowValuesSql()} ON w.code = f.content->>'code'
WHERE ${planFilter}
AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL
GROUP BY 1, 2, 3`;
}
/**
* 关联片段:外层 `fp`(followup_plans)/ `p`(patients)必须在作用域内。
*
* ⭐ **不重新 SELECT followup_plans** —— 直接从 plan_reasons 出发关联 `pr.plan_id = fp.id`,
* 这样内部没有 `fp` 别名,不会遮蔽外层(那是上面那个函数不能用在这里的原因)。
*/
export function labelExistsSql(label: string): Prisma.Sql {
return Prisma.sql`
AND EXISTS (
SELECT 1
FROM plan_reasons pr
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
)`;
}
/**
* 关联片段:该 plan 在 `label` 这一格上的温度落在 `buckets` 之内。
*
* ⚠️ 必须先聚合再判档 —— 温度是「该标签所有未治疗证据**取最热**」后的结果,
* 逐条判再 OR 会把"有一条还热"错读成整格热(反过来也一样)。所以走 GROUP BY + HAVING。
*/
export function labelTemperatureExistsSql(
label: string,
buckets: readonly TemperatureValue[],
): Prisma.Sql {
return Prisma.sql`
AND EXISTS (
SELECT 1
FROM plan_reasons pr
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
JOIN ${windowValuesSql()} ON w.code = f.content->>'code'
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
AND anc.at IS NOT NULL
HAVING ${temperatureBucketCaseSql(
Prisma.raw("max(anc.at + (w.urg || ' days')::interval)"),
Prisma.raw("max(anc.at + (w.wnd || ' days')::interval)"),
Prisma.raw('max(anc.at)'),
)} IN (${Prisma.join(
buckets.map((t) => Prisma.sql`${t}`),
', ',
)})
)`;
}
...@@ -148,38 +148,47 @@ describe('画像圈人 —— 维度点名', () => { ...@@ -148,38 +148,47 @@ describe('画像圈人 —— 维度点名', () => {
}); });
describe('初选矩阵', () => { describe('初选矩阵', () => {
const matrixPrisma = (rows: Array<{ label: string; temp: string; n: number }>) => const matrixPrisma = (rows: Array<{ label: string; temp: string | null; n: number }>) =>
({ ({
$queryRaw: jest.fn(async () => rows.map((r) => ({ ...r, n: BigInt(r.n) }))), $queryRaw: jest.fn(async () => rows.map((r) => ({ ...r, n: BigInt(r.n) }))),
}) as unknown as PrismaService; }) as unknown as PrismaService;
test('⭐⭐ 「待重算」单独一列,⛔ 不许并进「冷」', async () => { test('⭐⭐ 「算不出温度」单独一列,⛔ 不许并进任何一个冷档', async () => {
// 并进去行合计好看,但那是**假分布** —— 主管会以为那些人真的超窗了。 // 并进去行合计好看,但那是**假分布** —— 主管会以为那些人真的超窗了。
// 上线到全量重算跑完之间这一列一定非零,不能等出了事再补 // ⚠️ SQL 侧 temp 为 NULL 时这里收到的是 null,不是字符串 'unknown'
const svc = await build( const svc = await build(
matrixPrisma([ matrixPrisma([
{ label: 'implant', temp: 'hot', n: 10 }, { label: 'implant', temp: 'hot', n: 10 },
{ label: 'implant', temp: 'cold', n: 5 }, { label: 'implant', temp: 'cold_1y', n: 5 },
{ label: 'implant', temp: 'unknown', n: 3 }, { label: 'implant', temp: null, n: 3 },
]), ]),
); );
const m = await svc.matrix(SCOPE, 'c1'); const m = await svc.matrix(SCOPE, 'c1');
const implant = m.rows.find((r) => r.key === 'implant')!; const implant = m.rows.find((r) => r.key === 'implant')!;
expect(implant.cold).toBe(5); // ⛔ 不是 8 expect(implant.counts.cold_1y).toBe(5); // ⛔ 不是 8
expect(implant.unknown).toBe(3); expect(implant.unknown).toBe(3);
expect(m.unknownTotal).toBe(3); expect(m.unknownTotal).toBe(3);
expect(m.note).toContain('温度待重算'); expect(m.note).toContain('算不出温度');
expect(m.note).toContain('没有并进'); expect(m.note).toContain('没有并进');
}); });
test('⭐⭐ 六档都要在 counts 里出现(没人的给 0)—— ⛔ 缺键会让前端渲染成空白而不是 0', async () => {
const svc = await build(matrixPrisma([{ label: 'implant', temp: 'hot', n: 1 }]));
const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!;
expect(Object.keys(implant.counts).sort()).toEqual(
['cold_1y', 'cold_2y', 'cold_3y', 'cold_over', 'hot', 'warm'].sort(),
);
expect(implant.counts.cold_over).toBe(0);
});
test('⭐⭐ total 含 unknown —— 它就是「点这一行能拿到多少人」', async () => { test('⭐⭐ total 含 unknown —— 它就是「点这一行能拿到多少人」', async () => {
// 少算了主管会以为系统丢了人;逐行对数(矩阵行合计 vs 圈人结果)靠的就是这条。 // 少算了主管会以为系统丢了人;逐行对数(矩阵行合计 vs 圈人结果)靠的就是这条。
const svc = await build( const svc = await build(
matrixPrisma([ matrixPrisma([
{ label: 'implant', temp: 'hot', n: 10 }, { label: 'implant', temp: 'hot', n: 10 },
{ label: 'implant', temp: 'warm', n: 2 }, { label: 'implant', temp: 'warm', n: 2 },
{ label: 'implant', temp: 'cold', n: 5 }, { label: 'implant', temp: 'cold_1y', n: 5 },
{ label: 'implant', temp: 'unknown', n: 3 }, { label: 'implant', temp: null, n: 3 },
]), ]),
); );
const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!; const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!;
...@@ -195,7 +204,10 @@ describe('初选矩阵', () => { ...@@ -195,7 +204,10 @@ describe('初选矩阵', () => {
expect(m.rows.map((r) => r.key)).toEqual([ expect(m.rows.map((r) => r.key)).toEqual([
'implant', 'ortho', 'early_ortho', 'endo', 'perio', 'filling', 'restoration', 'extraction', '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 }); expect(m.rows.find((r) => r.key === 'implant')).toMatchObject({
counts: { hot: 0, warm: 0, cold_1y: 0, cold_2y: 0, cold_3y: 0, cold_over: 0 },
total: 0,
});
}); });
test('⭐ 没有待重算的人时 note 为空 —— 不制造无谓的告警噪音', async () => { test('⭐ 没有待重算的人时 note 为空 —— 不制造无谓的告警噪音', async () => {
......
...@@ -47,78 +47,80 @@ describe('人群取数 —— 画像必须按患者取当前版', () => { ...@@ -47,78 +47,80 @@ describe('人群取数 —— 画像必须按患者取当前版', () => {
expect(all).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/); expect(all).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/);
}); });
test('⭐ 三处(治疗项/温度/画像标签)全部按 patient_id + 当前版关联', () => { test('⭐ 仍走画像的**只剩画像标签**一处 —— 两根轴 2026-08 已换成召回单证据', () => {
expect(all.match(/pe\.patient_id = fp\.patient_id/g)?.length).toBe(3); // 换源前是三处(治疗项/温度/画像标签);现在治疗项和温度都不再碰 personas。
expect(all.match(/pe\.superseded_at IS NULL/g)?.length).toBe(3); expect(all.match(/pe\.patient_id = fp\.patient_id/g)?.length).toBe(1);
expect(all.match(/pe\.superseded_at IS NULL/g)?.length).toBe(1);
}); });
}); });
describe('人群取数 —— 温度', () => { describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () => {
const hot = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);
test('⭐⭐ 温度必须挂在治疗项上:光给温度直接拒', () => { test('⭐⭐ 温度必须挂在治疗项上:光给温度直接拒', () => {
// 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的"热"是无意义的。 // 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的"热"是无意义的。
// ⛔ 尤其不许"贴心地"给个默认治疗项 —— 那会让主管拿到一批他没要的人。 // ⛔ 尤其不许"贴心地"给个默认治疗项 —— 那会让主管拿到一批他没要的人。
expect(() => assertCohortCriteria({ clinicId: 'c1', temperature: Temperature.HOT })).toThrow(/必须与潜在治疗项/); expect(() => assertCohortCriteria({ clinicId: 'c1', temperature: Temperature.HOT })).toThrow(/必须与潜在治疗项/);
expect(() => assertCohortCriteria({ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT })).not.toThrow(); expect(() =>
assertCohortCriteria({ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }),
).not.toThrow();
}); });
test('⭐⭐ 老画像(无边界)⛔ 不许掉进任何一档 —— 尤其不许默认掉进冷', () => { test('⭐⭐ 两根轴从 plan_reasons → patient_facts 取,⛔ 不再读画像的 types / temperature', () => {
for (const t of [Temperature.HOT, Temperature.WARM, Temperature.COLD]) { // 画像回答「这个人有哪些潜在治疗」,矩阵要回答「引擎为什么召回他」。实测 161 个格位
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: t }, NOW); // 画像有而召回单没有 —— 主管点那一格捞到的人,plan 讲的是别的病。
// 边界键整个不存在时 `#>` 返回 NULL,这条把它挡在三档之外 expect(hot).toContain('plan_reasons');
expect(s).toContain('IS NOT NULL'); expect(hot).toContain('patient_facts');
} expect(hot).not.toContain("pf.data #> '{types}'");
expect(hot).not.toContain('pf.data #>>');
}); });
test('⭐⭐ 走**稳定路径** temperature.<标签>,⛔ 不走 detail 数组下标', () => { test('⭐⭐ 只认**未治疗**的证据 —— 治完是 fulfilled,拿它当锚就是念一个做完的诊断', () => {
// detail 是数组,同一标签在不同患者身上下标不同 → `detail.0.hotUntil` 对谁都不成立, expect(hot).toContain("f.status = 'active'");
// 于是列表页(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('⭐ 三档互斥且穷尽(有边界的人必进且只进一档)—— 否则矩阵加不出总数', () => { test('🔴 关联片段⛔ 不许自己 SELECT followup_plans —— 内层 fp 会遮蔽外层,关联条件恒真且不报错', () => {
const of = (t: (typeof Temperature)[keyof typeof Temperature]) => // 这是本次改造最贵的一个坑:SQL 合法、跑得通,只是筛选完全失效。
cohortWhereSql(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: t }, NOW); expect(hot).not.toMatch(/FROM\s+followup_plans/);
// 参数里能看出各档比的是哪个边界:热/冷各一个比较,温两个 expect(hot).toContain('pr.plan_id = fp.id');
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('⛔ 温度**不做任何天数运算** —— 天数是时钟,边界才是事实', () => { test('⭐⭐ 先聚合再判档(GROUP/HAVING)—— 逐条判再 OR 会把"有一条还热"错读成整格热', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW); expect(hot).toContain('HAVING');
expect(s).not.toContain('daysSince'); expect(hot).toMatch(/max\(/);
expect(s).not.toMatch(/EXTRACT\s*\(\s*DAY/i);
}); });
});
describe('人群取数 —— 画像标签', () => { test('⭐⭐ 旧取值 `cold` 展开成四个冷档的并集 —— API 契约不能一夜之间全变非法', () => {
test('⭐ 跨维度 AND:两个维度各出一个 EXISTS,不合并成 OR', () => { const legacy = cohortWhereSql(
const s = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'rfm:important_value,gender:male' }, NOW); SCOPE,
expect(s.match(/AND EXISTS \(/g)?.length).toBe(2); { clinicId: 'c1', potentialTreatment: 'implant', temperature: 'cold' as never },
NOW,
);
const vals = legacy.values.flat();
for (const t of ['cold_1y', 'cold_2y', 'cold_3y', 'cold_over']) expect(vals).toContain(t);
}); });
test('⭐ 非法维度 / 非法取值静默丢弃(与列表页同行为,不报错)', () => { test('⭐ 冷端按**自然年**推(interval years),⛔ 不用 365*n —— 闰年会让两边对不上数', () => {
const bogus = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'not_a_dim:x,rfm:not_a_value' }, NOW); const cold = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: 'cold_2y' as never }, NOW);
const none = sqlOf(SCOPE, { clinicId: 'c1' }, NOW); expect(cold).toMatch(/interval '\d+ years'/);
expect(bogus).toBe(none); expect(cold).not.toMatch(/365/);
}); });
test('⭐ 数组维度走 @> 包含,标量维度走等值 —— 用错索引吃不到,生产实测会退化到 20 秒', () => { test('⭐ 窗口天数来自 DiagnosisTreatmentMap,不是手写常量', () => {
expect(sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'entitlement_status:medical' }, NOW)).toContain('@>'); const vals = cohortWhereSql(
expect(sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'gender:male' }, NOW)).toContain('pf.data ->>'); SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
NOW,
).values.flat();
// K08 种植:黄金 120 / 窗 180 —— 这两个数必须以**参数**形式出现(= 从配置注入的)
expect(vals).toContain(120);
expect(vals).toContain(180);
}); });
test('⭐ 「上次到诊」走 patient_profiles 的日期区间,不穿画像表', () => { test('⛔ 判档用 SQL 的 NOW(),不掺 JS 时刻 —— 两个时钟会让边界人群漂', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'last_visit_bucket:0_3m' }, NOW); expect(hot).toContain('NOW()');
expect(s).toContain('patient_profiles'); expect(cohortWhereSql(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW)
expect(s).toContain('pp.last_visit_at'); .values.flat()
.some((v) => v instanceof Date)).toBe(false);
}); });
}); });
import { Test } from '@nestjs/testing';
import { PlanService } from '../src/modules/plan/plan.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { Permission } from '@pac/types';
/**
* 列表页「按患者过滤」的回归 —— 姓名搜索 / 真号码 / 画像圈人。
*
* ── 事故经过(2026-08-06)──────────────────────────────────────────
* 这三个筛选条件都先攒进一个**局部对象** `patientWhere`,最后由一行
* `where.patient = patientWhere`
* 挂到查询上。换初选口径(温度改从召回证据算)时,旧的 Prisma 温度谓词块
* 紧贴在这一行前面,删旧块时**把它一起删了**。
*
* 🔴 后果是三个筛选**同时静默失效**:搜「黄」照样返回全部 1729 人 ——
* 不报错、不空列表,看起来只是"没搜着这个人"。走查时才被发现。
*
* ⚠️ 这类 bug 的形状:**攒条件的地方和生效的地方隔着几十行**,
* 中间任何一次删改都可能把出口带走,而类型系统看不见(局部对象没人读不算错)。
* ⇒ 只能靠这条测试盯着"出口还在不在"。
*/
const HOST = 'h1';
const TENANT = 't1';
const PERMS = [Permission.PLAN_VIEW_ALL, Permission.PLAN_DISPATCH];
function makePrisma() {
const calls: Array<Record<string, unknown>> = [];
const prisma = {
followupPlan: {
findMany: jest.fn(async (args: Record<string, unknown>) => {
calls.push(args);
return [];
}),
count: jest.fn(async () => 0),
},
} as unknown as PrismaService;
return { prisma, calls };
}
async function build(prisma: PrismaService): Promise<PlanService> {
const mod = await Test.createTestingModule({
providers: [PlanService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanService);
}
/** 取 findMany 实际用的 where.patient(没挂上就是 undefined —— 正是那个 bug) */
async function patientWhereOf(query: Record<string, unknown>, sourceUnits: string[] = []) {
const { prisma, calls } = makePrisma();
const svc = await build(prisma);
const scope = {
hostId: HOST,
tenantId: TENANT,
sourceUnits,
clinicIds: [] as string[],
userId: 'u1',
} as never;
await svc.list(scope, { view: 'pool', ...query } as never, PERMS);
const where = calls[0]?.where as Record<string, unknown> | undefined;
return where?.patient as Record<string, unknown> | undefined;
}
describe('列表页按患者过滤 —— 条件必须真的挂到查询上', () => {
test('⭐⭐ 姓名搜索:keyword 要落到 where.patient.OR(挂不上就是"搜了等于没搜")', async () => {
const p = await patientWhereOf({ keyword: '黄' });
expect(p).toBeDefined(); // 🔴 bug 时这里是 undefined
const or = p!.OR as Array<Record<string, Record<string, unknown>>>;
expect(or.map((c) => Object.keys(c)[0])).toEqual([
'name',
'phone',
'medicalRecordNumber',
'externalId',
]);
expect(or[0]!.name!.contains).toBe('黄');
});
test('⭐ 真号码筛选同样要挂上', async () => {
const p = await patientWhereOf({ phoneVerified: true });
expect(p?.phoneVerified).toBe(true);
});
test('⭐ 画像圈人同样要挂上', async () => {
const p = await patientWhereOf({ personaTags: 'gender:male' });
expect(Array.isArray(p?.AND)).toBe(true);
expect((p!.AND as unknown[]).length).toBeGreaterThan(0);
});
test('⭐⭐ 挂的时候⛔不能盖掉 scope 的品牌边界(sourceUnit)—— 那是越权', async () => {
// 原实现是直接赋值 `where.patient = patientWhere`,会把顶上那句
// `patient: { sourceUnit: { in: scope.sourceUnits } }` **整个覆盖掉**。
const p = await patientWhereOf({ keyword: '黄' }, ['ruier']);
expect(p?.sourceUnit).toEqual({ in: ['ruier'] }); // 边界还在
expect(p?.OR).toBeDefined(); // 搜索也在
});
test('什么都不传时不挂 patient 条件(别凭空加一个空对象)', async () => {
const p = await patientWhereOf({});
expect(p).toBeUndefined();
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { focusOrderReasons, reasonMatchesLabel, subKeyRule } from '@pac/types';
/**
* 聚焦排序 —— 「这一单客服该先谈哪件事」。
*
* 由来:实测本地库 40% 的在跑单跨多个业务标签,而详情页和话术都只认 `reasons[0]`。
* 主管在矩阵点「补牙」分下来一批人,其中 **49%** 身上分最高的是别的病(缺牙/正畸),
* 客服打开看到的聚焦项和整篇话术就是那个别的病 —— 主管的分配意图在最后一米丢了,
* 而界面上没有任何迹象。
*
* 本文件锁四条:
* 1. 命中批次标签的排第一(哪怕分更低)
* 2. 无批次 / 撤销后 → 与老口径「分最高」**逐位相同**(回归保护)
* 3. sub_key 带牙位后缀也要能匹配
* 4. 三处排序都必须走同一个函数(纯文本扫描 —— 这个 bug 不会在类型或运行时暴露)
*/
const R = (subKey: string | null, priorityScore: number) => ({ subKey, priorityScore });
describe('focusOrderReasons — 批次意图优先', () => {
test('⭐⭐ 命中批次标签的排第一,哪怕分更低', () => {
const rows = [R('missing_tooth@15', 88), R('caries_no_filling@36', 55), R('perio_no_srp@whole', 70)];
const out = focusOrderReasons(rows, 'filling');
expect(out[0]!.subKey).toBe('caries_no_filling@36');
// 其余仍按分排
expect(out.slice(1).map((r) => r.priorityScore)).toEqual([88, 70]);
});
test('⭐⭐ 无批次标签 → 与老口径「分最高」逐位相同(回归保护)', () => {
const rows = [R('caries_no_filling', 55), R('missing_tooth', 88), R('perio_no_srp', 70)];
const legacy = [...rows].sort((a, b) => b.priorityScore - a.priorityScore);
expect(focusOrderReasons(rows, null)).toEqual(legacy);
expect(focusOrderReasons(rows, undefined)).toEqual(legacy);
});
test('⭐ 批次标签没有任何 reason 命中 → 同样回落分最高,不抛错', () => {
const rows = [R('caries_no_filling', 55), R('missing_tooth', 88)];
const out = focusOrderReasons(rows, 'early_ortho');
expect(out.map((r) => r.priorityScore)).toEqual([88, 55]);
});
test('⭐ 不修改入参(调用方常把 Prisma 行直接传进来)', () => {
const rows = [R('missing_tooth', 88), R('caries_no_filling', 55)];
const snapshot = rows.map((r) => r.subKey);
focusOrderReasons(rows, 'filling');
expect(rows.map((r) => r.subKey)).toEqual(snapshot);
});
test('多条命中同一标签 → 它们之间仍按分排', () => {
const rows = [R('caries_no_filling@36', 40), R('missing_tooth', 90), R('caries_no_filling@11', 60)];
const out = focusOrderReasons(rows, 'filling');
expect(out.map((r) => r.priorityScore)).toEqual([60, 40, 90]);
});
});
describe('subKeyRule / reasonMatchesLabel', () => {
test('⭐ sub_key 带牙位后缀也要能匹配(落库形态是 `规则@牙位`)', () => {
expect(subKeyRule('caries_no_filling@36')).toBe('caries_no_filling');
expect(subKeyRule('perio_no_srp@whole')).toBe('perio_no_srp');
expect(subKeyRule('missing_tooth')).toBe('missing_tooth');
expect(subKeyRule(null)).toBe('');
expect(reasonMatchesLabel('caries_no_filling@36', 'filling')).toBe(true);
});
test('一对多的两条(年龄/诊断名才能定死)按候选集合判包含', () => {
expect(reasonMatchesLabel('ortho_no_consult@whole', 'ortho')).toBe(true);
expect(reasonMatchesLabel('ortho_no_consult@whole', 'early_ortho')).toBe(true);
expect(reasonMatchesLabel('hard_tissue_damage@21', 'restoration')).toBe(true);
expect(reasonMatchesLabel('hard_tissue_damage@21', 'extraction')).toBe(true);
});
test('⭐ 不属于 8 类业务标签的子规则永远不被选为聚焦项', () => {
for (const rule of ['development_eruption', 'jaw_cyst', 'extraction_recommended']) {
for (const lbl of ['implant', 'filling', 'extraction', 'ortho']) {
expect(reasonMatchesLabel(rule, lbl)).toBe(false);
}
}
});
test('空标签 / 空 subKey 一律不匹配(⛔ 别让 undefined 意外命中)', () => {
expect(reasonMatchesLabel('caries_no_filling', null)).toBe(false);
expect(reasonMatchesLabel(null, 'filling')).toBe(false);
});
});
/**
* 🔴 三处排序必须走同一个函数。
*
* 这个 bug 的形态是:只改其中一处,另外两处**再排一次**把批次意图洗回"分最高" ——
* 表现是"后端明明改了、界面没变化",不报错、类型也全绿,只能靠文本扫描锁住。
*/
describe('reasons 排序的三个消费方都走 focusOrderReasons', () => {
const FILES = [
['详情页数据源', '../src/modules/plan-aggregate/plan-aggregate.service.ts'],
['话术编排器', '../src/modules/ai/orchestrators/plan-script.orchestrator.ts'],
] as const;
test.each(FILES)('⭐⭐ %s 调用了 focusOrderReasons', (_name, rel) => {
const src = readFileSync(join(__dirname, rel), 'utf-8');
expect(src).toContain('focusOrderReasons(');
});
test('⭐⭐ 话术编排器不再直接按 priorityScore 手排 reasons', () => {
const src = readFileSync(
join(__dirname, '../src/modules/ai/orchestrators/plan-script.orchestrator.ts'),
'utf-8',
);
expect(src).not.toMatch(/\[\.\.\.plan\.reasons\]\.sort/);
});
test('⭐⭐ 话术编排器加载的 reasons 数量足够(take≥12)—— 否则主管选的那条可能压根没被加载', () => {
// take:3 时,若批次标签的 reason 排第 4,它不在候选里 → 聚焦排序无从提前,且**不报错**
const src = readFileSync(
join(__dirname, '../src/modules/ai/orchestrators/plan-script.orchestrator.ts'),
'utf-8',
);
const m = src.match(/reasons:\s*\{\s*orderBy:\s*\{\s*priorityScore:\s*'desc'\s*\},\s*take:\s*(\d+)/);
expect(m).not.toBeNull();
expect(Number(m![1])).toBeGreaterThanOrEqual(12);
});
});
...@@ -84,11 +84,11 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => { ...@@ -84,11 +84,11 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => {
test('⭐ 带结构化原因 → 落进 plan_event_logs.reason,事件类型是 release', async () => { test('⭐ 带结构化原因 → 落进 plan_event_logs.reason,事件类型是 release', async () => {
const { prisma, events } = makePrisma(); const { prisma, events } = makePrisma();
const svc = await buildService(prisma); const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OVER_CAPACITY); await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.ALREADY_IN_PROGRESS);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ expect(events[0]).toMatchObject({
event: 'release', event: 'release',
reason: 'over_capacity', reason: 'already_in_progress',
assigneeUserId: null, assigneeUserId: null,
}); });
}); });
...@@ -122,7 +122,7 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => { ...@@ -122,7 +122,7 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => {
test('⭐⭐ 红线:退回**绝不动 snoozedUntil**(动了池子里会凭空少一批人)', async () => { test('⭐⭐ 红线:退回**绝不动 snoozedUntil**(动了池子里会凭空少一批人)', async () => {
const { prisma, updates } = makePrisma(); const { prisma, updates } = makePrisma();
const svc = await buildService(prisma); const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.RECENTLY_CONTACTED); await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.TOO_SOON);
expect(updates).toHaveLength(1); expect(updates).toHaveLength(1);
// 「最近刚联系过」最像该压一压的原因 —— 正因为像,才要在这里钉死 // 「最近刚联系过」最像该压一压的原因 —— 正因为像,才要在这里钉死
expect(updates[0]).not.toHaveProperty('snoozedUntil'); expect(updates[0]).not.toHaveProperty('snoozedUntil');
...@@ -132,14 +132,14 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => { ...@@ -132,14 +132,14 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => {
test('⭐ 退回原因同时落主表当前值(列表页要直接显示,不必回查事件流)', async () => { test('⭐ 退回原因同时落主表当前值(列表页要直接显示,不必回查事件流)', async () => {
const { prisma, updates } = makePrisma(); const { prisma, updates } = makePrisma();
const svc = await buildService(prisma); const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.NOT_MY_PATIENT); await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.NEEDS_OTHER_ROLE);
expect(updates[0]).toMatchObject({ releaseReason: 'not_my_patient', releaseNote: null }); expect(updates[0]).toMatchObject({ releaseReason: 'needs_other_role', releaseNote: null });
}); });
test('⭐⭐ 红线:退回**不清 assignment_id / assigned_by / assign_strategy** —— 那是退回率的分母', async () => { test('⭐⭐ 红线:退回**不清 assignment_id / assigned_by / assign_strategy** —— 那是退回率的分母', async () => {
const { prisma, updates } = makePrisma(); const { prisma, updates } = makePrisma();
const svc = await buildService(prisma); const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OVER_CAPACITY); await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.ALREADY_IN_PROGRESS);
// 「这批分了 60 条,退回 5 条」全靠 assignment_id 还在;清了就只剩分子 // 「这批分了 60 条,退回 5 条」全靠 assignment_id 还在;清了就只剩分子
expect(updates[0]).not.toHaveProperty('assignmentId'); expect(updates[0]).not.toHaveProperty('assignmentId');
expect(updates[0]).not.toHaveProperty('assignedBy'); expect(updates[0]).not.toHaveProperty('assignedBy');
......
import { classifyCodeToLabel, EXTRACTION_NAME_KEYWORDS, POTENTIAL_LABEL_RULES } from '@pac/types';
import { classifyGapToLabel } from '../src/modules/persona/features/potential-treatment.feature';
import type { PotentialGap } from '../src/modules/clinical-gap/potential-treatment.selector';
import { labelCaseSql, windowValuesSql } from '../src/modules/plan/reason-temperature.sql';
/**
* 🔴 码 → 业务标签的判定,现在有**两个执行现场**:
* · TS 侧 `classifyGapToLabel`(画像出标签、urgency_level 判待转)
* · SQL 侧 `labelCaseSql`(初选矩阵 / 确认单 / 列表,2026-08 换成 reason 驱动后新增)
*
* 两边分叉的表现是「矩阵里有这个人、点进去列表没有」—— T6a 明令要防的那件事,
* **而且不报错**。所以这里在**全量组合**上逐个比对,不是抽样。
*
* 声明式规则表 `POTENTIAL_LABEL_RULES` 是给 SQL 生成用的;本文件证明它与那个 switch 等价。
*/
const CODES = ['K00', 'K01', 'K02', 'K03', 'K04', 'K05', 'K06', 'K07', 'K08', 'K09', 'UNKNOWN'];
const AGES = [null, 0, 2, 3, 12, 13, 18, 19, 40, 41, 80];
const NAMES = ['', '牙列缺损', '残根', '残冠伴龋', '无法保留', '不能保留的患牙', '楔状缺损'];
const gapOf = (code: string, nameZh: string): PotentialGap =>
({ primaryCode: code, nameZh, factId: 'f', code, tooth: null, daysSince: 0, anchorAt: new Date(), signalType: 'diagnosis', confidence: 1 }) as unknown as PotentialGap;
describe('规则表与 classifyGapToLabel 等价(全量组合)', () => {
test('⭐⭐ 11 码 × 11 年龄 × 7 诊断名 = 847 组,逐个一致', () => {
const diffs: string[] = [];
for (const code of CODES) {
for (const age of AGES) {
for (const name of NAMES) {
const fromSwitch = classifyGapToLabel(gapOf(code, name), age)?.key ?? null;
const fromTable = classifyCodeToLabel(code, name, age);
if (fromSwitch !== fromTable) {
diffs.push(`${code}/age=${age}/"${name}": switch=${fromSwitch} table=${fromTable}`);
}
}
}
}
expect(diffs).toEqual([]);
});
test('⭐ 关键词只有一份 —— switch 用的就是 @pac/types 那个常量', () => {
// 改了关键词而只改一处 = 两边分叉。这里断言它们**是同一个数组引用来源**的内容。
expect(EXTRACTION_NAME_KEYWORDS).toContain('残根');
expect(classifyCodeToLabel('K03', '残根', 30)).toBe('extraction');
expect(classifyCodeToLabel('K03', '楔状缺损', 30)).toBe('restoration');
});
});
describe('SQL 生成 —— 数字/关键词一个都不许手抄', () => {
const sql = labelCaseSql('f', { strings: ['age'], values: [] } as never);
const flat = sql.strings.join('?');
test('⭐⭐ CASE 的 WHEN 条数 == 规则表条数(漏一条 = 那类患者静默消失)', () => {
expect((flat.match(/WHEN/g) ?? []).length).toBe(POTENTIAL_LABEL_RULES.length);
});
test('⭐⭐ K03 的「含关键词→拔牙」必须排在「→修复」之前(顺序敏感)', () => {
// 调换顺序会让所有 K03 都归修复,而 SQL 完全合法、跑得通。
const vals = sql.values.flat();
expect(vals.indexOf('extraction')).toBeLessThan(vals.indexOf('restoration'));
});
test('⭐ 关键词以**参数**形式注入,⛔ 不拼进 SQL 文本(业务配置可能含引号/元字符)', () => {
for (const k of EXTRACTION_NAME_KEYWORDS) {
expect(flat).not.toContain(k);
expect(sql.values.flat()).toContain(k);
}
});
test('⭐ 关键词判包含用 position() 而不是正则 —— 关键词是业务配置,可能含正则元字符', () => {
expect(flat).toContain('position(');
expect(flat).not.toMatch(/~\s*\?/);
});
test('⭐⭐ 窗口天数来自 DiagnosisTreatmentMap(参数注入),⛔ 不是 SQL 里的字面量', () => {
const w = windowValuesSql();
const wflat = w.strings.join('?');
// K08 种植 120/180、K02 龋 60/90 —— 必须是参数
for (const n of [120, 180, 60, 90]) expect(w.values.flat()).toContain(n);
expect(wflat).not.toMatch(/\b(120|180)\b/);
});
test('⭐ 只生成 8 类标签用到的码 —— K00/K09 不该出现(它们没有矩阵行)', () => {
const codes = windowValuesSql().values.flat().filter((v) => typeof v === 'string');
expect(codes).not.toContain('K00');
expect(codes).not.toContain('K09');
expect(codes).toContain('K08');
});
});
{
// 只做**类型检查**的配置(不产出), `tests/` 也纳进来。
//
// 由来(2026-08-05): `ReleaseReason` 里几个键改成历史值之后,`pnpm exec tsc --noEmit` 全绿,
// 1000+ 个用例里有 7 处还在引用 `ReleaseReason.OVER_CAPACITY` —— 那些属性**已经不存在**,
// 运行时取到 `undefined`,表现是"退回原因静默变成 null"
// 根因是主 tsconfig `include` 只有 `src/**/*`,`tests/` 从来没被类型检查过。
//
// ⚠️ 为什么不直接改主 tsconfig include:那份要被 nest build / swc ,
// tests 纳进去会让**产物**里多出一堆测试文件。所以另起一份只给 CI / 本地自查用。
//
// 用法:pnpm exec tsc --noEmit -p tsconfig.typecheck.json
"extends": "./tsconfig.json",
// ⚠️ rootDir 要抬到包根 —— tsconfig 里是 `src`, tests 纳进来会全量报 TS6059
// 本配置 noEmit,rootDir 只影响产物布局,抬高没有副作用。
"compilerOptions": { "rootDir": ".", "noEmit": true },
"include": ["src/**/*", "tests/**/*"]
}
...@@ -187,6 +187,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -187,6 +187,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
recommendedAt: real.plan?.recommendedAt ? new Date(real.plan.recommendedAt) : now, recommendedAt: real.plan?.recommendedAt ? new Date(real.plan.recommendedAt) : now,
recommendedRole: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF, recommendedRole: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
recommendedChannel: (real.plan?.recommendedChannel as ExecutionChannel) ?? ExecutionChannel.PHONE, recommendedChannel: (real.plan?.recommendedChannel as ExecutionChannel) ?? ExecutionChannel.PHONE,
/// 批次意图(主管点的那一格)—— 决定聚焦哪条 reason;⛔ 别丢,丢了前端重排会洗回"分最高"
focusLabel: real.plan?.focusLabel ?? null,
reasons: planReasons, reasons: planReasons,
}; };
......
...@@ -349,6 +349,8 @@ export const mockPlan = { ...@@ -349,6 +349,8 @@ export const mockPlan = {
maxContactAttempts: 4, maxContactAttempts: 4,
targetClinic: '望京旗舰店', targetClinic: '望京旗舰店',
targetClinicId: null as string | null, // 原始诊所 id(EMR 跳转 {clinicId} 用) targetClinicId: null as string | null, // 原始诊所 id(EMR 跳转 {clinicId} 用)
/// 批次意图(主管点的那一格)—— 决定聚焦哪条 reason;mock 无批次 → null(等价"分最高")
focusLabel: null as string | null,
executorClinic: '望京旗舰店', executorClinic: '望京旗舰店',
goal: '邀约本周种植面诊,避免邻牙倾斜 / 对颌伸长' as string | null, goal: '邀约本周种植面诊,避免邻牙倾斜 / 对颌伸长' as string | null,
assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole }, assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole },
......
...@@ -51,6 +51,7 @@ import { ...@@ -51,6 +51,7 @@ import {
HOST_CASE_STAGE_CONSULTED, HOST_CASE_STAGE_CONSULTED,
type HostPotentialTreatmentPayload, type HostPotentialTreatmentPayload,
personaFeatureCategoryTone, personaFeatureCategoryTone,
focusOrderReasons,
type AbandonReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
...@@ -137,6 +138,15 @@ export type ReturnVisitItem = { ...@@ -137,6 +138,15 @@ export type ReturnVisitItem = {
* ⚠️ 只关**头部**这一处。左栏每行的优先级条与分数**不动** —— * ⚠️ 只关**头部**这一处。左栏每行的优先级条与分数**不动** ——
* 那是主管挑人的排序依据,隐藏了他就没法判断先跟谁。 * 那是主管挑人的排序依据,隐藏了他就没法判断先跟谁。
*/ */
/**
* 四点简报里**不该渲染**的占位符。
*
* 老数据(纯文本一句话)被服务端兼容成「整句进 problem + 其余三段填「—」」,
* 照直渲染就是三行破折号夹一行真话,像加载失败。⛔ 别改成"渲染成空行"——
* 空行同样占位;要的是那一行**根本不存在**。
*/
const BRIEF_PLACEHOLDERS = new Set(['', '—', '-', '–', '无', '暂无']);
const HEADER_PRIORITY_VISIBLE: boolean = false; const HEADER_PRIORITY_VISIBLE: boolean = false;
/** /**
...@@ -191,7 +201,13 @@ export function PlanDetailApp({ ...@@ -191,7 +201,13 @@ export function PlanDetailApp({
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
// 顶部那句 AI 召回简报 —— 由 RecallBriefLine 拿到后回报(它负责 get-or-generate)。 // 顶部那句 AI 召回简报 —— 由 RecallBriefLine 拿到后回报(它负责 get-or-generate)。
// 「打开潜在治疗」的 postMessage 要发同一句话给宿主,所以提到这层存。 // 「打开潜在治疗」的 postMessage 要发同一句话给宿主,所以提到这层存。
const [recallBrief, setRecallBrief] = useState<string | null>(null); /** 四点简报;null = 还没生成 / 无召回原因 */
const [recallBrief, setRecallBrief] = useState<{
who: string;
history: string;
problem: string;
hook: string;
} | null>(null);
// 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null // 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null); const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
...@@ -341,9 +357,14 @@ export function PlanDetailApp({ ...@@ -341,9 +357,14 @@ export function PlanDetailApp({
// 口径统一收口到「召回算法 + 牙位事实」单一来源 —— 召回判定什么,这里就显示什么 // 口径统一收口到「召回算法 + 牙位事实」单一来源 —— 召回判定什么,这里就显示什么
// (避免链与召回背离:李梦维 1B 乳牙滞留贴面,链判"替代闭环"误删,而召回算法仍召 → 错隐藏)。 // (避免链与召回背离:李梦维 1B 乳牙滞留贴面,链判"替代闭环"误删,而召回算法仍召 → 错隐藏)。
// "是否该召 1B" 这类问题改由召回算法本身回答(如 K00 是否认 cosmetic),不再借链做二次抑制。 // "是否该召 1B" 这类问题改由召回算法本身回答(如 K00 是否认 cosmetic),不再借链做二次抑制。
// ⚠️ 排序**必须**走 focusOrderReasons(与服务端 plan-aggregate / 话术编排器同一份实现):
// 主管点「补牙」那一格分下来的单,客服打开就该先看补牙,哪怕这人身上缺牙的分更高
// (实测补牙这一格 49% 的单不是以补牙为主因)。
// ⛔ 别改回 `sort(b.priorityScore - a.priorityScore)` —— 那会把服务端排好的批次意图
// **静默洗掉**,表现是"后端改了但界面没变化",而且不报任何错。
const visibleReasons = useMemo( const visibleReasons = useMemo(
() => [...reasons].sort((a, b) => b.priorityScore - a.priorityScore), () => focusOrderReasons(reasons, plan.focusLabel),
[reasons], [reasons, plan.focusLabel],
); );
// 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签 // 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
...@@ -440,7 +461,8 @@ export function PlanDetailApp({ ...@@ -440,7 +461,8 @@ export function PlanDetailApp({
const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => { const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => {
const codes = potentialTreatmentCodes; const codes = potentialTreatmentCodes;
return { return {
desc: recallBrief ?? visibleReasons[0]?.reason ?? '', // ⚠️ 这里只要一句话 → 取 problem(它承载的正是原来那句的主干)
desc: recallBrief?.problem ?? visibleReasons[0]?.reason ?? '',
treatments: Array.isArray(codes) treatments: Array.isArray(codes)
? codes.filter((c): c is string => typeof c === 'string').map(potentialTreatmentItemName) ? codes.filter((c): c is string => typeof c === 'string').map(potentialTreatmentItemName)
: [], : [],
...@@ -1886,9 +1908,10 @@ function RecallBriefLine({ ...@@ -1886,9 +1908,10 @@ function RecallBriefLine({
visibleReasons: PlanReason[]; visibleReasons: PlanReason[];
/** 简报拿到后报给父层 —— 「打开潜在治疗」的 postMessage 要发同一句话给宿主, /** 简报拿到后报给父层 —— 「打开潜在治疗」的 postMessage 要发同一句话给宿主,
* 不能各查一次(那会出现"页面显示 A、发过去 B")。 */ * 不能各查一次(那会出现"页面显示 A、发过去 B")。 */
onSummary?: (summary: string | null) => void; /** 四点简报回上层(标题栏那句只取 problem);null = 没生成 */
onSummary?: (summary: { who: string; history: string; problem: string; hook: string } | null) => void;
}) { }) {
const [summary, setSummary] = useState<string | null>(null); const [summary, setSummary] = useState<{ who: string; history: string; problem: string; hook: string } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
...@@ -1929,11 +1952,29 @@ function RecallBriefLine({ ...@@ -1929,11 +1952,29 @@ function RecallBriefLine({
canExpand ? 'cursor-pointer hover:from-brand-100/70' : 'cursor-default', canExpand ? 'cursor-pointer hover:from-brand-100/70' : 'cursor-default',
)} )}
> >
<svg viewBox="0 0 24 24" className="mt-[1px] h-3.5 w-3.5 flex-none text-brand-500" fill="none" stroke="currentColor" strokeWidth="2"> {/*
<circle cx="12" cy="12" r="9" /> 四点简报(2026-08-05 从一句话拆开)。
<path d="M12 8v5M12 16h.01" strokeLinecap="round" /> ⛔ **不加「画像/回访/问题/切入」前缀标签**(2026-08-05 产品走查):
</svg> 每句本身已经说清自己在讲什么,标签是重复编码,还吃掉左边一列宽度。
<span className="flex-1 min-w-0 font-medium text-brand-900 leading-snug">{summary}</span> ⛔ **不放感叹号图标**(2026-08-06 产品走查):那是"警告"的语义,而这四句是**交底**,
没有一句需要警示;左边那条 brand 竖线已经把这块圈出来了,图标是第二套更弱的编码。
🔴 **占位符那几行不渲染**(2026-08-06 产品走查):
库里还有大量**老行是纯文本一句话**,服务端兼容时把整句落到 problem、
其余三段填「—」(见 recall-brief.orchestrator 的 parseBriefContent)。
照直渲染出来就是三行「—」夹一行真话,看着像加载失败。
⇒ 空 / 「—」 / 「-」一律跳过;真没内容时那一行**不存在**,而不是留个破折号。
⚠️ 这不是"前端补位":四段仍由服务端保证语义完整(没事实时说"此前无回访记录"),
这里滤掉的只是**兼容老数据造出来的占位符**。
*/}
<span className="flex-1 min-w-0 space-y-0.5 font-medium text-brand-900 leading-snug">
{[summary.who, summary.history, summary.problem, summary.hook]
.filter((t) => !BRIEF_PLACEHOLDERS.has(t?.trim() ?? ''))
.map((text, idx) => (
<span key={idx} className="block">
{text}
</span>
))}
</span>
{canExpand && ( {canExpand && (
// 右指箭头(收起)→ 旋转 90° 朝下(展开) // 右指箭头(收起)→ 旋转 90° 朝下(展开)
<svg <svg
......
...@@ -96,6 +96,10 @@ export type PlanDetailData = { ...@@ -96,6 +96,10 @@ export type PlanDetailData = {
recallFeedbackNote: string | null; recallFeedbackNote: string | null;
/// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度 /// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度
updatedAt: string; updatedAt: string;
/// 批次意图:这单是主管点哪一格分下来的(8 类潜在治疗之一);自助认领 / 批次已撤销 → null。
/// ⚠️ 用途是**聚焦哪条 reason** —— 服务端已按它排好序(reasons[0] 就是聚焦项),
/// 前端若要重排**必须**走同一个 focusOrderReasons,否则会把批次意图静默洗回"分最高"。
focusLabel: string | null;
reasons: Array<{ reasons: Array<{
id: string; id: string;
scenario: string; scenario: string;
......
...@@ -21,7 +21,7 @@ import type { ScriptSection } from './mock-data'; ...@@ -21,7 +21,7 @@ import type { ScriptSection } from './mock-data';
*/ */
interface ServerSection { interface ServerSection {
// 稳健/标准:固定 4 id(开场白/告知应治未治/复查建议/结束回访语);深度:段数不定,id = s0/s1/…(string) // 稳健/标准:固定 4 id(开场白/告知潜在治疗/复查建议/结束回访语);深度:段数不定,id = s0/s1/…(string)
id: string; id: string;
label: string; label: string;
durationHint: string; durationHint: string;
...@@ -193,6 +193,28 @@ export function useScriptStream(): UseScriptStream { ...@@ -193,6 +193,28 @@ export function useScriptStream(): UseScriptStream {
modelId: evt.modelId, modelId: evt.modelId,
invocationId: evt.invocationId, invocationId: evt.invocationId,
}); });
} else if (evt.type === 'delta') {
/**
* ⭐ 企微:正文增量,直接**追加**到那一段上。
* 2026-08 企微砍掉工作流之后没有步骤可推了,改推 delta —— 体感从
* "盯着三个勾等 60 秒"变成"看着它写"。
* ⚠️ 追加不是替换:服务端推的就是增量,替换会只剩最后一小段。
*/
setState((prev) => {
if (prev.status !== 'streaming') return prev;
const cur = prev.sections?.[0];
return {
...prev,
sections: [
{
id: 'wecom',
label: '企微话术',
durationHint: '',
markdown: (cur?.markdown ?? '') + (evt.text ?? ''),
},
],
};
});
} else if (evt.type === 'step') { } else if (evt.type === 'step') {
setState((prev) => setState((prev) =>
prev.status === 'streaming' prev.status === 'streaming'
...@@ -213,6 +235,13 @@ export function useScriptStream(): UseScriptStream { ...@@ -213,6 +235,13 @@ export function useScriptStream(): UseScriptStream {
* 这里把企微那块包成**一段**,让下游(渲染 / 复制 / 状态)只认一种形状。 * 这里把企微那块包成**一段**,让下游(渲染 / 复制 / 状态)只认一种形状。
* ⛔ 别为此在下游到处判渠道 —— 判据散开之后总会漏一处。 * ⛔ 别为此在下游到处判渠道 —— 判据散开之后总会漏一处。
*/ */
/**
* 🔴 企微 `source==='failed'` 时 content 是**空串** —— 这里必须让它覆盖掉
* 流式过程中已经追加进 sections 的正文。
* 那份稿子没过机器安全闸(多半带着 `【时间段1】` 之类的占位),
* 企微是**整段复制直发**的:留在框里 = 客服一键复制就发给患者了,
* 而占位符会原样出现在对方微信里。⛔ 宁可空着让他自己写。
*/
sections: evt.sections sections: evt.sections
? serverToClientSections(evt.sections) ? serverToClientSections(evt.sections)
: [{ id: 'wecom', label: '企微话术', durationHint: '', markdown: evt.content ?? '' }], : [{ id: 'wecom', label: '企微话术', durationHint: '', markdown: evt.content ?? '' }],
...@@ -284,7 +313,19 @@ interface SseStepEvent { ...@@ -284,7 +313,19 @@ interface SseStepEvent {
status: DeepStep['status']; status: DeepStep['status'];
detail?: DeepStep['detail']; detail?: DeepStep['detail'];
} }
type SseEvent = SseStartEvent | SsePartialEvent | SseDoneEvent | SseErrorEvent | SseStepEvent; /** 企微:正文增量(2026-08 砍掉工作流后取代 step 事件) */
interface SseDeltaEvent {
type: 'delta';
text: string;
}
type SseEvent =
| SseStartEvent
| SsePartialEvent
| SseDoneEvent
| SseErrorEvent
| SseStepEvent
| SseDeltaEvent;
/** 合并 step 事件到时间线:同名步更新状态/详情,否则追加(保持 plan→write→verify→repair 顺序)*/ /** 合并 step 事件到时间线:同名步更新状态/详情,否则追加(保持 plan→write→verify→repair 顺序)*/
function upsertStep(steps: DeepStep[], evt: SseStepEvent): DeepStep[] { function upsertStep(steps: DeepStep[], evt: SseStepEvent): DeepStep[] {
......
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
personaTagDimId, personaTagDimId,
potentialTreatmentCardLabel, potentialTreatmentCardLabel,
TEMPERATURE_META, TEMPERATURE_META,
type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { cn, formatGender } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
import { actionTemplate } from '@/lib/action-url'; import { actionTemplate } from '@/lib/action-url';
...@@ -65,8 +66,12 @@ const VIEW_TABS: Array<{ v: View; label: string; requires?: Permission }> = [ ...@@ -65,8 +66,12 @@ const VIEW_TABS: Array<{ v: View; label: string; requires?: Permission }> = [
* 所以藏起来之后请求里不会残留任何筛选条件 —— ⛔ 别改它们的初值, * 所以藏起来之后请求里不会残留任何筛选条件 —— ⛔ 别改它们的初值,
* 否则会出现"看不见的筛选"(列表少人而主管找不到原因)。 * 否则会出现"看不见的筛选"(列表少人而主管找不到原因)。
*/ */
/** 「真」号码筛选(只看已核实的真实手机号) */ /**
const PHONE_FILTER_VISIBLE: boolean = true; * 「真」号码筛选(只看已核实的真实手机号)—— **隐藏**(2026-08-05 产品走查)。
* ⚠️ 反复过两次:8-04 与标签筛选一起藏 → 8-05 单独放回 → 8-05 再次藏起。
* 所以⛔ 别把这个开关连同按钮一起删掉,下次还要放回来。
*/
const PHONE_FILTER_VISIBLE: boolean = false;
/** 画像标签筛选 —— 仍隐藏 */ /** 画像标签筛选 —— 仍隐藏 */
const TAG_FILTER_VISIBLE: boolean = false; const TAG_FILTER_VISIBLE: boolean = false;
...@@ -112,7 +117,9 @@ export function PatientPickerRail({ ...@@ -112,7 +117,9 @@ export function PatientPickerRail({
const [matrix, setMatrix] = useState<PoolMatrixData | null>(null); const [matrix, setMatrix] = useState<PoolMatrixData | null>(null);
const [matrixLoading, setMatrixLoading] = useState(false); const [matrixLoading, setMatrixLoading] = useState(false);
/// 上一次点过的那一格 —— **只用于矩阵内部回显**(再打开时能看出刚交过哪一格),不外溢到列表 /// 上一次点过的那一格 —— **只用于矩阵内部回显**(再打开时能看出刚交过哪一格),不外溢到列表
const [cell, setCell] = useState<{ treatment: string; temperature: 'hot' | 'warm' | 'cold' } | null>(null); // ⚠️ 用共享的 TemperatureValue,⛔ 别手写联合 —— 档位从 3 扩到 6 时手写的那份不会跟着变,
// 而它只在**赋值处**报错,读的地方(如 TEMPERATURE_META[t])会静默漏档。
const [cell, setCell] = useState<{ treatment: string; temperature: TemperatureValue } | null>(null);
/** /**
* 待确认退回的那一条 —— ⭐ **退回必须先选原因**(T7),⛔ 不能点一下就走。 * 待确认退回的那一条 —— ⭐ **退回必须先选原因**(T7),⛔ 不能点一下就走。
* 原因分布是主管调整分配策略的输入;拿不到它,他只知道"退了 12 条",不知道该改什么。 * 原因分布是主管调整分配策略的输入;拿不到它,他只知道"退了 12 条",不知道该改什么。
...@@ -237,7 +244,7 @@ export function PatientPickerRail({ ...@@ -237,7 +244,7 @@ export function PatientPickerRail({
const pickCell = (c: { const pickCell = (c: {
treatment: string; treatment: string;
treatmentZh: string; treatmentZh: string;
temperature: 'hot' | 'warm' | 'cold'; temperature: TemperatureValue;
count: number; count: number;
rect: { x: number; y: number; w: number; h: number }; rect: { x: number; y: number; w: number; h: number };
}) => { }) => {
......
'use client'; 'use client';
import type { import type {
TemperatureValue,
ListPlansQuery, ListPlansQuery,
ListPlansResponse, ListPlansResponse,
PlanActionAck, PlanActionAck,
...@@ -14,10 +15,12 @@ export interface PoolMatrixRow { ...@@ -14,10 +15,12 @@ export interface PoolMatrixRow {
key: string; key: string;
zh: string; zh: string;
hint?: string; hint?: string;
hot: number; /**
warm: number; * 六档人数(**去重患者数**,不是 plan 条数)。
cold: number; * ⚠️ 用 map 不平铺 —— 档位是会变的(2026-08 从 3 档扩到 6 档),平铺每加一档就要改一圈类型 + 组件。
/** 温度待重算(画像还没算出窗口边界)。⛔ 服务端刻意没把它并进 cold */ */
counts: Record<TemperatureValue, number>;
/** 温度算不出来(锚点缺失)。⛔ 服务端刻意没把它并进任何一个冷档 —— 那是假分布 */
unknown: number; unknown: number;
/** 含 unknown —— 它就是「点这一行能拿到多少人」 */ /** 含 unknown —— 它就是「点这一行能拿到多少人」 */
total: number; total: number;
...@@ -89,9 +92,18 @@ export const plansApi = { ...@@ -89,9 +92,18 @@ export const plansApi = {
`/pac/v1/plans/${encodeURIComponent(planId)}/persona-summary`, `/pac/v1/plans/${encodeURIComponent(planId)}/persona-summary`,
), ),
/** 本次召回一句话简报(谁/解决什么/到诊做什么;有则取、无则当场生成;无召回原因 status='empty')*/ /**
* 本次召回**四点**简报(2026-08-05 从一句话拆开)。
* 有则取、无则当场生成;无召回原因 status='empty'。
* ⚠️ 老缓存行是纯文本 → 服务端解析成 `{who:'—',history:'—',problem:老文本,hook:'—'}`,
* 所以前端**永远拿到四段**,⛔ 不必再判"是不是老格式"。
*/
getRecallBrief: (planId: string) => getRecallBrief: (planId: string) =>
api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>( api.get<{
summary: { who: string; history: string; problem: string; hook: string } | null;
status: 'ready' | 'empty';
source?: string;
}>(
`/pac/v1/plans/${encodeURIComponent(planId)}/recall-brief`, `/pac/v1/plans/${encodeURIComponent(planId)}/recall-brief`,
), ),
......
...@@ -44,14 +44,22 @@ type TempKey = TemperatureValue; ...@@ -44,14 +44,22 @@ type TempKey = TemperatureValue;
const HUE: Record<TempKey, { dot: string; text: string }> = { const HUE: Record<TempKey, { dot: string; text: string }> = {
hot: { dot: 'bg-amber-600', text: 'text-amber-700' }, hot: { dot: 'bg-amber-600', text: 'text-amber-700' },
warm: { dot: 'bg-emerald-600', text: 'text-emerald-700' }, warm: { dot: 'bg-emerald-600', text: 'text-emerald-700' },
cold: { dot: 'bg-sky-600', text: 'text-sky-700' }, // ⭐ 冷端四档走**同一色相(sky)的深浅**,不换色相:
// 换色相会读成"四种不同的东西",而它们是同一件事(已出临床周期)的**程度**差异。
// 深→浅 = 近→远,与左边 amber→emerald→sky 的"由烫到冷"方向一致。
cold_1y: { dot: 'bg-sky-600', text: 'text-sky-700' },
cold_2y: { dot: 'bg-sky-500', text: 'text-sky-600' },
cold_3y: { dot: 'bg-sky-400', text: 'text-sky-500' },
cold_over: { dot: 'bg-slate-400', text: 'text-slate-500' },
}; };
/// 整片矩阵那条渐变 —— 一处定义,列头刻度与格子面共用;写两遍必然漂 /// 整片矩阵那条渐变 —— 一处定义,列头刻度与格子面共用;写两遍必然漂
const PLANE = 'bg-linear-to-r from-amber-600/55 via-emerald-600/45 to-sky-600/40'; /// ⚠️ 冷端占了 4/6 的宽度,所以 sky 段要给足停靠点,否则右侧一大片糊成一个颜色、分不出四档。
const PLANE =
'bg-linear-to-r from-amber-600/55 via-emerald-600/45 via-45% to-slate-400/25';
/// 行标签列宽 + 行高:列头、标签列、格子面三者靠它们对齐,别在某一处手改 /// 行标签列宽 + 行高:列头、标签列、格子面三者靠它们对齐,别在某一处手改
const LABEL_W = 'w-[54px]'; const LABEL_W = 'w-[46px]';
const ROW_H = 'h-[30px]'; const ROW_H = 'h-[30px]';
export function PoolMatrix({ export function PoolMatrix({
...@@ -83,21 +91,17 @@ export function PoolMatrix({ ...@@ -83,21 +91,17 @@ export function PoolMatrix({
}) => void; }) => void;
}) { }) {
const showUnknown = data.unknownTotal > 0; const showUnknown = data.unknownTotal > 0;
/// 列汇总 = 该列 8 行之和。⚠️ 现算不请求:后端给的是格子,汇总是纯展示派生,不该多一个字段去漂
const colTotal = (c: TempKey) => data.rows.reduce((a, r) => a + r[c], 0);
return ( return (
<Card className="w-[380px] border-0 shadow-none"> <Card className="w-[560px] border-0 shadow-none">
{/*
⛔ **不放列合计**。列合计 = 该列 8 行相加,而**一个人可以同时出现在多行**
(有几个潜在治疗就占几行,实测人均 1.37 个)。于是六列一加会**大于**池子总人数
—— 主管一对数就觉得系统在骗他(实测 2,526 vs 池子 2,129)。
他真正要的是「点哪一格拿多少人」,那是格子本身的数,不是列合计。
*/}
<CardHeader className="flex-row items-baseline justify-between space-y-0 p-3 pb-2.5"> <CardHeader className="flex-row items-baseline justify-between space-y-0 p-3 pb-2.5">
<div className="text-[13px] font-semibold">潜在治疗</div> <div className="text-[13px] font-semibold">潜在治疗</div>
<div className="flex items-baseline gap-2.5 whitespace-nowrap text-[11px] text-muted-foreground nums"> <div className="text-[11px] text-muted-foreground">点一格 = 把这批人交给助手</div>
{COLUMNS.map((c) => (
<span key={c}>
{TEMPERATURE_META[c].zh}{' '}
<span className={cn('font-semibold', HUE[c].text)}>{colTotal(c).toLocaleString()}</span>
</span>
))}
</div>
</CardHeader> </CardHeader>
<CardContent className="p-3 pt-0"> <CardContent className="p-3 pt-0">
...@@ -110,7 +114,8 @@ export function PoolMatrix({ ...@@ -110,7 +114,8 @@ export function PoolMatrix({
key={c} key={c}
title={TEMPERATURE_META[c].hint} title={TEMPERATURE_META[c].hint}
className={cn( className={cn(
'flex flex-1 items-center justify-center gap-1.5 text-[11px] font-semibold', // ⚠️ whitespace-nowrap:「3 年以上」会折成两行,把整排列头撑高、基线也歪
'flex flex-1 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-semibold',
HUE[c].text, HUE[c].text,
)} )}
> >
...@@ -120,7 +125,7 @@ export function PoolMatrix({ ...@@ -120,7 +125,7 @@ export function PoolMatrix({
))} ))}
</div> </div>
{showUnknown && ( {showUnknown && (
<div className="w-10 shrink-0 text-center text-[11px] text-muted-foreground">待重算</div> <div className="w-12 shrink-0 text-center text-[11px] whitespace-nowrap text-muted-foreground">算不出</div>
)} )}
</div> </div>
...@@ -142,11 +147,11 @@ export function PoolMatrix({ ...@@ -142,11 +147,11 @@ export function PoolMatrix({
</div> </div>
{showUnknown && ( {showUnknown && (
<div className="w-10 shrink-0"> <div className="w-12 shrink-0">
{data.rows.map((row) => ( {data.rows.map((row) => (
<div <div
key={row.key} key={row.key}
title="温度待重算 —— 画像还没算出窗口边界" title="算不出温度 —— 召回证据里没有可用的诊断日"
className={cn( className={cn(
ROW_H, ROW_H,
'flex items-center justify-center text-[11px] text-muted-foreground nums', 'flex items-center justify-center text-[11px] text-muted-foreground nums',
...@@ -159,8 +164,20 @@ export function PoolMatrix({ ...@@ -159,8 +164,20 @@ export function PoolMatrix({
)} )}
</div> </div>
{/*
⭐ 一句话讲清两件事,替代逐格 hover 的长解释:
· 前两档 vs 后四档量的**不是同一把尺子**(前者按该治疗自己的临床周期,后者按年数)
· 一个人可能出现在多行 —— 否则主管把各行相加会发现比池子总数大
⛔ 别写成教学文案。主管扫一眼就要能懂,写长了没人看。
*/}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
前两档按<span className="font-medium">该治疗自己的周期</span>算,
后四档按<span className="font-medium">诊断距今多久</span>算。
一个人有几个潜在治疗就出现在几行。
</p>
{data.note && ( {data.note && (
<p className="mt-2 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800"> <p className="mt-1.5 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
{data.note} {data.note}
</p> </p>
)} )}
...@@ -191,7 +208,7 @@ function MatrixRow({ ...@@ -191,7 +208,7 @@ function MatrixRow({
return ( return (
<div className="flex"> <div className="flex">
{COLUMNS.map((c) => { {COLUMNS.map((c) => {
const n = row[c]; const n = row.counts[c] ?? 0;
const isSelected = selected?.treatment === row.key && selected.temperature === c; const isSelected = selected?.treatment === row.key && selected.temperature === c;
return ( return (
<button <button
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { RELEASE_REASON_META, type ReleaseReason } from '@pac/types'; import { RELEASE_REASON_META, releaseReasonsForForm, type ReleaseReason } from '@pac/types';
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
...@@ -66,14 +66,16 @@ export function ReleaseReasonDialog({ ...@@ -66,14 +66,16 @@ export function ReleaseReasonDialog({
<AlertDialogTitle className="text-[15px]"> <AlertDialogTitle className="text-[15px]">
退回「{patientName || '这位患者'} 退回「{patientName || '这位患者'}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-[12.5px]">
这单会回到召回池,由主管重新安排。选一个原因 —— 主管靠这个判断是派多了、
时效太紧,还是压根不该派给你。
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="max-h-[46vh] space-y-1 overflow-y-auto py-1"> <div className="max-h-[46vh] space-y-1 overflow-y-auto py-1">
{(Object.keys(RELEASE_REASON_META) as ReleaseReason[]).map((r) => { {/*
⚠️ 走 releaseReasonsForForm() 而不是 Object.keys —— META 里留着 5 个**历史值**
(不是我的客户 / 这段时间不在 / 时效太紧 / 最近刚联系过 / 其他),
它们只为翻译库里的老数据而存在,⛔ 不能出现在选项里。
直接 Object.keys 会把它们一起渲染出来,而且不报错。
*/}
{releaseReasonsForForm().map((r) => {
const m = RELEASE_REASON_META[r]; const m = RELEASE_REASON_META[r];
const on = reason === r; const on = reason === r;
return ( return (
...@@ -114,9 +116,6 @@ export function ReleaseReasonDialog({ ...@@ -114,9 +116,6 @@ export function ReleaseReasonDialog({
placeholder="写清楚具体情况(必填)" placeholder="写清楚具体情况(必填)"
className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400" className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400"
/> />
<p className="text-[10.5px] text-slate-400">
「其他原因」不写说明等于没填 —— 分布表里会堆出一坨读不出信息的「其他」。
</p>
</div> </div>
)} )}
......
...@@ -60,7 +60,7 @@ W1 ✅ 框架定稿 + 数据库结构评审 closure ...@@ -60,7 +60,7 @@ W1 ✅ 框架定稿 + 数据库结构评审 closure
| 1 | **5 家试点全量数据** | 瑞尔 / 瑞泰双品牌 5 家诊所、13 万患者池全部上测试服务器,真实召回任务成规模(不再是 100 个样本) | | 1 | **5 家试点全量数据** | 瑞尔 / 瑞泰双品牌 5 家诊所、13 万患者池全部上测试服务器,真实召回任务成规模(不再是 100 个样本) |
| 2 | **网络电话拨号** | 客服在工作台详情页直接点击拨号,无需切软电话 / 手机,通话即时发起 | | 2 | **网络电话拨号** | 客服在工作台详情页直接点击拨号,无需切软电话 / 手机,通话即时发起 |
| 3 | **实时 AI 辅助(伴飞)** | 通话过程中 AI 实时给提示(下一步说什么 / 异议怎么接),与本次话术上下文同源 | | 3 | **实时 AI 辅助(伴飞)** | 通话过程中 AI 实时给提示(下一步说什么 / 异议怎么接),与本次话术上下文同源 |
| 4 | **参考话术(沿用老版提示词)** | 话术按业务"原版提示词"生成,4 模块(开场白 / 告知应治未治 / 复查建议 / 结束回访语),自报家门用真实客服岗位 + 姓名 | | 4 | **参考话术(沿用老版提示词)** | 话术按业务"原版提示词"生成,4 模块(开场白 / 告知潜在治疗 / 复查建议 / 结束回访语),自报家门用真实客服岗位 + 姓名 |
| 5 | **患者详情打磨** | 关键事实(主治医生 / 专属客服 / 累计消费 / 保险)、治疗历史、病历快读(SOAP)、为什么召回 — 按老板意见逐项调整 | | 5 | **患者详情打磨** | 关键事实(主治医生 / 专属客服 / 累计消费 / 保险)、治疗历史、病历快读(SOAP)、为什么召回 — 按老板意见逐项调整 |
| 6 | **召回算法稳定** | 修复"0 命中遗留任务不关闭""全口病误召"等边界,召回池更干净 | | 6 | **召回算法稳定** | 修复"0 命中遗留任务不关闭""全口病误召"等边界,召回池更干净 |
......
...@@ -8,5 +8,7 @@ export * from './clinical-signals'; ...@@ -8,5 +8,7 @@ export * from './clinical-signals';
export * from './visit-recency'; export * from './visit-recency';
export * from './temperature'; export * from './temperature';
export * from './persona-tag-filters'; export * from './persona-tag-filters';
export * from './plan-reason-focus';
export * from './potential-label-rules';
export * from './host-action-message'; export * from './host-action-message';
export * from './kin-relationship'; export * from './kin-relationship';
...@@ -102,7 +102,10 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [ ...@@ -102,7 +102,10 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
{ value: 'early_ortho', zh: '早矫', hint: '建议儿童早期矫治(3-12 岁)' }, { value: 'early_ortho', zh: '早矫', hint: '建议儿童早期矫治(3-12 岁)' },
{ value: 'endo', zh: '根管', hint: '诊断牙髓问题,还没做根管' }, { value: 'endo', zh: '根管', hint: '诊断牙髓问题,还没做根管' },
{ value: 'perio', zh: '牙周', hint: '诊断牙周问题,还没治' }, { value: 'perio', zh: '牙周', hint: '诊断牙周问题,还没治' },
{ value: 'filling', zh: '补牙', hint: '诊断龋齿,还没补' }, // ⚠️ 「充填」不是「补牙」—— 业务 2026-07-29 定的措辞,矩阵/卡片渲染走
// `potentialTreatmentItemName`(labels.ts)。这里曾漏改,于是助手嘴里是「补牙」、
// 界面上是「充填」,主管看着像两件事。⛔ 改措辞两处必须一起改(有测试锁)。
{ value: 'filling', zh: '充填', hint: '诊断龋齿,还没补' },
{ value: 'restoration', zh: '修复', hint: '需要冠桥 / 贴面,还没做' }, { value: 'restoration', zh: '修复', hint: '需要冠桥 / 贴面,还没做' },
{ value: 'extraction', zh: '拔牙', hint: '残根残冠等需拔,还没拔' }, { value: 'extraction', zh: '拔牙', hint: '残根残冠等需拔,还没拔' },
], ],
......
/**
* 召回理由的**聚焦顺序** —— 「这一单,客服该先谈哪件事」。
*
* ═══ 为什么需要它 ══════════════════════════════════════════════════
* 一条 plan 可以有多条 reason(实测本地库:**40% 的在跑单跨多个业务标签**,
* 平均主次分差 15.3 分)。原来的规则只有一条:**分最高的那条排第一**,
* 而排第一的那条决定了三件事 ——
* · 详情页聚焦哪个诊断(plan-detail-app 的 focusedReason)
* · 话术整篇讲什么(fact-block 的 `plan.reasons[0]`:主诉/病种/牙位/病历/医生姓/日期锚)
* · 其余 reason 退为"顺带提一句"
*
* 🔴 问题:主管在矩阵上点「补牙 · 1年内」分下去一批人,**其中相当一部分**
* 身上分最高的是别的病(实测:补牙这一格 49%、根管 41%、阻生牙 38% 不是主因)。
* 客服打开看到的聚焦项和话术是缺牙/正畸,而主管以为自己在做补牙专项。
* **主管的分配意图在最后一米丢了**,且界面上没有任何迹象。
*
* ✅ 规则:**这单是通过某个批次分下来的 → 聚焦那个批次选的标签**;
* 没有批次(自助认领)或批次已撤销 → 完全等价于原来的"分最高"。
*
* ═══ 三处排序必须一起走这个函数 ═════════════════════════════════════
* ⚠️ 仓库里按 `priorityScore desc` 排 reasons 的地方**不止一处**,
* 只改其中一个,另外两个会静默把顺序洗回去(改完"看着没效果",且不报错):
* · plan-aggregate.service 详情页数据源(Prisma orderBy)
* · plan-detail-app.tsx 前端拿到后**又排了一次**
* · plan-script.orchestrator 话术生成前**再排一次**,取 [0] 当 top
* 本函数是这三处的唯一实现,⛔ 别在任何一处重写等价逻辑。
*/
/**
* 召回子规则 → 它能对应的**业务标签**(初选矩阵 X 轴的 8 类)。
*
* ⚠️ 一对多是真实的,不是偷懒:
* · `ortho_no_consult` 由年龄决定落 早矫(3-12) 还是 正畸(13-40) —— 同一患者只可能是其中一个
* · `hard_tissue_damage`(K03)由诊断名是否含「残根/残冠」决定落 拔牙 还是 修复
* 两者都要"看患者/看诊断名"才能定死,而**聚焦排序只需要判「这条 reason 有没有可能是
* 主管选的那一格」**,取候选集合做包含判断就够,不必把它解到唯一值。
*
* ⛔ 不在这里的子规则(development_eruption / jaw_cyst / extraction_recommended)
* 本来就不属于 8 类业务标签,矩阵里没有它们的行 —— 返回空集,永远不被选为聚焦项。
*/
export const SUBKEY_TO_POTENTIAL_LABELS: Readonly<Record<string, readonly string[]>> = {
missing_tooth: ['implant'],
caries_no_filling: ['filling'],
endo_no_rct: ['endo'],
perio_no_srp: ['perio'],
gum_alveolar_lesion: ['perio'],
impacted_tooth: ['extraction'],
hard_tissue_damage: ['restoration', 'extraction'],
ortho_no_consult: ['ortho', 'early_ortho'],
};
/**
* 取 sub_key 的规则名 —— 落库形态是 `caries_no_filling@36` / `perio_no_srp@whole`
* (`@` 之后是牙位或 `whole`)。⛔ 别用 split('@')[0] 之外的切法:牙位本身不含 `@`。
*/
export function subKeyRule(subKey: string | null | undefined): string {
if (!subKey) return '';
const i = subKey.indexOf('@');
return i === -1 ? subKey : subKey.slice(0, i);
}
/** 这条 reason 是否**可能**就是主管选的那一格 */
export function reasonMatchesLabel(
subKey: string | null | undefined,
label: string | null | undefined,
): boolean {
if (!label) return false;
const rule = subKeyRule(subKey);
if (!rule) return false;
return (SUBKEY_TO_POTENTIAL_LABELS[rule] ?? []).includes(label);
}
/** 排序只需要这两个字段 —— 服务端 Prisma 行、前端 DTO、编排器入参都满足 */
export interface FocusOrderable {
subKey?: string | null;
priorityScore: number;
}
/**
* 按「批次意图优先,其余按分」排序。**不修改入参**。
*
* @param focusLabel 批次 criteria 里的 potentialTreatment;
* ⚠️ 只在批次仍 **confirmed** 时传 —— 撤销后批次意图已作废,应回落"分最高"。
* 判据与话术带不带福利完全一致(见 plan-script.orchestrator 的 benefit),⛔ 别另立标准。
*
* `focusLabel` 为空、或没有任何 reason 命中它 → 结果与原来的 `priorityScore desc` **逐位相同**。
*/
export function focusOrderReasons<T extends FocusOrderable>(
reasons: readonly T[],
focusLabel?: string | null,
): T[] {
return [...reasons].sort((a, b) => {
const am = reasonMatchesLabel(a.subKey, focusLabel) ? 1 : 0;
const bm = reasonMatchesLabel(b.subKey, focusLabel) ? 1 : 0;
if (am !== bm) return bm - am; // 命中批次标签的排前面
return b.priorityScore - a.priorityScore; // 其余保持原口径
});
}
/**
* 诊断码 → 8 类业务标签(初选矩阵 X 轴)的**声明式**映射。
*
* ═══ 为什么要有这张表 ═══════════════════════════════════════════════
* 真理源本来是 `potential-treatment.feature.ts` 的 `classifyGapToLabel`(一个 switch)。
* 初选矩阵改成 **reason 驱动**之后,同一套判定必须在 **SQL 里**再表达一次
* (矩阵/确认单/列表三处都是原生 SQL,拿不到那个 TS 函数)。
*
* ⛔ 手在 SQL 里抄一遍 = 两份真理源。年龄闸一改、K03 关键词一加,两边就悄悄分叉,
* 表现是「矩阵里有这个人、点进去列表没有」——T6a 明令要防的那件事,而且不报错。
* ✅ 改成:**一张声明式表** → TS 侧和 SQL 侧都从它生成。
* `tests/potential-label-rules.spec.ts` 锁住本表与 `classifyGapToLabel` 在全量组合上等价。
*
* ═══ 顺序敏感,第一条命中即返回(与 switch 语义一致)═══════════════════
* K03 必须「含关键词 → 拔牙」在前、「其余 → 修复」在后;调换顺序会让所有 K03 都归修复。
*/
/** K03「该拔不是该补」的诊断名关键词 —— ⛔ 单一定义,SQL 与 TS 同源 */
export const EXTRACTION_NAME_KEYWORDS: readonly string[] = ['残根', '残冠', '无法保留', '不能保留'];
export interface PotentialLabelRule {
/** 诊断/建议的**组主码**(GAP_PRIMARY_GROUPS 的 key) */
code: string;
/** 命中后归入的业务标签 */
label: string;
/** 年龄**严格大于**(种植 K08 要求 >18) */
ageGt?: number;
/** 年龄闭区间(正畸 13-40 / 早矫 3-12) */
ageMin?: number;
ageMax?: number;
/** 诊断名需含其一(K03 → 拔牙) */
nameAny?: readonly string[];
}
/**
* ⚠️ 年龄为 null(无生日)时:带年龄条件的规则**一律不命中** —— 与 `classifyGapToLabel`
* 的 `age !== null && age > 18` / `if (age === null) return null` 一致。
* SQL 侧靠"与 NULL 比较得 NULL(非真)"天然获得同样语义,⛔ 别加 COALESCE 兜底。
* ⚠️ K00 / K09 / EXTRACTION_RECOMMENDED **刻意不在表里** —— 它们不属于 8 类业务标签,
* 矩阵没有这些行。落到 NULL 是正确结果,不是遗漏。
*/
export const POTENTIAL_LABEL_RULES: readonly PotentialLabelRule[] = [
{ code: 'K08', label: 'implant', ageGt: 18 },
{ code: 'K02', label: 'filling' },
{ code: 'K04', label: 'endo' },
{ code: 'K05', label: 'perio' },
{ code: 'K06', label: 'perio' },
{ code: 'K01', label: 'extraction' },
{ code: 'K03', label: 'extraction', nameAny: EXTRACTION_NAME_KEYWORDS },
{ code: 'K03', label: 'restoration' },
{ code: 'K07', label: 'early_ortho', ageMin: 3, ageMax: 12 },
{ code: 'K07', label: 'ortho', ageMin: 13, ageMax: 40 },
];
/**
* TS 侧判定(与 SQL 侧同源)。
* @param age 周岁;无生日传 null —— 带年龄条件的规则一律不命中
*/
export function classifyCodeToLabel(
code: string | null | undefined,
nameZh: string | null | undefined,
age: number | null,
): string | null {
if (!code) return null;
const nm = nameZh ?? '';
for (const r of POTENTIAL_LABEL_RULES) {
if (r.code !== code) continue;
if (r.ageGt !== undefined && !(age !== null && age > r.ageGt)) continue;
if (r.ageMin !== undefined && !(age !== null && age >= r.ageMin)) continue;
if (r.ageMax !== undefined && !(age !== null && age <= r.ageMax)) continue;
if (r.nameAny && !r.nameAny.some((k) => nm.includes(k))) continue;
return r.label;
}
return null;
}
...@@ -173,7 +173,11 @@ export const ListPlansQuerySchema = z.object({ ...@@ -173,7 +173,11 @@ export const ListPlansQuerySchema = z.object({
* (`patient-picker-rail` 直接遍历它),加进去温度就会出现在客服面板 —— 违 T16; * (`patient-picker-rail` 直接遍历它),加进去温度就会出现在客服面板 —— 违 T16;
* 且温度是**初选轴**不是精选标签(六·已定取舍:精选不再切窗口)。 * 且温度是**初选轴**不是精选标签(六·已定取舍:精选不再切窗口)。
*/ */
temperature: z.enum(['hot', 'warm', 'cold']).optional(), /// ⚠️ 六档(2026-08 冷端拆四);旧值 `cold` 仍收 —— 服务端 expandTemperatureFilter 展开成四档并集,
/// 这样已有的链接 / MCP 工具调用 / 助手上下文里的 `temperature=cold` 不会一夜之间全变非法。
temperature: z
.enum(['hot', 'warm', 'cold_1y', 'cold_2y', 'cold_3y', 'cold_over', 'cold'])
.optional(),
/// 与 temperature 配套的治疗项(矩阵 X 轴)。单给 temperature 会被服务端拒。 /// 与 temperature 配套的治疗项(矩阵 X 轴)。单给 temperature 会被服务端拒。
potentialTreatment: z.string().trim().min(1).optional(), potentialTreatment: z.string().trim().min(1).optional(),
/// 只看真实号码(patient.phoneVerified=true,外部对照表核实过的)。query 串传 'true'。 /// 只看真实号码(patient.phoneVerified=true,外部对照表核实过的)。query 串传 'true'。
......
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