Commit 291a3cdc by luoqi

feat(矩阵): 档位两版口径 —— 按诊断 / 按末诊,主管现切

「机会有多老」不可观测(onset_date 宿主 0 填充),手上只有两个观测时间:
  · diagnosis(默认) 医生最后一次写下该诊断 → 患者最后一次被提醒多久了(话术)
  · last_visit      患者末诊               → 这人多久没露面(接通率)

改口径的由头是实测出来的偏差:复诊时医生常不重复写同一诊断 ——
41.3% 的机会在诊断后患者又来过(平均 6 次)却一次都没再写,诊断日与末诊日
中位差 526 天(矩阵上差 1-2 档);各医生重复记录率 20.7%~43.2%,差一倍。
⇒ diagnosis 档位里含着"这个医生爱不爱重复写"的成分,不该被读成临床新鲜度。

🔴 **这是口径开关不是样式开关**,它决定分到的是哪批人。所以三处同时到位:
  ① 看(/plans/matrix?anchor=)与圈人(cohort-filter ← criteria.anchorMode)同 mode;
  ② 随确认单进 plan_assignments.criteria 快照;
  ③ 批次名标「(按末诊)」—— 只标非默认那版,老批次不用改名。
少任何一处就是「主管看到 87 人、确认单给另一批」,两个数都对且不报错(T14)。

助手侧判据只有一条:那句话里带「按末诊」三个字才传 last_visit
(ANCHOR_MODE_TOOL_DESC);矩阵的移交措辞与它必须同进同退。

