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
import { randomUUID } from 'node:crypto';
import {
Permission,
AnchorMode,
ANCHOR_MODE_TOOL_DESC,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
......@@ -168,6 +171,11 @@ export class AssistantService {
enum: [...TEMPERATURE_TOOL_VALUES],
description: TEMPERATURE_TOOL_DESC,
},
anchorMode: {
type: 'string',
enum: [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT],
description: ANCHOR_MODE_TOOL_DESC,
},
personaTags: {
type: 'string',
description:
......@@ -211,6 +219,7 @@ export class AssistantService {
clinicId?: string;
potentialTreatment?: string;
temperature?: TemperatureValue;
anchorMode?: AnchorModeValue;
personaTags?: string;
expiresInDays?: number;
agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
......
......@@ -4,6 +4,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
Permission,
AnchorMode,
ANCHOR_MODE_TOOL_DESC,
parseAnchorMode,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
} from '@pac/types';
......@@ -357,11 +360,17 @@ export class McpServerFactory {
.enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]])
.optional()
.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),
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'),
},
},
async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) =>
async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) =>
jsonResult(
await this.cohorts.describe(
scope,
......@@ -369,6 +378,7 @@ export class McpServerFactory {
clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature } : {}),
anchorMode: parseAnchorMode(anchorMode),
...(personaTags ? { personaTags } : {}),
},
{ ...(keys ? { keys } : {}) },
......
......@@ -8,6 +8,8 @@ import {
type AgentInfo,
type AssignmentProposal,
type ProposedItem,
AnchorMode,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { calcAge } from '@pac/utils';
......@@ -82,6 +84,12 @@ export class AssignmentProposalService {
potentialTreatment?: string;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
temperature?: TemperatureValue;
/**
* 🔴 档位口径(按诊断 / 按末诊)—— **矩阵上切的是哪版,这里就必须是哪版**。
* 同一个「三个月内」两版圈出来的是两批不同的人;不带过来 = 主管看 87 人、
* 确认单给另一批,而且不报错(T14)。缺省 `diagnosis`(历史行为)。
*/
anchorMode?: AnchorModeValue;
/** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
personaTags?: string;
/** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */
......@@ -132,6 +140,8 @@ export class AssignmentProposalService {
clinicId,
...(potentialTreatment ? { potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}),
// 🔴 口径必须进 criteria —— cohortWhereSql 靠它决定按哪个锚点圈人
anchorMode: input.anchorMode ?? AnchorMode.DIAGNOSIS,
...(input.personaTags ? { personaTags: input.personaTags } : {}),
};
assertCohortCriteria(criteria);
......@@ -425,6 +435,9 @@ export class AssignmentProposalService {
// ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
// 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
temperature: input.temperature ?? null,
/// ⭐ 口径同理必须下发 —— 前端写进 criteria 快照,批次名也靠它标「按末诊」。
/// ⛔ 别省:同一个「三个月内」两版是两批人,事后分不出来就永远说不清了。
anchorMode: criteria.anchorMode ?? AnchorMode.DIAGNOSIS,
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal,
target,
......
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types';
import { Permission, parseAnchorMode } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -188,6 +188,8 @@ export class AssignmentController {
clinicId: body.clinicId,
...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
...(body.temperature ? { temperature: body.temperature as never } : {}),
// ⚠️ 口径原样带回 —— 少了它,「重新排一版」会静默换成另一批人
anchorMode: parseAnchorMode(body.anchorMode),
...(body.personaTags ? { personaTags: body.personaTags } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
......
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
AnchorMode,
ANCHOR_MODE_META,
PERSONA_TAG_FILTER_DIMS,
personaTagDimId,
visitRecencyRange,
VISIT_RECENCY_BUCKETS,
TEMPERATURE_ORDER,
type AnchorModeValue,
type PersonaTagFilterDim,
type TemperatureValue,
} from '@pac/types';
......@@ -80,14 +83,20 @@ export class CohortAttributesService {
* ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。
* ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 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 的时刻掺进来(两个时钟会让边界人群漂)
const rows = await this.prisma.$queryRaw<
Array<{ label: string; temp: string | null; n: bigint }>
>(
Prisma.sql`
WITH la AS (
${planLabelAnchorsSql(poolBaseSql(scope, clinicId))}
${planLabelAnchorsSql(poolBaseSql(scope, clinicId), anchorMode)}
)
SELECT label,
${temperatureBucketCaseSql(
......@@ -133,10 +142,15 @@ export class CohortAttributesService {
};
}),
unknownTotal,
/// ⭐ 口径随数据一起下发 —— 前端拿它渲染列头说明,助手拿它进 criteria。
/// ⛔ 别让前端"自己记得刚才切的是哪版":那等于第二份真理源,刷新一次就漂。
anchorMode,
anchorModeZh: ANCHOR_MODE_META[anchorMode].zh,
note:
unknownTotal > 0
? `另有 ${unknownTotal} 人**算不出温度**(召回证据里没有可用的诊断日),已单列,` +
`⛔ 没有并进任何冷档 —— 并进去数字好看但那是假分布。`
? `另有 ${unknownTotal} 人**算不出档位**(${
anchorMode === AnchorMode.LAST_VISIT ? '没有末诊记录' : '召回证据里没有可用的诊断日'
}),已单列,⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。`
: '',
};
}
......
import { Prisma } from '@prisma/client';
import {
AnchorMode,
PERSONA_TAG_FILTER_DIMS,
expandTemperatureFilter,
Temperature,
parsePersonaTags,
personaTagDimId,
visitRecencyRange,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -31,6 +33,13 @@ export interface CohortCriteria {
potentialTreatment?: string;
/** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
temperature?: TemperatureValue;
/**
* 🔴 档位口径(按诊断 / 按末诊)—— **必须跟着 temperature 一起传**。
* 同一个「三个月内」,两个 mode 圈出来的是**两批不同的人**;这里少传一个字段,
* 主管在矩阵上按末诊看了 87 人、确认单却按诊断捞了另一批,而且不报错(T14)。
* ⚠️ 缺省 = `diagnosis`(历史行为),老批次 criteria 里没这个键时也走这条。
*/
anchorMode?: AnchorModeValue;
/** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
personaTags?: string;
}
......@@ -88,10 +97,11 @@ function temperatureSql(
potentialTreatment: string,
temperature: TemperatureValue,
now: Date,
mode: AnchorModeValue,
): Prisma.Sql {
void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂)
// ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档
return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature));
return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature), mode);
}
/**
......@@ -166,7 +176,11 @@ export function cohortWhereSql(
return Prisma.sql`
${poolBaseSql(scope, c.clinicId)}
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${c.temperature && c.potentialTreatment ? temperatureSql(c.potentialTreatment, c.temperature, now) : Prisma.empty}
${
c.temperature && c.potentialTreatment
? temperatureSql(c.potentialTreatment, c.temperature, now, c.anchorMode ?? AnchorMode.DIAGNOSIS)
: Prisma.empty
}
${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty}
`;
}
......@@ -17,6 +17,8 @@ import {
type AgentWorkloadResponse,
RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES,
ANCHOR_MODE_META,
parseAnchorMode,
TEMPERATURE_META,
potentialTreatmentCardLabel,
type TemperatureValue,
......@@ -714,7 +716,11 @@ export class PlanAssignmentService {
private buildLabel(head: {
criteria: unknown; createdAt: Date; status: string; attributes: unknown;
}, 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)];
if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
if (c.temperature) {
......@@ -728,8 +734,17 @@ export class PlanAssignmentService {
* 写成"1 年内/1–2 年/2–3 年/3 年以上"反而是编了一个当时不存在的精度。
*/
const t = c.temperature;
/**
* 🔴 **口径要跟着档名一起说**(2026-08-11 两版并存之后)。
* 「种植 · 三个月内」按诊断和按末诊是**两批完全不同的人**;名字里不标,
* 主管过两周回看这批就分不出当时点的是哪一版 —— 而 criteria 存了、名字不说,
* 等于把证据锁在库里(批次名是他指认一批的唯一抓手)。
* ⚠️ 只标非默认那版:`diagnosis` 的 batchTag 是空串,老批次不受影响、不用改名。
*/
const tag = ANCHOR_MODE_META[parseAnchorMode(c.anchorMode)].batchTag;
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} 人`);
......
......@@ -9,7 +9,7 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types';
import { Permission, parseAnchorMode } from '@pac/types';
import {
CurrentUser,
AuthenticatedUser,
......@@ -105,18 +105,31 @@ export class PlanController {
@Get('matrix')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
summary: '初选矩阵(潜在治疗 × 窗口温度),按患者去重',
summary: '初选矩阵(潜在治疗 × 时间档),按患者去重',
description:
'口径是**去重患者数**,不是 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 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。
* 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**,
* 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')
......
import { Prisma } from '@prisma/client';
import {
AnchorMode,
COLD_BUCKET_YEARS,
COLD_TEMPERATURES,
HOT_BUCKET_DAYS,
POTENTIAL_LABEL_RULES,
Temperature,
WARM_BUCKET_DAYS,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
......@@ -82,9 +84,36 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql
* `make_interval(days => double precision)` **这个函数不存在** → 整个 /plans/matrix 报 500
* (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))`;
const ANCHOR_MAX_SQL = Prisma.sql`max(anc.at)`;
/**
* 锚点表达式 —— **两版口径**(见 @pac/types 的 `AnchorMode`)。
*
* · 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。
......@@ -128,12 +157,16 @@ export function temperatureBucketCaseSql(
* ⚠️ 三个聚合都用 max(取最热 / 取最新)—— 见 temperature.ts:统一成"最早"会让
* 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`
SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
${HOT_UNTIL_SQL} AS hot_until,
${WARM_UNTIL_SQL} AS warm_until,
${ANCHOR_MAX_SQL} AS anchor_at
${b.hot} AS hot_until,
${b.warm} AS warm_until,
${b.anchor} 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
......@@ -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'
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
${lastVisitJoin(mode)}
WHERE ${planFilter}
AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL
......@@ -174,7 +208,14 @@ export function labelExistsSql(label: string): Prisma.Sql {
export function labelTemperatureExistsSql(
label: string,
buckets: readonly TemperatureValue[],
/**
* 🔴 **必须与矩阵那次查询用同一个 mode**。这里是**圈人**侧,矩阵是**看**侧 ——
* 两边 mode 不一致 = 主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
* 调用方(`cohort-filter`)从 `criteria.anchorMode` 取,⛔ 别在这里给"聪明的默认"。
*/
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql {
const b = boundsSql(mode);
return Prisma.sql`
AND EXISTS (
SELECT 1
......@@ -182,10 +223,11 @@ export function labelTemperatureExistsSql(
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
${lastVisitJoin(mode)}
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
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}`),
', ',
)})
......
......@@ -168,7 +168,9 @@ describe('初选矩阵', () => {
expect(implant.counts.cold_1y).toBe(5); // ⛔ 不是 8
expect(implant.unknown).toBe(3);
expect(m.unknownTotal).toBe(3);
expect(m.note).toContain('算不出温度');
// ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了),
// 而且原因随口径变(按诊断=没诊断日 / 按末诊=没末诊记录)。
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';
/**
......@@ -129,3 +129,46 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () =
.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 {
AnchorMode,
ANCHOR_MODE_META,
HOT_BUCKET_DAYS,
parseAnchorMode,
Temperature,
TEMPERATURE_META,
TEMPERATURE_ORDER,
......@@ -183,10 +186,19 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', ()
describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('诊断距今 ≤ 90 天');
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 年');
// 默认口径(按诊断)—— 主语是"医生最后一次提到",⛔ 不是"诊断距今"
// (2026-08-11 两版并存后改的措辞:复诊漏写时,它表达的确实是"最后一次被提及")
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('医生最后一次提到 ≤ 90 天');
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)与单码标签逐字相同', () => {
......@@ -225,7 +237,7 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => {
// 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的,
// 空串反而是错的(格子会失去 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 工具的温度取值 —— 必须与矩阵同源', () => {
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({
// 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的,
// 也没法反推"哪种格子分下去效果好"(实测漏了很久)。
temperature: sheet.temperature,
// 🔴 口径与温度同等重要:同一个「三个月内」按诊断/按末诊是两批完全不同的人。
// 不存这一个键,批次名说不清、事后也没法反推"哪一版分下去效果好"。
anchorMode: sheet.anchorMode,
candidateTotal: sheet.candidateTotal,
target: sheet.target,
selectionNote: sheet.selectionNote,
......@@ -1290,6 +1293,8 @@ export function AssignmentConfirmSheet({
clinicId: sheet.clinicId,
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
// ⚠️ 口径也要带 —— 重排的是同一批人群,换口径就换了人群
anchorMode: sheet.anchorMode,
targetCount: sheet.batchSize,
expiresInDays,
});
......
'use client';
import type {
AnchorModeValue,
TemperatureValue,
ListPlansQuery,
ListPlansResponse,
......@@ -32,6 +33,9 @@ export interface PoolMatrix {
unknownTotal: number;
/** 有待重算的人时才非空;成品句子,直接展示 */
note: string;
/** ⭐ 这份数据是按哪个锚点算的 —— 由**服务端**回声,⛔ 前端别自己记(刷新就漂) */
anchorMode?: AnchorModeValue;
anchorModeZh?: string;
}
export const plansApi = {
......@@ -63,8 +67,8 @@ export const plansApi = {
* 初选矩阵(8 潜在治疗 × 3 窗口温度)。
* ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。
*/
matrix: (clinicId?: string) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId } }),
matrix: (clinicId?: string, anchor?: AnchorModeValue) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId, anchor } }),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
......
'use client';
import {
ANCHOR_MODE_META,
AnchorMode,
TEMPERATURE_META,
TEMPERATURE_ORDER,
potentialTreatmentItemName,
temperatureWindowHint,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { cn } from '@/lib/utils';
......@@ -92,6 +95,13 @@ export function PoolMatrix({
* ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。
*/
className,
/**
* 档位口径(按诊断 / 按末诊)—— 只影响**格子 hover 的那句话**与底部说明的措辞;
* 数字本身是服务端按同一个 mode 算好的。
* ⚠️ 默认 `diagnosis` 是为了让老调用点行为不变,⛔ 别把它当"聪明的默认"——
* 调用方拿到的数据是哪版,就必须传哪版(见 new-batch 的 dataMode)。
*/
anchorMode = AnchorMode.DIAGNOSIS,
}: {
/**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
......@@ -101,6 +111,7 @@ export function PoolMatrix({
* 数据到了再重排,浮层的抖动比列表刺眼得多。
*/
data: PoolMatrixData;
anchorMode?: AnchorModeValue;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
className?: string;
......@@ -169,7 +180,13 @@ export function PoolMatrix({
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}>
{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>
......@@ -178,7 +195,9 @@ export function PoolMatrix({
{data.rows.map((row) => (
<div
key={row.key}
title="算不出温度 —— 召回证据里没有可用的诊断日"
title={`算不出档位 —— ${
anchorMode === AnchorMode.LAST_VISIT ? '这些人没有末诊记录' : '召回证据里没有可用的诊断日'
}`}
className={cn(
ROW_H,
'flex items-center justify-center text-[11px] text-muted-foreground nums',
......@@ -198,7 +217,12 @@ export function PoolMatrix({
否则主管把各行相加会发现比池子总数大,当场以为系统在骗他(实测 2,526 vs 2,129)。
*/}
<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>
{data.note && (
......@@ -215,8 +239,10 @@ function MatrixRow({
row,
selected,
onPick,
anchorMode,
}: {
row: PoolMatrixRow;
anchorMode: AnchorModeValue;
selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: {
treatment: string;
......@@ -253,7 +279,7 @@ function MatrixRow({
// ⭐ hover 文案保持原样:只给**天数区间**,不给解释。
// 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声;
// 区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c)}
title={temperatureWindowHint(row.key, c, anchorMode)}
className={cn(
ROW_H,
'flex-1 text-[13px] text-slate-900 nums',
......
'use client';
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 { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { cn } from '@/lib/utils';
/**
* 「分一批新的」—— **常驻面板**,摆在「我分的批次」正上方。
......@@ -34,10 +41,20 @@ type Picked = {
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 tag = ANCHOR_MODE_META[mode].batchTag;
const cell = `${c.treatmentZh} · ${tempZh}${tag ? `(${tag})` : ''}`;
useAssistantStore.getState().handoff({
text: `帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`,
text: `帮我给「${cell}」这批患者出一份分配方案`,
from: c.rect,
count: c.count,
treatment: c.treatmentZh,
......@@ -50,13 +67,31 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [error, setError] = useState<string | null>(null);
/**
* 换诊所先清空 —— ⛔ 不能让上一家的人数在新诊所的标题下面多留一帧。
* ⚠️ 只挂 `clinicId`:下面那次「分完重拉」不走这里,否则每分一批矩阵都要闪一下骨架。
* 🔴 档位口径 —— **这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。
* 它决定分到的是哪批人:同一个「三个月内」,按诊断和按末诊是两批完全不同的人。
*
* ⚠️ 记进 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(() => {
setMatrix(null);
setError(null);
}, [clinicId]);
}, [clinicId, mode]);
/**
* 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`):
......@@ -67,23 +102,55 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
if (!clinicId) return;
let alive = true;
plansApi
.matrix(clinicId)
.matrix(clinicId, mode)
.then((r) => alive && setMatrix(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败'));
return () => {
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 (
<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-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单
</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 className="overflow-x-auto">
......@@ -95,7 +162,13 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
/* ⚠️ `w-full` 覆盖组件默认的 560px(那是当年给左栏 popover 的)——
不覆盖的话这一栏有一千多像素宽而矩阵只占 560,右边一大片空白。
`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">
{Array.from({ length: 6 }).map((_, i) => (
......
......@@ -347,6 +347,43 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。
> 天数常量的单一真理源:`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`)
上面三行是**语义**,落地时有两处必须写死,否则各人实现各人的:
......
......@@ -599,6 +599,15 @@ export const AssignmentProposalSchema = z.object({
* ⚠️ 老批次补不回来(数据当时就没存),只能从此往后有。
*/
temperature: z.string().nullable(),
/**
* ⭐ 档位口径(`diagnosis` 按诊断 / `last_visit` 按末诊)—— 与 temperature 同等重要。
*
* 🔴 同一个「三个月内」,两版圈出来是**两批不同的人**。它必须
* ① 随确认单下发 → 前端写进 `criteria` 快照 → 批次名标「按末诊」;
* ② 与主管当时在矩阵上切的那版**逐字一致**(T14)。
* ⚠️ 老确认单没有这个字段 → 消费方按 `diagnosis` 兜底(那就是当时的唯一口径)。
*/
anchorMode: z.string().optional(),
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
......@@ -706,6 +715,8 @@ export const RefillProposalRequestSchema = z.object({
clinicId: z.string().min(1),
potentialTreatment: z.string().nullable().optional(),
temperature: z.string().nullable().optional(),
/// ⚠️ 口径也要原样带回 —— 重排的是**同一批人群**,换口径等于换了人群
anchorMode: z.string().nullable().optional(),
personaTags: z.string().nullable().optional(),
/// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了
targetCount: z.number().int().positive().optional(),
......
......@@ -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 产品定,固定值,与治疗项无关。
*
* 🔴 这两个数是**唯一真理源**:TS 判档(`gapTemperatureBounds`)、SQL 判档
......@@ -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)。
*
* ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
......@@ -317,15 +391,21 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]>
* ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。
* 旧口径下这里要拼各码窗口的并集(拔牙 = 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;
const from = mode === AnchorMode.LAST_VISIT ? '距上次到诊' : '医生最后一次提到';
const years = COLD_BUCKET_YEARS[temp];
if (years !== undefined) {
if (years === null) return '诊断距今 > 3 年';
return years === 1 ? `诊断距今 ${WARM_BUCKET_DAYS} 天–1 年` : `诊断距今 ${years - 1}${years} 年`;
if (years === null) return `${from} > 3 年`;
return years === 1 ? `${from} ${WARM_BUCKET_DAYS} 天–1 年` : `${from} ${years - 1}${years} 年`;
}
if (temp === Temperature.HOT) return `诊断距今${HOT_BUCKET_DAYS} 天`;
return `诊断距今 ${HOT_BUCKET_DAYS}${WARM_BUCKET_DAYS} 天`;
if (temp === Temperature.HOT) return `${from}${HOT_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