两版**只换锚点,不换档位切分**(90/180 天 + 自然年),测试锁着这条。
本地实测(朝阳):按末诊后「3 年以上」整列清空、人数整体左移
(充填 半年到1年 326→420),因为诊断三年前没做的人其实一直在来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a3e31288
...@@ -3,8 +3,11 @@ import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type Tool ...@@ -3,8 +3,11 @@ import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type Tool
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { import {
Permission, Permission,
AnchorMode,
ANCHOR_MODE_TOOL_DESC,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES, TEMPERATURE_TOOL_VALUES,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
...@@ -168,6 +171,11 @@ export class AssistantService { ...@@ -168,6 +171,11 @@ export class AssistantService {
enum: [...TEMPERATURE_TOOL_VALUES], enum: [...TEMPERATURE_TOOL_VALUES],
description: TEMPERATURE_TOOL_DESC, description: TEMPERATURE_TOOL_DESC,
}, },
anchorMode: {
type: 'string',
enum: [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT],
description: ANCHOR_MODE_TOOL_DESC,
},
personaTags: { personaTags: {
type: 'string', type: 'string',
description: description:
...@@ -211,6 +219,7 @@ export class AssistantService { ...@@ -211,6 +219,7 @@ export class AssistantService {
clinicId?: string; clinicId?: string;
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue; temperature?: TemperatureValue;
anchorMode?: AnchorModeValue;
personaTags?: string; personaTags?: string;
expiresInDays?: number; expiresInDays?: number;
agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>; agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
......
...@@ -4,6 +4,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; ...@@ -4,6 +4,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod'; import { z } from 'zod';
import { import {
Permission, Permission,
AnchorMode,
ANCHOR_MODE_TOOL_DESC,
parseAnchorMode,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES, TEMPERATURE_TOOL_VALUES,
} from '@pac/types'; } from '@pac/types';
...@@ -357,11 +360,17 @@ export class McpServerFactory { ...@@ -357,11 +360,17 @@ export class McpServerFactory {
.enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]]) .enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]])
.optional() .optional()
.describe(TEMPERATURE_TOOL_DESC), .describe(TEMPERATURE_TOOL_DESC),
// 🔴 口径必须跟着人群走 —— 属性分布与确认单**必须是同一批人**(T6a 同源保证)。
// 少这一个参数:主管按末诊圈的批,属性分布却按诊断算,两个数字都"对"但不是同一群人。
anchorMode: z
.enum([AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT])
.optional()
.describe(ANCHOR_MODE_TOOL_DESC),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC), personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'), keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'),
}, },
}, },
async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) => async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) =>
jsonResult( jsonResult(
await this.cohorts.describe( await this.cohorts.describe(
scope, scope,
...@@ -369,6 +378,7 @@ export class McpServerFactory { ...@@ -369,6 +378,7 @@ export class McpServerFactory {
clinicId: resolveClinicId(scope, clinicId), clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature } : {}), ...(temperature ? { temperature } : {}),
anchorMode: parseAnchorMode(anchorMode),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
}, },
{ ...(keys ? { keys } : {}) }, { ...(keys ? { keys } : {}) },
......
...@@ -8,6 +8,8 @@ import { ...@@ -8,6 +8,8 @@ import {
type AgentInfo, type AgentInfo,
type AssignmentProposal, type AssignmentProposal,
type ProposedItem, type ProposedItem,
AnchorMode,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { calcAge } from '@pac/utils'; import { calcAge } from '@pac/utils';
...@@ -82,6 +84,12 @@ export class AssignmentProposalService { ...@@ -82,6 +84,12 @@ export class AssignmentProposalService {
potentialTreatment?: string; potentialTreatment?: string;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */ /** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
temperature?: TemperatureValue; temperature?: TemperatureValue;
/**
* 🔴 档位口径(按诊断 / 按末诊)—— **矩阵上切的是哪版,这里就必须是哪版**。
* 同一个「三个月内」两版圈出来的是两批不同的人;不带过来 = 主管看 87 人、
* 确认单给另一批,而且不报错(T14)。缺省 `diagnosis`(历史行为)。
*/
anchorMode?: AnchorModeValue;
/** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */ /** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
personaTags?: string; personaTags?: string;
/** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */ /** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */
...@@ -132,6 +140,8 @@ export class AssignmentProposalService { ...@@ -132,6 +140,8 @@ export class AssignmentProposalService {
clinicId, clinicId,
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}), ...(input.temperature ? { temperature: input.temperature } : {}),
// 🔴 口径必须进 criteria —— cohortWhereSql 靠它决定按哪个锚点圈人
anchorMode: input.anchorMode ?? AnchorMode.DIAGNOSIS,
...(input.personaTags ? { personaTags: input.personaTags } : {}), ...(input.personaTags ? { personaTags: input.personaTags } : {}),
}; };
assertCohortCriteria(criteria); assertCohortCriteria(criteria);
...@@ -425,6 +435,9 @@ export class AssignmentProposalService { ...@@ -425,6 +435,9 @@ export class AssignmentProposalService {
// ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清 // ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
// 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人) // 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
temperature: input.temperature ?? null, temperature: input.temperature ?? null,
/// ⭐ 口径同理必须下发 —— 前端写进 criteria 快照,批次名也靠它标「按末诊」。
/// ⛔ 别省:同一个「三个月内」两版是两批人,事后分不出来就永远说不清了。
anchorMode: criteria.anchorMode ?? AnchorMode.DIAGNOSIS,
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过 /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal, candidateTotal,
target, target,
......
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types'; import { Permission, parseAnchorMode } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator'; import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator'; import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -188,6 +188,8 @@ export class AssignmentController { ...@@ -188,6 +188,8 @@ export class AssignmentController {
clinicId: body.clinicId, clinicId: body.clinicId,
...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}), ...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
...(body.temperature ? { temperature: body.temperature as never } : {}), ...(body.temperature ? { temperature: body.temperature as never } : {}),
// ⚠️ 口径原样带回 —— 少了它,「重新排一版」会静默换成另一批人
anchorMode: parseAnchorMode(body.anchorMode),
...(body.personaTags ? { personaTags: body.personaTags } : {}), ...(body.personaTags ? { personaTags: body.personaTags } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}), ...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}), ...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AnchorMode,
ANCHOR_MODE_META,
PERSONA_TAG_FILTER_DIMS, PERSONA_TAG_FILTER_DIMS,
personaTagDimId, personaTagDimId,
visitRecencyRange, visitRecencyRange,
VISIT_RECENCY_BUCKETS, VISIT_RECENCY_BUCKETS,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
type AnchorModeValue,
type PersonaTagFilterDim, type PersonaTagFilterDim,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
...@@ -80,14 +83,20 @@ export class CohortAttributesService { ...@@ -80,14 +83,20 @@ export class CohortAttributesService {
* ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。 * ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。
* ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 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(),
/// 档位口径(按诊断 / 按末诊)。⚠️ 出确认单时**必须**把同一个值带进 criteria,见 AnchorMode 注释
anchorMode: AnchorModeValue = AnchorMode.DIAGNOSIS,
) {
void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂) void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂)
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ label: string; temp: string | null; n: bigint }> Array<{ label: string; temp: string | null; n: bigint }>
>( >(
Prisma.sql` Prisma.sql`
WITH la AS ( WITH la AS (
${planLabelAnchorsSql(poolBaseSql(scope, clinicId))} ${planLabelAnchorsSql(poolBaseSql(scope, clinicId), anchorMode)}
) )
SELECT label, SELECT label,
${temperatureBucketCaseSql( ${temperatureBucketCaseSql(
...@@ -133,10 +142,15 @@ export class CohortAttributesService { ...@@ -133,10 +142,15 @@ export class CohortAttributesService {
}; };
}), }),
unknownTotal, unknownTotal,
/// ⭐ 口径随数据一起下发 —— 前端拿它渲染列头说明,助手拿它进 criteria。
/// ⛔ 别让前端"自己记得刚才切的是哪版":那等于第二份真理源,刷新一次就漂。
anchorMode,
anchorModeZh: ANCHOR_MODE_META[anchorMode].zh,
note: note:
unknownTotal > 0 unknownTotal > 0
? `另有 ${unknownTotal} 人**算不出温度**(召回证据里没有可用的诊断日),已单列,` + ? `另有 ${unknownTotal} 人**算不出档位**(${
`⛔ 没有并进任何冷档 —— 并进去数字好看但那是假分布。` anchorMode === AnchorMode.LAST_VISIT ? '没有末诊记录' : '召回证据里没有可用的诊断日'
}),已单列,⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。`
: '', : '',
}; };
} }
......
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AnchorMode,
PERSONA_TAG_FILTER_DIMS, PERSONA_TAG_FILTER_DIMS,
expandTemperatureFilter, expandTemperatureFilter,
Temperature, Temperature,
parsePersonaTags, parsePersonaTags,
personaTagDimId, personaTagDimId,
visitRecencyRange, visitRecencyRange,
type AnchorModeValue,
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';
...@@ -31,6 +33,13 @@ export interface CohortCriteria { ...@@ -31,6 +33,13 @@ export interface CohortCriteria {
potentialTreatment?: string; potentialTreatment?: string;
/** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */ /** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
temperature?: TemperatureValue; temperature?: TemperatureValue;
/**
* 🔴 档位口径(按诊断 / 按末诊)—— **必须跟着 temperature 一起传**。
* 同一个「三个月内」,两个 mode 圈出来的是**两批不同的人**;这里少传一个字段,
* 主管在矩阵上按末诊看了 87 人、确认单却按诊断捞了另一批,而且不报错(T14)。
* ⚠️ 缺省 = `diagnosis`(历史行为),老批次 criteria 里没这个键时也走这条。
*/
anchorMode?: AnchorModeValue;
/** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */ /** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
personaTags?: string; personaTags?: string;
} }
...@@ -88,10 +97,11 @@ function temperatureSql( ...@@ -88,10 +97,11 @@ function temperatureSql(
potentialTreatment: string, potentialTreatment: string,
temperature: TemperatureValue, temperature: TemperatureValue,
now: Date, now: Date,
mode: AnchorModeValue,
): Prisma.Sql { ): Prisma.Sql {
void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂) void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂)
// ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档 // ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档
return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature)); return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature), mode);
} }
/** /**
...@@ -166,7 +176,11 @@ export function cohortWhereSql( ...@@ -166,7 +176,11 @@ export function cohortWhereSql(
return Prisma.sql` return Prisma.sql`
${poolBaseSql(scope, c.clinicId)} ${poolBaseSql(scope, c.clinicId)}
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty} ${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${c.temperature && c.potentialTreatment ? temperatureSql(c.potentialTreatment, c.temperature, now) : Prisma.empty} ${
c.temperature && c.potentialTreatment
? temperatureSql(c.potentialTreatment, c.temperature, now, c.anchorMode ?? AnchorMode.DIAGNOSIS)
: Prisma.empty
}
${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty} ${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty}
`; `;
} }
...@@ -17,6 +17,8 @@ import { ...@@ -17,6 +17,8 @@ import {
type AgentWorkloadResponse, type AgentWorkloadResponse,
RELEASE_REASON_META, RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES, REVOKE_WINDOW_MINUTES,
ANCHOR_MODE_META,
parseAnchorMode,
TEMPERATURE_META, TEMPERATURE_META,
potentialTreatmentCardLabel, potentialTreatmentCardLabel,
type TemperatureValue, type TemperatureValue,
...@@ -714,7 +716,11 @@ export class PlanAssignmentService { ...@@ -714,7 +716,11 @@ export class PlanAssignmentService {
private buildLabel(head: { private buildLabel(head: {
criteria: unknown; createdAt: Date; status: string; attributes: unknown; criteria: unknown; createdAt: Date; status: string; attributes: unknown;
}, planned: number, agents: number, tz: string): string { }, planned: number, agents: number, tz: string): string {
const c = (head.criteria ?? {}) as { potentialTreatment?: string; temperature?: string }; const c = (head.criteria ?? {}) as {
potentialTreatment?: string;
temperature?: string;
anchorMode?: string;
};
const parts: string[] = [formatBatchTime(head.createdAt, tz)]; const parts: string[] = [formatBatchTime(head.createdAt, tz)];
if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment)); if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
if (c.temperature) { if (c.temperature) {
...@@ -728,8 +734,17 @@ export class PlanAssignmentService { ...@@ -728,8 +734,17 @@ export class PlanAssignmentService {
* 写成"1 年内/1–2 年/2–3 年/3 年以上"反而是编了一个当时不存在的精度。 * 写成"1 年内/1–2 年/2–3 年/3 年以上"反而是编了一个当时不存在的精度。
*/ */
const t = c.temperature; const t = c.temperature;
/**
* 🔴 **口径要跟着档名一起说**(2026-08-11 两版并存之后)。
* 「种植 · 三个月内」按诊断和按末诊是**两批完全不同的人**;名字里不标,
* 主管过两周回看这批就分不出当时点的是哪一版 —— 而 criteria 存了、名字不说,
* 等于把证据锁在库里(批次名是他指认一批的唯一抓手)。
* ⚠️ 只标非默认那版:`diagnosis` 的 batchTag 是空串,老批次不受影响、不用改名。
*/
const tag = ANCHOR_MODE_META[parseAnchorMode(c.anchorMode)].batchTag;
parts.push( parts.push(
TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t), (TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t)) +
(tag ? `(${tag})` : ''),
); );
} }
parts.push(`${planned} 人`); parts.push(`${planned} 人`);
......
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types'; import { Permission, parseAnchorMode } from '@pac/types';
import { import {
CurrentUser, CurrentUser,
AuthenticatedUser, AuthenticatedUser,
...@@ -105,18 +105,31 @@ export class PlanController { ...@@ -105,18 +105,31 @@ export class PlanController {
@Get('matrix') @Get('matrix')
@RequirePermission(Permission.PLAN_DISPATCH) @RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({ @ApiOperation({
summary: '初选矩阵(潜在治疗 × 窗口温度),按患者去重', summary: '初选矩阵(潜在治疗 × 时间档),按患者去重',
description: description:
'口径是**去重患者数**,不是 plan 条数(列表页按 plan 分页)。' + '口径是**去重患者数**,不是 plan 条数(列表页按 plan 分页)。' +
'温度定义见 packages/types/temperature.ts:逐条 gap 用自己 K 码的窗口判档、取最热。', '档位定义见 packages/types/temperature.ts:六档同一条绝对时间轴(90/180 天 + 自然年)。' +
'`anchor=diagnosis`(默认,医生最后一次写下该诊断)/ `anchor=last_visit`(患者末诊)—— ' +
'⚠️ 出确认单时必须把同一个值带进 criteria,否则看到的和分到的是两批人。',
}) })
matrix(@TenantScope() scope: TenantScopeContext, @Query('clinicId') clinicId?: string) { matrix(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId?: string,
@Query('anchor') anchor?: string,
) {
/** /**
* 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。 * 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。
* 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**, * 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**,
* 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。 * 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。
* ⚠️ `anchor` 走 `parseAnchorMode` 兜非法值(查询串是用户可改的)——
* ⛔ 别在这里抛 400:切错一个字母就整屏报错,而回落默认口径是安全的。
*/ */
return this.cohorts.matrix(scope, resolveClinicId(scope, clinicId)); return this.cohorts.matrix(
scope,
resolveClinicId(scope, clinicId),
new Date(),
parseAnchorMode(anchor),
);
} }
@Get(':id') @Get(':id')
......
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AnchorMode,
COLD_BUCKET_YEARS, COLD_BUCKET_YEARS,
COLD_TEMPERATURES, COLD_TEMPERATURES,
HOT_BUCKET_DAYS, HOT_BUCKET_DAYS,
POTENTIAL_LABEL_RULES, POTENTIAL_LABEL_RULES,
Temperature, Temperature,
WARM_BUCKET_DAYS, WARM_BUCKET_DAYS,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
...@@ -82,9 +84,36 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql ...@@ -82,9 +84,36 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql
* `make_interval(days => double precision)` **这个函数不存在** → 整个 /plans/matrix 报 500 * `make_interval(days => double precision)` **这个函数不存在** → 整个 /plans/matrix 报 500
* (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。 * (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。
*/ */
const HOT_UNTIL_SQL = Prisma.sql`max(anc.at + make_interval(days => ${HOT_BUCKET_DAYS}::int))`; /**
const WARM_UNTIL_SQL = Prisma.sql`max(anc.at + make_interval(days => ${WARM_BUCKET_DAYS}::int))`; * 锚点表达式 —— **两版口径**(见 @pac/types 的 `AnchorMode`)。
const ANCHOR_MAX_SQL = Prisma.sql`max(anc.at)`; *
* · diagnosis `anc.at` = 该标签最新的未治疗诊断(默认,历史行为)
* · last_visit `lvp.last_visit_at` = 患者末诊(**患者级**,同一人所有标签同值)
*
* ⚠️ 两版都套**同一个** `temperatureBucketCaseSql`(90/180 天 + 自然年)——
* 换的只是"距哪个时刻",档位切分本身一个字都不动。
* ⚠️ `last_visit_at` 取 `patient_profiles`(本地实测 5,825 人 100% 有值、100% 与
* encounter/emr 事实的最大 occurred_at 同日),⛔ 别为它再去 join 一遍 patient_facts。
* ⚠️ 末诊为 NULL → 三个值全 NULL → 落 `unknown` 档(与"边界缺失"同路),
* ⛔ 不许默认塞进某一档。
*/
function anchorExpr(mode: AnchorModeValue): Prisma.Sql {
return mode === AnchorMode.LAST_VISIT ? Prisma.sql`lvp.last_visit_at` : Prisma.sql`anc.at`;
}
function boundsSql(mode: AnchorModeValue) {
const a = anchorExpr(mode);
return {
hot: Prisma.sql`max(${a} + make_interval(days => ${HOT_BUCKET_DAYS}::int))`,
warm: Prisma.sql`max(${a} + make_interval(days => ${WARM_BUCKET_DAYS}::int))`,
anchor: Prisma.sql`max(${a})`,
};
}
/// 末诊口径要多一张表;诊断口径下**不 join**(省一次无谓的扫描)
function lastVisitJoin(mode: AnchorModeValue): Prisma.Sql {
return mode === AnchorMode.LAST_VISIT
? Prisma.sql`LEFT JOIN patient_profiles lvp ON lvp.patient_id = fp.patient_id`
: Prisma.empty;
}
/** /**
* 六档判定的 CASE。 * 六档判定的 CASE。
...@@ -128,12 +157,16 @@ export function temperatureBucketCaseSql( ...@@ -128,12 +157,16 @@ export function temperatureBucketCaseSql(
* ⚠️ 三个聚合都用 max(取最热 / 取最新)—— 见 temperature.ts:统一成"最早"会让 * ⚠️ 三个聚合都用 max(取最热 / 取最新)—— 见 temperature.ts:统一成"最早"会让
* hot/warm 反单调(多一条旧需求反而更冷)。 * hot/warm 反单调(多一条旧需求反而更冷)。
*/ */
export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql { export function planLabelAnchorsSql(
planFilter: Prisma.Sql,
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql {
const b = boundsSql(mode);
return Prisma.sql` return Prisma.sql`
SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label, SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
${HOT_UNTIL_SQL} AS hot_until, ${b.hot} AS hot_until,
${WARM_UNTIL_SQL} AS warm_until, ${b.warm} AS warm_until,
${ANCHOR_MAX_SQL} AS anchor_at ${b.anchor} AS anchor_at
FROM followup_plans fp FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id JOIN plan_reasons pr ON pr.plan_id = fp.id
...@@ -141,6 +174,7 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql { ...@@ -141,6 +174,7 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active' 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 COALESCE(f.occurred_at, f.planned_for) AS at) anc
CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab
${lastVisitJoin(mode)}
WHERE ${planFilter} WHERE ${planFilter}
AND lab.lbl IS NOT NULL AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL AND anc.at IS NOT NULL
...@@ -174,7 +208,14 @@ export function labelExistsSql(label: string): Prisma.Sql { ...@@ -174,7 +208,14 @@ export function labelExistsSql(label: string): Prisma.Sql {
export function labelTemperatureExistsSql( export function labelTemperatureExistsSql(
label: string, label: string,
buckets: readonly TemperatureValue[], buckets: readonly TemperatureValue[],
/**
* 🔴 **必须与矩阵那次查询用同一个 mode**。这里是**圈人**侧,矩阵是**看**侧 ——
* 两边 mode 不一致 = 主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
* 调用方(`cohort-filter`)从 `criteria.anchorMode` 取,⛔ 别在这里给"聪明的默认"。
*/
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql { ): Prisma.Sql {
const b = boundsSql(mode);
return Prisma.sql` return Prisma.sql`
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
...@@ -182,10 +223,11 @@ export function labelTemperatureExistsSql( ...@@ -182,10 +223,11 @@ export function labelTemperatureExistsSql(
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active' 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 COALESCE(f.occurred_at, f.planned_for) AS at) anc
${lastVisitJoin(mode)}
WHERE pr.plan_id = fp.id WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label} AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
AND anc.at IS NOT NULL AND anc.at IS NOT NULL
HAVING ${temperatureBucketCaseSql(HOT_UNTIL_SQL, WARM_UNTIL_SQL, ANCHOR_MAX_SQL)} IN (${Prisma.join( HAVING ${temperatureBucketCaseSql(b.hot, b.warm, b.anchor)} IN (${Prisma.join(
buckets.map((t) => Prisma.sql`${t}`), buckets.map((t) => Prisma.sql`${t}`),
', ', ', ',
)}) )})
......
...@@ -168,7 +168,9 @@ describe('初选矩阵', () => { ...@@ -168,7 +168,9 @@ describe('初选矩阵', () => {
expect(implant.counts.cold_1y).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('算不出温度'); // ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了),
// 而且原因随口径变(按诊断=没诊断日 / 按末诊=没末诊记录)。
expect(m.note).toContain('算不出档位');
expect(m.note).toContain('没有并进'); expect(m.note).toContain('没有并进');
}); });
......
import { HOT_BUCKET_DAYS, Temperature, WARM_BUCKET_DAYS } from '@pac/types'; import { AnchorMode, HOT_BUCKET_DAYS, Temperature, WARM_BUCKET_DAYS } from '@pac/types';
import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter'; import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter';
/** /**
...@@ -129,3 +129,46 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () = ...@@ -129,3 +129,46 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () =
.some((v) => v instanceof Date)).toBe(false); .some((v) => v instanceof Date)).toBe(false);
}); });
}); });
/**
* 🔴 **两版口径的圈人侧** —— 这一层错了,后果是「主管看到 87 人、确认单给另一批」,
* 而且两个数字都"对"、全程不报错(T14 口径对数;矩阵 67→324 那次事故的同构版本)。
*/
describe('人群取数 —— 档位口径(按诊断 / 按末诊)', () => {
const sql = (mode: (typeof AnchorMode)[keyof typeof AnchorMode]) =>
cohortWhereSql(
SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT, anchorMode: mode },
NOW,
);
test('⭐⭐ 按末诊时**必须**join patient_profiles 并用 last_visit_at 当锚点', () => {
const t = sql(AnchorMode.LAST_VISIT).strings.join('?');
expect(t).toContain('patient_profiles');
expect(t).toContain('last_visit_at');
});
test('⭐⭐ 按诊断时**不许**碰末诊 —— 多 join 一张表就是另一个口径', () => {
const t = sql(AnchorMode.DIAGNOSIS).strings.join('?');
expect(t).not.toContain('last_visit_at');
});
test('⭐ 缺省 = 按诊断(老 criteria 没有这个键,行为必须原样不变)', () => {
const legacy = cohortWhereSql(
SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
NOW,
).strings.join('?');
expect(legacy).toBe(sql(AnchorMode.DIAGNOSIS).strings.join('?'));
});
test('⭐⭐ 两版的**档位切分**必须完全一样 —— 换的只是锚点', () => {
for (const mode of [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT]) {
const vals = sql(mode).values.flat();
expect(vals).toContain(HOT_BUCKET_DAYS);
expect(vals).toContain(WARM_BUCKET_DAYS);
// 自然年推法两版共用(⛔ 不许一版 365 天一版 interval year)
expect(sql(mode).strings.join('?')).toMatch(/interval '\d+ years'/);
}
});
});
import { import {
AnchorMode,
ANCHOR_MODE_META,
HOT_BUCKET_DAYS, HOT_BUCKET_DAYS,
parseAnchorMode,
Temperature, Temperature,
TEMPERATURE_META, TEMPERATURE_META,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
...@@ -183,10 +186,19 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', () ...@@ -183,10 +186,19 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', ()
describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => { test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('诊断距今 ≤ 90 天'); // 默认口径(按诊断)—— 主语是"医生最后一次提到",⛔ 不是"诊断距今"
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('诊断距今 90–180 天'); // (2026-08-11 两版并存后改的措辞:复诊漏写时,它表达的确实是"最后一次被提及")
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('诊断距今 180 天–1 年'); expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('医生最后一次提到 ≤ 90 天');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('诊断距今 > 3 年'); expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('医生最后一次提到 90–180 天');
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('医生最后一次提到 180 天–1 年');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('医生最后一次提到 > 3 年');
// 末诊口径:同一档、同样的天数,**主语换成人**
expect(temperatureWindowHint('implant', Temperature.HOT, AnchorMode.LAST_VISIT)).toBe(
'距上次到诊 ≤ 90 天',
);
expect(temperatureWindowHint('implant', Temperature.COLD_OVER, AnchorMode.LAST_VISIT)).toBe(
'距上次到诊 > 3 年',
);
}); });
test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => { test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => {
...@@ -225,7 +237,7 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { ...@@ -225,7 +237,7 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => { test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => {
// 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的, // 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的,
// 空串反而是错的(格子会失去 hover 提示)。 // 空串反而是错的(格子会失去 hover 提示)。
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('诊断距今 ≤ 90 天'); expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('医生最后一次提到 ≤ 90 天');
}); });
}); });
...@@ -263,3 +275,32 @@ describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => { ...@@ -263,3 +275,32 @@ describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => {
expect(TEMPERATURE_TOOL_DESC).toMatch(/合计|并集|全体/); expect(TEMPERATURE_TOOL_DESC).toMatch(/合计|并集|全体/);
}); });
}); });
/**
* ⭐ 两版口径(按诊断 / 按末诊)—— 2026-08-11 并存后的契约。
*
* 🔴 这里锁的是**口径分叉**那一类事故:切换只能改"距哪个时刻",
* ⛔ 不许顺手改档位切分(90/180/自然年)—— 那会让两版连人数量级都不可比。
*/
describe('档位口径 —— 两版并存', () => {
test('⭐ 非法 / 缺省一律回落按诊断(查询串是用户可改的,⛔ 不抛错)', () => {
expect(parseAnchorMode(undefined)).toBe(AnchorMode.DIAGNOSIS);
expect(parseAnchorMode('')).toBe(AnchorMode.DIAGNOSIS);
expect(parseAnchorMode('lastvisit')).toBe(AnchorMode.DIAGNOSIS); // 少个下划线也不许猜
expect(parseAnchorMode(AnchorMode.LAST_VISIT)).toBe(AnchorMode.LAST_VISIT);
});
test('⭐⭐ 只有非默认那版带批次名标记 —— 老批次不受影响、不用改名', () => {
expect(ANCHOR_MODE_META[AnchorMode.DIAGNOSIS].batchTag).toBe('');
expect(ANCHOR_MODE_META[AnchorMode.LAST_VISIT].batchTag).toBe('按末诊');
});
test('⭐⭐ 两版的**档位切分**必须逐字相同 —— 换的只是主语', () => {
for (const t of TEMPERATURE_ORDER) {
const a = temperatureWindowHint('implant', t, AnchorMode.DIAGNOSIS);
const b = temperatureWindowHint('implant', t, AnchorMode.LAST_VISIT);
// 去掉主语后必须一模一样(天数/年数一个字都不能差)
expect(a.replace('医生最后一次提到', '')).toBe(b.replace('距上次到诊', ''));
}
});
});
...@@ -490,6 +490,9 @@ export function AssignmentConfirmSheet({ ...@@ -490,6 +490,9 @@ export function AssignmentConfirmSheet({
// 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的, // 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的,
// 也没法反推"哪种格子分下去效果好"(实测漏了很久)。 // 也没法反推"哪种格子分下去效果好"(实测漏了很久)。
temperature: sheet.temperature, temperature: sheet.temperature,
// 🔴 口径与温度同等重要:同一个「三个月内」按诊断/按末诊是两批完全不同的人。
// 不存这一个键,批次名说不清、事后也没法反推"哪一版分下去效果好"。
anchorMode: sheet.anchorMode,
candidateTotal: sheet.candidateTotal, candidateTotal: sheet.candidateTotal,
target: sheet.target, target: sheet.target,
selectionNote: sheet.selectionNote, selectionNote: sheet.selectionNote,
...@@ -1290,6 +1293,8 @@ export function AssignmentConfirmSheet({ ...@@ -1290,6 +1293,8 @@ export function AssignmentConfirmSheet({
clinicId: sheet.clinicId, clinicId: sheet.clinicId,
potentialTreatment: sheet.potentialTreatment, potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature, temperature: sheet.temperature,
// ⚠️ 口径也要带 —— 重排的是同一批人群,换口径就换了人群
anchorMode: sheet.anchorMode,
targetCount: sheet.batchSize, targetCount: sheet.batchSize,
expiresInDays, expiresInDays,
}); });
......
'use client'; 'use client';
import type { import type {
AnchorModeValue,
TemperatureValue, TemperatureValue,
ListPlansQuery, ListPlansQuery,
ListPlansResponse, ListPlansResponse,
...@@ -32,6 +33,9 @@ export interface PoolMatrix { ...@@ -32,6 +33,9 @@ export interface PoolMatrix {
unknownTotal: number; unknownTotal: number;
/** 有待重算的人时才非空;成品句子,直接展示 */ /** 有待重算的人时才非空;成品句子,直接展示 */
note: string; note: string;
/** ⭐ 这份数据是按哪个锚点算的 —— 由**服务端**回声,⛔ 前端别自己记(刷新就漂) */
anchorMode?: AnchorModeValue;
anchorModeZh?: string;
} }
export const plansApi = { export const plansApi = {
...@@ -63,8 +67,8 @@ export const plansApi = { ...@@ -63,8 +67,8 @@ export const plansApi = {
* 初选矩阵(8 潜在治疗 × 3 窗口温度)。 * 初选矩阵(8 潜在治疗 × 3 窗口温度)。
* ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。 * ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。
*/ */
matrix: (clinicId?: string) => matrix: (clinicId?: string, anchor?: AnchorModeValue) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId } }), api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId, anchor } }),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/ /** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'), doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
......
'use client'; 'use client';
import { import {
ANCHOR_MODE_META,
AnchorMode,
TEMPERATURE_META, TEMPERATURE_META,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
potentialTreatmentItemName, potentialTreatmentItemName,
temperatureWindowHint, temperatureWindowHint,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
...@@ -92,6 +95,13 @@ export function PoolMatrix({ ...@@ -92,6 +95,13 @@ export function PoolMatrix({
* ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。 * ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。
*/ */
className, className,
/**
* 档位口径(按诊断 / 按末诊)—— 只影响**格子 hover 的那句话**与底部说明的措辞;
* 数字本身是服务端按同一个 mode 算好的。
* ⚠️ 默认 `diagnosis` 是为了让老调用点行为不变,⛔ 别把它当"聪明的默认"——
* 调用方拿到的数据是哪版,就必须传哪版(见 new-batch 的 dataMode)。
*/
anchorMode = AnchorMode.DIAGNOSIS,
}: { }: {
/** /**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。 * 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
...@@ -101,6 +111,7 @@ export function PoolMatrix({ ...@@ -101,6 +111,7 @@ export function PoolMatrix({
* 数据到了再重排,浮层的抖动比列表刺眼得多。 * 数据到了再重排,浮层的抖动比列表刺眼得多。
*/ */
data: PoolMatrixData; data: PoolMatrixData;
anchorMode?: AnchorModeValue;
/** 当前选中的格子(回显用) */ /** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null; selected?: { treatment: string; temperature: TempKey } | null;
className?: string; className?: string;
...@@ -169,7 +180,13 @@ export function PoolMatrix({ ...@@ -169,7 +180,13 @@ export function PoolMatrix({
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */} {/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}> <div className={cn('flex-1 overflow-hidden rounded', PLANE)}>
{data.rows.map((row) => ( {data.rows.map((row) => (
<MatrixRow key={row.key} row={row} selected={selected} onPick={onPick} /> <MatrixRow
key={row.key}
row={row}
selected={selected}
onPick={onPick}
anchorMode={anchorMode}
/>
))} ))}
</div> </div>
...@@ -178,7 +195,9 @@ export function PoolMatrix({ ...@@ -178,7 +195,9 @@ export function PoolMatrix({
{data.rows.map((row) => ( {data.rows.map((row) => (
<div <div
key={row.key} key={row.key}
title="算不出温度 —— 召回证据里没有可用的诊断日" title={`算不出档位 —— ${
anchorMode === AnchorMode.LAST_VISIT ? '这些人没有末诊记录' : '召回证据里没有可用的诊断日'
}`}
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',
...@@ -198,7 +217,12 @@ export function PoolMatrix({ ...@@ -198,7 +217,12 @@ export function PoolMatrix({
否则主管把各行相加会发现比池子总数大,当场以为系统在骗他(实测 2,526 vs 2,129)。 否则主管把各行相加会发现比池子总数大,当场以为系统在骗他(实测 2,526 vs 2,129)。
*/} */}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground"> <p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
<span className="font-medium">诊断距今多久</span>分档。一个人有几个潜在治疗就出现在几行。 <span className="font-medium">{ANCHOR_MODE_META[anchorMode].axisZh}距今多久</span>分档。
{anchorMode === AnchorMode.LAST_VISIT
? // ⚠️ 末诊是**患者级**的:同一个人的几个潜在治疗必然落同一档 —— 不说清楚,
// 主管会以为"这人三个机会都刚诊断"。⛔ 别省这半句。
'末诊按人算,所以同一个人的几个治疗项都在同一档。'
: '一个人有几个潜在治疗就出现在几行。'}
</p> </p>
{data.note && ( {data.note && (
...@@ -215,8 +239,10 @@ function MatrixRow({ ...@@ -215,8 +239,10 @@ function MatrixRow({
row, row,
selected, selected,
onPick, onPick,
anchorMode,
}: { }: {
row: PoolMatrixRow; row: PoolMatrixRow;
anchorMode: AnchorModeValue;
selected?: { treatment: string; temperature: TempKey } | null; selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: { onPick: (cell: {
treatment: string; treatment: string;
...@@ -253,7 +279,7 @@ function MatrixRow({ ...@@ -253,7 +279,7 @@ function MatrixRow({
// ⭐ hover 文案保持原样:只给**天数区间**,不给解释。 // ⭐ hover 文案保持原样:只给**天数区间**,不给解释。
// 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声; // 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声;
// 区间是主管唯一看不出来的那个信息。 // 区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c)} title={temperatureWindowHint(row.key, c, anchorMode)}
className={cn( className={cn(
ROW_H, ROW_H,
'flex-1 text-[13px] text-slate-900 nums', 'flex-1 text-[13px] text-slate-900 nums',
......
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { TEMPERATURE_META, type TemperatureValue } from '@pac/types'; import {
AnchorMode,
ANCHOR_MODE_META,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api'; import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix'; import { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store'; import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { cn } from '@/lib/utils';
/** /**
* 「分一批新的」—— **常驻面板**,摆在「我分的批次」正上方。 * 「分一批新的」—— **常驻面板**,摆在「我分的批次」正上方。
...@@ -34,10 +41,20 @@ type Picked = { ...@@ -34,10 +41,20 @@ type Picked = {
rect: { x: number; y: number; w: number; h: number }; rect: { x: number; y: number; w: number; h: number };
}; };
function handoff(c: Picked) { /**
* 🔴 口径要**写进交给助手的那句话里**。
*
* 移交只有一条自然语言通道(模型看不到界面开关),所以「按末诊」这三个字就是
* 主管选择的**唯一载体** —— `ANCHOR_MODE_TOOL_DESC` 那边的判据也正是"话里带没带这三个字"。
* ⛔ 别改这句的措辞而不同步改工具说明:改了模型就不传 `anchorMode`,
* 主管在按末诊那版上点的格子,确认单会按诊断口径捞另一批人 —— 而且不报错(T14)。
*/
function handoff(c: Picked, mode: AnchorModeValue) {
const tempZh = TEMPERATURE_META[c.temperature].zh; const tempZh = TEMPERATURE_META[c.temperature].zh;
const tag = ANCHOR_MODE_META[mode].batchTag;
const cell = `${c.treatmentZh} · ${tempZh}${tag ? `(${tag})` : ''}`;
useAssistantStore.getState().handoff({ useAssistantStore.getState().handoff({
text: `帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`, text: `帮我给「${cell}」这批患者出一份分配方案`,
from: c.rect, from: c.rect,
count: c.count, count: c.count,
treatment: c.treatmentZh, treatment: c.treatmentZh,
...@@ -50,13 +67,31 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -50,13 +67,31 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** /**
* 换诊所先清空 —— ⛔ 不能让上一家的人数在新诊所的标题下面多留一帧。 * 🔴 档位口径 —— **这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。
* ⚠️ 只挂 `clinicId`:下面那次「分完重拉」不走这里,否则每分一批矩阵都要闪一下骨架。 * 它决定分到的是哪批人:同一个「三个月内」,按诊断和按末诊是两批完全不同的人。
*
* ⚠️ 记进 localStorage 是为了**别让他每次进来都要重切**(主管多半长期用同一版);
* 但切换那一刻必须**看得见**(下面开关旁边那句说明),⛔ 不能像样式开关那样悄悄记住。
*/
const [mode, setMode] = useState<AnchorModeValue>(AnchorMode.DIAGNOSIS);
useEffect(() => {
const v = localStorage.getItem('pac.anchorMode');
if (v === AnchorMode.LAST_VISIT || v === AnchorMode.DIAGNOSIS) setMode(v);
}, []);
const pickMode = (m: AnchorModeValue) => {
setMode(m);
localStorage.setItem('pac.anchorMode', m);
};
/**
* 换诊所 / 换口径先清空 —— ⛔ 不能让上一版的人数在新标题下面多留一帧
* (那一帧里主管点下去,拿到的就是另一批人)。
* ⚠️ 只挂 `clinicId` / `mode`:下面那次「分完重拉」不走这里,否则每分一批矩阵都要闪一下骨架。
*/ */
useEffect(() => { useEffect(() => {
setMatrix(null); setMatrix(null);
setError(null); setError(null);
}, [clinicId]); }, [clinicId, mode]);
/** /**
* 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`): * 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`):
...@@ -67,23 +102,55 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -67,23 +102,55 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
if (!clinicId) return; if (!clinicId) return;
let alive = true; let alive = true;
plansApi plansApi
.matrix(clinicId) .matrix(clinicId, mode)
.then((r) => alive && setMatrix(r)) .then((r) => alive && setMatrix(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败')); .catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败'));
return () => { return () => {
alive = false; alive = false;
}; };
}, [clinicId, syncSeq]); }, [clinicId, syncSeq, mode]);
const pick = useCallback((c: Picked) => handoff(c), []); /**
* ⚠️ 口径取**服务端回声**(`matrix.anchorMode`)而不是本地 state:
* 两者理论上一致,但万一请求慢/失败留着旧数据,回声能保证"这批数是按哪版算的"跟着数据走,
* ⛔ 不会出现"开关已经切了、格子还是旧那版、点下去按新那版圈人"这种错位。
*/
const dataMode = matrix?.anchorMode ?? mode;
const pick = useCallback((c: Picked) => handoff(c, dataMode), [dataMode]);
return ( return (
<div className="flex w-full flex-col overflow-hidden rounded-lg border bg-white"> <div className="flex w-full flex-col overflow-hidden rounded-lg border bg-white">
<div className="flex flex-none flex-wrap items-baseline gap-x-2 gap-y-1 border-b px-3.5 py-2.5"> <div className="flex flex-none flex-wrap items-center gap-x-2 gap-y-1 border-b px-3.5 py-2.5">
<span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span> <span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span>
<span className="text-[11px] text-slate-400"> <span className="text-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单 点一格 = 把这批人交给助手,助手出确认单
</span> </span>
{/* ⭐ 口径开关 —— 挨着列头放,主管点格子之前就知道这些数是按什么算的。
⚠️ 旁边那句 axisZh 不是装饰:列头写的是「三个月内」,而"什么的三个月"
只有这一处说得清;⛔ 别为了省空间把它收进 tooltip。 */}
<span className="ml-auto flex items-center gap-2">
<span className="hidden text-[11px] text-slate-400 sm:inline">
{ANCHOR_MODE_META[dataMode].axisZh}
</span>
<span className="inline-flex rounded-lg bg-slate-100 p-0.5">
{([AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT] as const).map((m) => (
<button
key={m}
type="button"
title={ANCHOR_MODE_META[m].hint}
onClick={() => pickMode(m)}
className={cn(
'rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
mode === m
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{ANCHOR_MODE_META[m].zh}
</button>
))}
</span>
</span>
</div> </div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */} {/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
...@@ -95,7 +162,13 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -95,7 +162,13 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
/* ⚠️ `w-full` 覆盖组件默认的 560px(那是当年给左栏 popover 的)—— /* ⚠️ `w-full` 覆盖组件默认的 560px(那是当年给左栏 popover 的)——
不覆盖的话这一栏有一千多像素宽而矩阵只占 560,右边一大片空白。 不覆盖的话这一栏有一千多像素宽而矩阵只占 560,右边一大片空白。
`min-w-[34rem]` 保证再窄也不把数字挤糊,由外层横向滚兜住。 */ `min-w-[34rem]` 保证再窄也不把数字挤糊,由外层横向滚兜住。 */
<PoolMatrix data={matrix} selected={null} onPick={pick} className="w-full min-w-[34rem]" /> <PoolMatrix
data={matrix}
selected={null}
onPick={pick}
anchorMode={dataMode}
className="w-full min-w-[34rem]"
/>
) : ( ) : (
<div className="space-y-2 p-3.5"> <div className="space-y-2 p-3.5">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
......
...@@ -347,6 +347,43 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -347,6 +347,43 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。 > (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。
> 天数常量的单一真理源:`HOT_BUCKET_DAYS` / `WARM_BUCKET_DAYS`(`packages/types/src/temperature.ts`)。 > 天数常量的单一真理源:`HOT_BUCKET_DAYS` / `WARM_BUCKET_DAYS`(`packages/types/src/temperature.ts`)。
#### T6″ · 锚点两版(2026-08-11)—— 距**哪个**时刻多久
「机会有多老」**不可观测**`onset_date` 在标准里但宿主 0 填充。手上只有两个观测时间,
各自诚实,主管在矩阵上现切(`AnchorMode`):
| mode | 锚点 | 回答的问题 |
|---|---|---|
| `diagnosis`(默认) | 医生最后一次写下该诊断 | 患者最后一次**被提醒**是多久前(话术) |
| `last_visit` | 患者末诊(`patient_profiles.last_visit_at`) | 这人多久**没露面**(接通率) |
> 🔴 `diagnosis` 有一个已量化的偏差:复诊时医生常不重复写同一诊断 ——
> **测试服朝阳全量实测**(45,190 个机会):诊断后患者又来过的 19,605 个里,
> **49.1%** 一次都没再写(期间平均又来 4.7 次),诊断日与末诊日**中位差 490 天**
> (矩阵上差 1–2 档);各医生重复记录率差约一倍。
> ⚠️ 所以 `diagnosis` 档位里含着「这个医生爱不爱重复写」的成分,⛔ 别读成临床新鲜度。
>
> ⚠️⚠️ **量这类比例必须按「日期」比,⛔ 不能按时间戳**:同一次就诊里 emr 与 diagnosis 的
> `occurred_at` 可能差几毫秒,用 `>` 会把"本次就诊"误算成"后来又来过一次"——
> 测试服上按时间戳算出来的"又来过"是 99.98%(真值 43%),而这个错**不会报错**。
**🔴 这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。它决定分到的是哪批人,所以三处必须同时到位,缺一处就是 T14 事故:
1. **看**`/plans/matrix?anchor=`)与**圈人**`cohort-filter``criteria.anchorMode`)必须同 mode;
2. 随确认单进 `plan_assignments.criteria` 快照;
3. 批次名标出来(`ANCHOR_MODE_META[*].batchTag`,只标非默认那版 → 老批次不用改名)。
助手侧的判据只有一条:主管那句话里**带「按末诊」三个字**才传 `last_visit`
`ANCHOR_MODE_TOOL_DESC`)——⛔ 改矩阵那句移交措辞就必须同步改工具说明。
> 🔴 **⛔ 别拿本地库量这两版的差异**(2026-08-11 踩过并当场被产品指出来):
> 本地只有 5,825 个患者、**最早末诊 2024-06**(全是近两年来过的人),
> 于是「按末诊·3 年以上」在本地恒为空 —— 看着像"诊断三年前没做的人其实一直在来",
> 其实是那批沉睡患者**根本不在本地库里**。
> 测试服全量(朝阳池子 7,168 格位)的真相相反:**「3 年以上」按末诊仍有 3,592 格**,
> 占池子一半以上;诊断 3 年+ 的格位里 **89% 末诊也是 3 年+**(真沉睡),只有 11% 是"人一直在来"。
> ⇒ 两版在真实数据上**比想象的接近**(粗档同档率 90%),⛔ 别照本地那份 71% 做判断。
#### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`) #### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`)
上面三行是**语义**,落地时有两处必须写死,否则各人实现各人的: 上面三行是**语义**,落地时有两处必须写死,否则各人实现各人的:
......
...@@ -599,6 +599,15 @@ export const AssignmentProposalSchema = z.object({ ...@@ -599,6 +599,15 @@ export const AssignmentProposalSchema = z.object({
* ⚠️ 老批次补不回来(数据当时就没存),只能从此往后有。 * ⚠️ 老批次补不回来(数据当时就没存),只能从此往后有。
*/ */
temperature: z.string().nullable(), temperature: z.string().nullable(),
/**
* ⭐ 档位口径(`diagnosis` 按诊断 / `last_visit` 按末诊)—— 与 temperature 同等重要。
*
* 🔴 同一个「三个月内」,两版圈出来是**两批不同的人**。它必须
* ① 随确认单下发 → 前端写进 `criteria` 快照 → 批次名标「按末诊」;
* ② 与主管当时在矩阵上切的那版**逐字一致**(T14)。
* ⚠️ 老确认单没有这个字段 → 消费方按 `diagnosis` 兜底(那就是当时的唯一口径)。
*/
anchorMode: z.string().optional(),
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
...@@ -706,6 +715,8 @@ export const RefillProposalRequestSchema = z.object({ ...@@ -706,6 +715,8 @@ export const RefillProposalRequestSchema = z.object({
clinicId: z.string().min(1), clinicId: z.string().min(1),
potentialTreatment: z.string().nullable().optional(), potentialTreatment: z.string().nullable().optional(),
temperature: z.string().nullable().optional(), temperature: z.string().nullable().optional(),
/// ⚠️ 口径也要原样带回 —— 重排的是**同一批人群**,换口径等于换了人群
anchorMode: z.string().nullable().optional(),
personaTags: z.string().nullable().optional(), personaTags: z.string().nullable().optional(),
/// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了 /// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了
targetCount: z.number().int().positive().optional(), targetCount: z.number().int().positive().optional(),
......
...@@ -100,6 +100,64 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [ ...@@ -100,6 +100,64 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
]; ];
/** /**
* 六档量的是「距**哪个**锚点多久」—— 两版并存,主管在矩阵上现切(2026-08-11 产品定)。
*
* ═══ 为什么要两版 ═══════════════════════════════════════════════════
* 「这个机会有多老」**不可观测**:`onset_date`(起病日)在 PAC 标准里有,宿主 0 填充;
* 龋洞在被发现之前已经长了多久,数据里没有、也不会有。手上只有两个观测时间,
* 各自诚实、各答一个问题:
* · `diagnosis` = 医生**最后一次写下**这条诊断 → 「患者最后一次被提醒」是多久前(话术)
* · `last_visit` = 患者**最后一次到诊** → 「这人多久没露面」(接通率 / 关系温度)
*
* 🔴 `diagnosis` 有一个**已量化**的系统偏差:复诊时医生常不重复写同一诊断
* (本地实测:诊断后又来过的机会里 **41.3%** 一次都没再写,期间平均又来了 6 次;
* 这些机会的诊断日与末诊日**中位相差 526 天** = 矩阵上差 1–2 档)。
* 而且各医生记录习惯差一倍(重复记录率 20.7% ~ 43.2%)——
* ⚠️ 所以 `diagnosis` 档位里含着"这个医生爱不爱重复写"的成分,⛔ 别把它读成临床新鲜度。
*
* ⛔ **不要改用「最早那条诊断」**去求所谓真实年龄:那会重新引入反单调
* (上周刚查出的龋齿,因为身上有颗两年前的旧龋,整个人被判成冷)—— 见文件头 ①。
*
* 🔴 **这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。
* 它决定**分到的是哪批人**,所以:
* ① 圈人侧(`cohort-filter`)必须跟着同一个 mode —— 否则矩阵 87 人、确认单另一批人,
* 而且全程不报错(T14 口径对数,矩阵 67→324 那次事故的同构版本);
* ② 必须随确认单进 `plan_assignments.criteria` 快照,并在批次名里标出来 ——
* 否则事后没人能回答「这批当时按什么圈的」。
*/
export const AnchorMode = {
/** 医生最后一次写下这条诊断(默认,历史行为) */
DIAGNOSIS: 'diagnosis',
/** 患者最后一次到诊(患者级,同一人所有行必然同档) */
LAST_VISIT: 'last_visit',
} as const;
export type AnchorModeValue = (typeof AnchorMode)[keyof typeof AnchorMode];
export const ANCHOR_MODE_META: Record<
AnchorModeValue,
{ zh: string; axisZh: string; hint: string; batchTag: string }
> = {
[AnchorMode.DIAGNOSIS]: {
zh: '按诊断',
axisZh: '医生最后一次提到',
hint: '档位 = 医生最后一次写下这条诊断距今多久。复诊没再写就不会刷新,读作「患者最后一次被提醒」',
/// 默认口径,批次名里**不标** —— 标了等于每个老批次都要改名
batchTag: '',
},
[AnchorMode.LAST_VISIT]: {
zh: '按末诊',
axisZh: '患者最后一次到诊',
hint: '档位 = 这个人最后一次到诊距今多久。患者级 —— 同一个人的所有治疗项必然落同一档',
batchTag: '按末诊',
},
};
/** 入参解析:非法值一律回落默认(⛔ 别抛错 —— 查询串是用户可改的) */
export function parseAnchorMode(v: unknown): AnchorModeValue {
return v === AnchorMode.LAST_VISIT ? AnchorMode.LAST_VISIT : AnchorMode.DIAGNOSIS;
}
/**
* 前两档的**天数上界**(距锚点)—— 2026-08-11 产品定,固定值,与治疗项无关。 * 前两档的**天数上界**(距锚点)—— 2026-08-11 产品定,固定值,与治疗项无关。
* *
* 🔴 这两个数是**唯一真理源**:TS 判档(`gapTemperatureBounds`)、SQL 判档 * 🔴 这两个数是**唯一真理源**:TS 判档(`gapTemperatureBounds`)、SQL 判档
...@@ -208,6 +266,22 @@ export const TEMPERATURE_TOOL_DESC: string = ...@@ -208,6 +266,22 @@ export const TEMPERATURE_TOOL_DESC: string =
'要说「往后放一档」而不是「放宽温度」。'; '要说「往后放一档」而不是「放宽温度」。';
/** /**
* 档位口径的**工具参数说明** —— 助手 / MCP 的 `anchorMode` 一律取这里。
*
* 🔴 主管点矩阵格子时,那句话里会带「(按末诊)」——**只有带了这四个字才传 last_visit**。
* ⛔ 别自己推断:两版圈出来是两批不同的人,猜错了主管拿到的名单就是错的,而且不报错。
*/
export const ANCHOR_MODE_TOOL_DESC: string =
'档位按哪个时间算。' +
`${AnchorMode.DIAGNOSIS}=按诊断(默认,医生最后一次提到这件事多久了)· ` +
`${AnchorMode.LAST_VISIT}=按末诊(这个人最后一次到诊多久了)。` +
'\n🔴 判据只有一条:主管那句话里**带「按末诊」三个字**就传 `last_visit`,' +
'否则**一律不传**(= 按诊断)。⛔ 不许自己推断、不许问主管"要按哪个口径" —— ' +
'他在矩阵上已经切过了,那句话就是他的选择。' +
'\n⚠️ 回话时不要说这两个取值码,也不要说「锚点/口径」——' +
'要提就说「按末诊那版」,不提最好。';
/**
* 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。 * 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。
* *
* ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序, * ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
...@@ -317,15 +391,21 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]> ...@@ -317,15 +391,21 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]>
* ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。 * ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。
* 旧口径下这里要拼各码窗口的并集(拔牙 = K01 ∪ K03),现在整段没有了。 * 旧口径下这里要拼各码窗口的并集(拔牙 = K01 ∪ K03),现在整段没有了。
*/ */
export function temperatureWindowHint(label: string, temp: TemperatureValue): string { export function temperatureWindowHint(
label: string,
temp: TemperatureValue,
/// ⚠️ 口径决定这句话的**主语**:同一个「≤90 天」,按诊断是"医生提过",按末诊是"人来过"
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): string {
void label; void label;
const from = mode === AnchorMode.LAST_VISIT ? '距上次到诊' : '医生最后一次提到';
const years = COLD_BUCKET_YEARS[temp]; const years = COLD_BUCKET_YEARS[temp];
if (years !== undefined) { if (years !== undefined) {
if (years === null) return '诊断距今 > 3 年'; if (years === null) return `${from} > 3 年`;
return years === 1 ? `诊断距今 ${WARM_BUCKET_DAYS} 天–1 年` : `诊断距今 ${years - 1}${years} 年`; return years === 1 ? `${from} ${WARM_BUCKET_DAYS} 天–1 年` : `${from} ${years - 1}${years} 年`;
} }
if (temp === Temperature.HOT) return `诊断距今${HOT_BUCKET_DAYS} 天`; if (temp === Temperature.HOT) return `${from}${HOT_BUCKET_DAYS} 天`;
return `诊断距今 ${HOT_BUCKET_DAYS}${WARM_BUCKET_DAYS} 天`; return `${from} ${HOT_BUCKET_DAYS}${WARM_BUCKET_DAYS} 天`;
} }
/** /**
......
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