Commit c1b468be by luoqi

merge: fix/k00-focus-category-and-ortho-mapping → main(K00 诊断词细分 + 低龄种植降位 +…

merge: fix/k00-focus-category-and-ortho-mapping → main(K00 诊断词细分 + 低龄种植降位 + 二期正畸误吞 + reparse工具 + 历史联系摘要不判到诊)

# Conflicts:
#	apps/pac-service/prisma/schema.prisma
parents 2111418b cdad7db0
...@@ -2,13 +2,21 @@ ...@@ -2,13 +2,21 @@
# 共享字典 · 治疗 category 关键词兜底 —— 【planned(未来计划)语义变体】 # 共享字典 · 治疗 category 关键词兜底 —— 【planned(未来计划)语义变体】
# 与 actual 变体是有意语义分歧:planned 文本本就以"建议/拟/推荐"措辞 → 不剥这些词, # 与 actual 变体是有意语义分歧:planned 文本本就以"建议/拟/推荐"措辞 → 不剥这些词,
# 只剥纯条件从句(必要时/如需/择期)。 # 只剥纯条件从句(必要时/如需/择期)。
# ⚠️ TODO(继承自 jvs-dw 陈旧拷贝,行为保真原样搬入):implant 规则里裸「二期」会误吞 # 规则顺序=优先级(首个命中即用);宿主本地规则(若有)自动排在本文件规则之前。
# "二期隐形矫正"(actual 变体已改「种植二期」)。修复属行为变更,须评估 + reparse,另行提交。
# 源出处:jvs-dw treatment_planned.yaml。 # 源出处:jvs-dw treatment_planned.yaml。
#
# ⭐ 2026-07 修「裸『二期』误吞正畸」(线上反馈 陈芃霖 BJ0A094257:2025-12-20 计划
# 「正畸二期矫治」被归成 implant/种植)。
# actual 变体的修法是把词收紧成「种植二期」,但 planned 侧不能照抄 —— planned 的
# 种植二期说法**普遍不带"种植"**(二期取模 346 / 二期手术 56 / 二期修复 36 / 裸"二期" 24…,
# prod 实测),收紧会让这 900+ 条掉进 _default 被丢弃,是更大的回归。
# 故改用**位次**解决:裸「二期」从 implant 主规则拆出、降到全表最末兜底 →
# 带正畸词的(二期矫治 / 二期矫正 / 正畸二期…)先被 orthodontic 收走,
# 其余纯"二期"仍落 implant,行为与修复前逐条一致。见文件尾那条规则。
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
keyword_mapping: keyword_mapping:
category: category:
- { value: implant, any: [种植, 即拔即种, 植体, 二期] } - { value: implant, any: [种植, 即拔即种, 植体] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] } - { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] } - { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] } - { value: cosmetic, any: [贴面, 漂白, 美白] }
...@@ -18,6 +26,12 @@ keyword_mapping: ...@@ -18,6 +26,12 @@ keyword_mapping:
- { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] } - { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] } - { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] } - { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# ⭐ 裸「二期」兜底 —— **必须留在最末**(尤其在 orthodontic 之后):
# 种植的二期在 planned 里常省掉"种植"(二期取模 / 二期手术 / 二期修复 / 三个月后二期),
# 前面 9 条都收不住,靠这条归 implant;而"二期矫治/二期矫正/正畸二期"含正畸词,
# 在上面第 3 条就被 orthodontic 拿走,走不到这里。**别把它挪回文件头**,
# 挪回去就退回 2026-07 之前的误吞(prod 420 条正畸计划被标成种植)。
- { value: implant, any: [二期] }
# planned 语义:计划本就以"建议/拟/推荐"措辞 → 不剥,只剥纯条件词(必要时/如需/择期)。 # planned 语义:计划本就以"建议/拟/推荐"措辞 → 不剥,只剥纯条件词(必要时/如需/择期)。
# 例 "建议全口洁治后,必要时正畸治疗" → 丢"必要时正畸治疗" → "建议全口洁治后" → periodontic。 # 例 "建议全口洁治后,必要时正畸治疗" → 丢"必要时正畸治疗" → "建议全口洁治后" → periodontic。
......
-- CreateIndex
--
-- 【为什么要这条索引】
-- reparse 分批重建源表时,每批取这 3000 个患者的源行(cold-import.service.ts):
-- SELECT id, raw_payload, payload_hash FROM patient_transactions
-- WHERE host_id = $1 AND subject_type IN ('emr','diagnosis') AND patient_id IN (3000 个)
-- 没有 (host_id, subject_type, patient_id) 时 planner 只能走 (patient_id, occurred_at),
-- 把这些患者的**全部** transaction 连 raw_payload 一起捞进堆,再在堆上过滤 host/subject_type。
--
-- 2026-07-27 测试服 EXPLAIN ANALYZE 实测(patient_transactions 12,519,060 行 / 33 GB):
-- Bitmap Heap Scan actual time=997..10382ms → 10,385 ms
-- rows=14,578,Rows Removed by Filter: 39,305 ← 读进来 73% 是白读
-- Buffers: shared hit=8,723 read=15,616(约 122MB 磁盘读)
-- 全量 reparse 87 批,单这一句约占 15 分钟。
--
-- ⚠️ 收益如实说:每批总耗时 76–180s,这句占 6–14%,补索引对全程约 4%。
-- reparse 真正的提速手段是 CLI 的 --patients-file(按受影响患者收窄,实测最高 250 倍)。
-- 本索引胜在一次性代价、之后每次 reparse / 每个 host 都受益。
--
-- 【体积】(uuid, text, uuid) × 千万行,测试服估 ~600MB、生产(24,074,031 行 / 64GB)估 ~1.2GB。
-- 测试服建前 24G 空闲,够;生产上线前请先确认磁盘余量。
--
-- 【CONCURRENTLY 与上线方式】同 20260727070000(sync_log_id 那条):
-- 普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表数分钟,摄入与 push 全挂,故用 CONCURRENTLY。
-- 当前 Prisma 6.19 可以直接 `migrate deploy` 跑通(20260701060607 里"有事务包裹跑不了"的
-- 说法已不成立,2026-07-27 实测)。但**推荐部署前手动先建**:deploy 时 pac-service 要等
-- pac-migrate 退出才启动,几分钟索引构建 = 白等的停机。手动建好后本条 IF NOT EXISTS 空跑:
-- docker exec pac-postgres-1 psql -U pac -d pac -c \
-- 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_host_id_subject_type_patient_id_idx" \
-- ON "patient_transactions"("host_id","subject_type","patient_id");'
CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_host_id_subject_type_patient_id_idx" ON "patient_transactions"("host_id", "subject_type", "patient_id");
...@@ -526,6 +526,19 @@ model PatientTransaction { ...@@ -526,6 +526,19 @@ model PatientTransaction {
/// 对方 HTTP 客户端直接 `SocketTimeoutException: Read timed out` /// 对方 HTTP 客户端直接 `SocketTimeoutException: Read timed out`
/// 这条随表增长只会更糟 —— 摄入量越大、push 越慢,属结构性问题不是调优。 /// 这条随表增长只会更糟 —— 摄入量越大、push 越慢,属结构性问题不是调优。
@@index([syncLogId]) @@index([syncLogId])
/// reparse 按批取源行:`WHERE host_id = ? AND subject_type IN (...) AND patient_id IN (3000)`
/// (cold-import.service.ts 分批重建源表那段)
///
/// 没这条时 planner (patient_id, occurred_at):把这 3000 个患者的**全部** transaction
/// raw_payload 一起捞进堆,再在堆上按 host_id / subject_type 过滤。
/// 2026-07-27 测试服 EXPLAIN ANALYZE 实测(12,519,060 / 33GB):
/// Bitmap Heap Scan 10,385ms; 53,883 行只要 14,578 —— Rows Removed by Filter: 39,305(73% 白读)
/// Buffers: read=15,616( 122MB 磁盘读);全量 87 这一句就占约 15 分钟
///
/// ⚠️ 收益要说实话:每批 76180s 里它只占 614%,对全程约 4%,**不是提速主手段** ——
/// 真正的杠杆是 CLI `--patients-file` 按受影响患者收窄(实测最高 250 )
/// 这条胜在每次 reparse / 每个 host 都受益,代价只有一次建索引。
@@index([hostId, subjectType, patientId])
/// 幂等键 partial UNIQUE (host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL /// 幂等键 partial UNIQUE (host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL
/// Prisma 不支持声明式 partial unique;migration 20260528000002 是单一真理源。 /// Prisma 不支持声明式 partial unique;migration 20260528000002 是单一真理源。
/// (init migration 漏建,20260528000002 dedup + 建索引) /// (init migration 漏建,20260528000002 dedup + 建索引)
......
/**
* `--patients-file` 的解析helper —— 独立成模块,**不要塞回 reparse.cli.ts**。
*
* 原因:CLI 文件末尾是 `void main()`(全仓 CLI 通例),任何 import 都会当场启动 Nest
* 并真跑一次 reparse。2026-07-27 我把测试直接 import CLI,结果测试跑起来对本地库
* 执行了一次真实 reparse(115 行 Nest 日志)—— 纯函数放这里,测试只碰这里。
* (CLI 侧另加了 require.main 闸做第二道防线。)
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 解析 --patients-file 的内容。
*
* 宽容到什么程度是刻意的:操作者多半是 `psql -tAc ... > ids.txt` 直接倒出来的,
* 会带前后空格、空行、行尾逗号;混着 uuid 和病历号也很常见(手上有什么用什么)。
* 但**不做猜测性纠错** —— 认不出的原样返回,由调用方去库里查、查不到就报出来,不静默丢。
*/
export function parsePatientsFile(raw: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const line of raw.split(/\r?\n/)) {
// 允许行尾注释:`<id> # 张三 缺牙` —— 圈人清单常顺手标注是谁
const token = line.split('#')[0]!.trim().replace(/[,;]+$/, '');
if (!token) continue;
if (seen.has(token)) continue;
seen.add(token);
out.push(token);
}
return out;
}
/** 按形态拆成 uuid(patient.id)与其余(当 externalId 去库里查) */
export function splitPatientTokens(tokens: string[]): { ids: string[]; externalIds: string[] } {
const ids: string[] = [];
const externalIds: string[] = [];
for (const t of tokens) (UUID_RE.test(t) ? ids : externalIds).push(t);
return { ids, externalIds };
}
...@@ -7,13 +7,32 @@ ...@@ -7,13 +7,32 @@
* Usage: * Usage:
* pnpm reparse -- --host=jvs-dw --subject-type=diagnosis # 实跑 + 定向重算 * pnpm reparse -- --host=jvs-dw --subject-type=diagnosis # 实跑 + 定向重算
* pnpm reparse -- --host=jvs-dw --subject-type=diagnosis --dry-run # 只报会改多少,不写 * pnpm reparse -- --host=jvs-dw --subject-type=diagnosis --dry-run # 只报会改多少,不写
* pnpm reparse -- --host=jvs-dw --patient=<uuid>,<uuid> # 限定患者 * pnpm reparse -- --host=jvs-dw --patient=<uuid>,<uuid> # 限定患者(少量,命令行)
* pnpm reparse -- --host=jvs-dw --patients-file=./ids.txt # 限定患者(大批,见下)
* pnpm reparse -- --host=jvs-dw --subject-type=diagnosis --no-recompute # 只重衍 fact,稍后单独 recompute * pnpm reparse -- --host=jvs-dw --subject-type=diagnosis --no-recompute # 只重衍 fact,稍后单独 recompute
* *
* ⭐ --patients-file:**按受影响患者收窄**,reparse 最有效的提速手段。
* 全量 reparse 的时间几乎全花在"证明没变"上 —— 2026-07-27 测试服实测:
* 260,434 患者 / 87 批 / ~4.5 小时,而其中真正会变的:
* · 改 clinical-signals 字典 → 约 4 万患者(15%) → 6.5 倍
* · 改 planned「二期」归类规则 → 1,017 患者(0.4%) → 250 倍(2 分钟跑完)
* 用法:先用 SQL 圈出受影响患者,dump 成一行一个 id 的文件,再喂给本参数。例:
* psql -tAc "SELECT DISTINCT patient_id FROM patient_facts
* WHERE type='treatment_record' AND kind='planned'
* AND content->>'subtype' LIKE '%二期%'" > /tmp/ids.txt
* pnpm reparse -- --host=jvs-dw --subject-type=treatment --patients-file=/tmp/ids.txt
* 文件支持 patient.id(uuid)与 externalId 混写,# 开头为注释,空行忽略。
* ⚠️ 解析结果为空时**直接报错退出**,绝不退化成全量 —— 服务侧 `patientIds` 传空数组
* 等同"不限定"(cold-import.service.ts `if (!scopePatientIds?.length)`),
* 一个写错路径的收窄命令若静默变成 4.5 小时全量,是最难发现的那种事故。
*
* 通过 NestApplicationContext 启动(不起 HTTP),复用 DI。 * 通过 NestApplicationContext 启动(不起 HTTP),复用 DI。
*/ */
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { existsSync, readFileSync } from 'node:fs';
// 纯函数拆在独立模块 —— 本文件末尾 `void main()`,被 import 就会真跑 reparse,测试不能碰
import { parsePatientsFile, splitPatientTokens } from './reparse-patients-file';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service'; import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
...@@ -25,6 +44,7 @@ interface CliArgs { ...@@ -25,6 +44,7 @@ interface CliArgs {
host: string; host: string;
subjectTypes?: string[]; subjectTypes?: string[];
patientIds?: string[]; patientIds?: string[];
patientsFile?: string;
dryRun: boolean; dryRun: boolean;
recompute: boolean; recompute: boolean;
help: boolean; help: boolean;
...@@ -40,6 +60,7 @@ function parseArgs(argv: string[]): CliArgs { ...@@ -40,6 +60,7 @@ function parseArgs(argv: string[]): CliArgs {
else if (a.startsWith('--host=')) args.host = a.slice('--host='.length); else if (a.startsWith('--host=')) args.host = a.slice('--host='.length);
else if (a.startsWith('--subject-type=')) args.subjectTypes = a.slice('--subject-type='.length).split(',').map((s) => s.trim()).filter(Boolean); else if (a.startsWith('--subject-type=')) args.subjectTypes = a.slice('--subject-type='.length).split(',').map((s) => s.trim()).filter(Boolean);
else if (a.startsWith('--patient=')) args.patientIds = a.slice('--patient='.length).split(',').map((s) => s.trim()).filter(Boolean); else if (a.startsWith('--patient=')) args.patientIds = a.slice('--patient='.length).split(',').map((s) => s.trim()).filter(Boolean);
else if (a.startsWith('--patients-file=')) args.patientsFile = a.slice('--patients-file='.length).trim();
} }
return args; return args;
} }
...@@ -51,7 +72,11 @@ async function main(): Promise<void> { ...@@ -51,7 +72,11 @@ async function main(): Promise<void> {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log( console.log(
'pnpm reparse -- --host=<name> [--dir=./data/<host>] [--subject-type=diagnosis,treatment]\n' + 'pnpm reparse -- --host=<name> [--dir=./data/<host>] [--subject-type=diagnosis,treatment]\n' +
' [--patient=<uuid>,...] [--dry-run] [--no-recompute]', ' [--patient=<uuid>,...] [--patients-file=<path>] [--dry-run] [--no-recompute]\n' +
'\n' +
' --patients-file 一行一个 patient.id(uuid)或 externalId,# 注释、空行忽略。\n' +
' 按受影响患者收窄是 reparse 最有效的提速手段(实测可达 250 倍)。\n' +
' 解析为空会直接报错,不会退化成全量。',
); );
return; return;
} }
...@@ -63,17 +88,64 @@ async function main(): Promise<void> { ...@@ -63,17 +88,64 @@ async function main(): Promise<void> {
const persona = app.get(PersonaService); const persona = app.get(PersonaService);
const planEngine = app.get(PlanEngineService); const planEngine = app.get(PlanEngineService);
// ── --patients-file:解析 → 认领 externalId → 与 --patient 合并 ──
let patientIds = args.patientIds;
if (args.patientsFile) {
if (!existsSync(args.patientsFile)) {
throw new Error(`--patients-file 不存在:${args.patientsFile}`);
}
const tokens = parsePatientsFile(readFileSync(args.patientsFile, 'utf8'));
if (tokens.length === 0) {
// ⚠️ 不能放过 —— 空 patientIds 到服务侧等同"不限定" = 全量 reparse(4.5 小时)
throw new Error(`--patients-file 解析后为 0 个患者:${args.patientsFile}(拒绝退化成全量)`);
}
const { ids, externalIds } = splitPatientTokens(tokens);
// externalId 只在 host 内唯一 —— 必须带 hostId 查,否则跨宿主撞号
let resolved: string[] = [];
if (externalIds.length > 0) {
const host = await prisma.host.findUnique({ where: { name: args.host }, select: { id: true } });
if (!host) throw new Error(`host '${args.host}' 不存在`);
const rows = await prisma.patient.findMany({
where: { hostId: host.id, externalId: { in: externalIds } },
select: { id: true, externalId: true },
});
resolved = rows.map((r) => r.id);
const found = new Set(rows.map((r) => r.externalId));
const missing = externalIds.filter((e) => !found.has(e));
if (missing.length > 0) {
// 报出来但不中断:圈人清单里混进几个别的 host / 已删患者是常态,
// 静默丢会让"为什么少跑了几百人"变成无头案。
logger.warn(
`--patients-file 有 ${missing.length} 个 externalId 在 host=${args.host} 查不到,已跳过` +
`(前 5 个:${missing.slice(0, 5).join(', ')})`,
);
}
}
const merged = [...new Set([...(args.patientIds ?? []), ...ids, ...resolved])];
if (merged.length === 0) {
throw new Error(`--patients-file 里没有一个能对上库中的患者:${args.patientsFile}(拒绝退化成全量)`);
}
patientIds = merged;
logger.log(
`--patients-file ${args.patientsFile}:读到 ${tokens.length} 个 token ` +
`(uuid ${ids.length} / externalId ${externalIds.length} → 认领 ${resolved.length});` +
`最终限定 ${merged.length} 个患者`,
);
}
logger.log( logger.log(
`reparse start: host=${args.host} dir=${args.dir} ` + `reparse start: host=${args.host} dir=${args.dir} ` +
`subjectTypes=${args.subjectTypes?.join(',') ?? 'ALL'} ` + `subjectTypes=${args.subjectTypes?.join(',') ?? 'ALL'} ` +
`patients=${args.patientIds?.length ?? 'all'} dryRun=${args.dryRun} recompute=${args.recompute}`, `patients=${patientIds?.length ?? 'all'} dryRun=${args.dryRun} recompute=${args.recompute}`,
); );
const result = await coldImport.reparseFromTransactions({ const result = await coldImport.reparseFromTransactions({
dir: args.dir, dir: args.dir,
hostName: args.host, hostName: args.host,
subjectTypes: args.subjectTypes, subjectTypes: args.subjectTypes,
patientIds: args.patientIds, patientIds,
dryRun: args.dryRun, dryRun: args.dryRun,
}); });
...@@ -134,4 +206,6 @@ async function main(): Promise<void> { ...@@ -134,4 +206,6 @@ async function main(): Promise<void> {
} }
} }
void main(); // ⚠️ 只在被直接执行时跑 —— 没这道闸,任何 import 本文件的代码(测试/工具脚本)
// 都会当场启动 Nest 并对连着的库跑一次真实 reparse。2026-07-27 实际踩过。
if (require.main === module) void main();
...@@ -15,7 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts'; ...@@ -15,7 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts';
import { resolveDiseaseLabel } from './disease-knowledge'; import { resolveDiseaseLabel } from './disease-knowledge';
import { deidentifyDoctor } from './pii'; import { deidentifyDoctor } from './pii';
import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity'; import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity';
import { DENTURE_FIRST_AGE } from '@pac/types'; import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
export function buildRichFactBlock(input: DraftPlanScriptInput): string { export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
...@@ -73,6 +73,11 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -73,6 +73,11 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
// 70-80 能否种植看身体条件与患者意愿,由医生评估)。PAC 只调整介绍先后,不替医生下结论。 // 70-80 能否种植看身体条件与患者意愿,由医生评估)。PAC 只调整介绍先后,不替医生下结论。
// 口径与召回建议治疗同源(DENTURE_FIRST_AGE)。 // 口径与召回建议治疗同源(DENTURE_FIRST_AGE)。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE; const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
// 低龄种植闸(口径同召回建议治疗 IMPLANT_LAST_AGE):颌骨没长完种植体会相对埋没,
// 属临床相对禁忌 —— 话术不主推种植,落到"来院评估"。与上面高龄块互斥。
const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE;
// 早矫措辞(口径同 treatmentCategoryNameZhFor EARLY_ORTHO_MAX_AGE):替牙期讲"早期矫治"。
const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可) return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可)
...@@ -97,7 +102,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自 ...@@ -97,7 +102,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者 ## 患者
- ${basics} - ${basics}
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}`; - 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(种植体不随颌骨生长,未成年是相对禁忌)。\n 涉及缺牙先讲**间隙管理 / 正畸方向**,修复方式统一落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」(干预颌骨发育与间隙管理),\n 不要说成给恒牙列排齐的"正畸/戴牙套";具体做不做、做哪种,落到"来院让医生评估"。` : ''}`;
} }
/** /**
......
...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> { ...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> { export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write'; readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v17'; // v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话 readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v18'; // v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema; readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
...@@ -4,7 +4,7 @@ import { resolveDisease } from './phrasing'; ...@@ -4,7 +4,7 @@ import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii'; import { deidentifyDoctor } from '../../shared/pii';
import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block'; import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity'; import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
import { DENTURE_FIRST_AGE } from '@pac/types'; import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
/** /**
* Prompt 版本管理约定: * Prompt 版本管理约定:
...@@ -108,6 +108,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -108,6 +108,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const noXray = patient.age == null || patient.age <= 18; const noXray = patient.age == null || patient.age <= 18;
// 高龄介绍顺序(同 fact-block,口径共用 DENTURE_FIRST_AGE):义齿在前、种植并行,不下禁忌结论。 // 高龄介绍顺序(同 fact-block,口径共用 DENTURE_FIRST_AGE):义齿在前、种植并行,不下禁忌结论。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE; const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
// 低龄种植闸 / 早矫措辞(同 fact-block,口径共用 IMPLANT_LAST_AGE / EARLY_ORTHO_MAX_AGE)
const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE;
const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE;
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。 // 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts) // (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
...@@ -145,5 +148,5 @@ ${advLines} ...@@ -145,5 +148,5 @@ ${advLines}
- ${basics} - ${basics}
## 语气 ## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${persona ? `\n\n${persona}` : ''}`; - ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(未成年相对禁忌)。缺牙先讲间隙管理 / 正畸方向,\n 修复方式落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」,不要说成给恒牙列排齐的"正畸/戴牙套"。` : ''}${persona ? `\n\n${persona}` : ''}`;
} }
...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc ...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-08-4module-v29'; // v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 'draft_plan_script@2026-06-08-4module-v30'; // v30: 低龄约束(<18 不主推种植,口径同召回 IMPLANT_LAST_AGE)+ 替牙期(≤12)矫治措辞统一说「早期矫治」(同 EARLY_ORTHO_MAX_AGE)。v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types'; ...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
* *
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。 * callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/ */
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v16'; // v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化 const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v17'; // v17: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable() @Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> { export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
...@@ -37,6 +37,16 @@ export interface DraftRecallSummaryInput { ...@@ -37,6 +37,16 @@ export interface DraftRecallSummaryInput {
* 摘要必须先回答这一条,再讲整体趋势。 * 摘要必须先回答这一条,再讲整体趋势。
*/ */
isLatestPast: boolean; isLatestPast: boolean;
/**
* ⭐ 距今天数(taskDate 到 today;未来排程为负数,无日期为 null)。
* 同 isFuture / isLatestPast 一样**程序算好**,不让 LLM 自己减日期。
*
* 生产事故背景(2026-07-27):模型自己算相对时间,把 2025-12-10 的回访写成
* 「近3个月」(实为 7.5 个月前)、把末诊 7 个月写成「已超一年未复诊」。
* 「能程序算的事实全算好、LLM 只润色」—— 与 isFuture 同一条纪律,只是那次补的是
* "未来 vs 过去"这个轴,漏了"距今多久"这个轴,于是同类错误换个轴又来一遍。
*/
daysAgo: number | null;
treatmentItems: string | null; // 关联治疗项(如 种植) treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…) followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果 result: string | null; // 回访结果
......
...@@ -8,8 +8,11 @@ import type { DraftRecallSummaryInput } from './input.types'; ...@@ -8,8 +8,11 @@ import type { DraftRecallSummaryInput } from './input.types';
* 2026-07-26-f ⭐ 摘要先说「最近一次」(程序标注),再讲整体;并明写"源系统只记事由/ * 2026-07-26-f ⭐ 摘要先说「最近一次」(程序标注),再讲整体;并明写"源系统只记事由/
* 结果标签、没有逐字沟通内容",没记就说没记,禁止补写沟通细节。 * 结果标签、没有逐字沟通内容",没记就说没记,禁止补写沟通细节。
* 来源:业务可用性测试「暂时没有办法详细的看到具体一次沟通什么内容」。 * 来源:业务可用性测试「暂时没有办法详细的看到具体一次沟通什么内容」。
* 2026-07-27-g ⭐ 加【职责边界】:本卡**只讲回访本身**,输入里没有任何到诊数据,
* 禁止对"是否到诊 / 失联 / 沉睡"下结论;并补程序算好的 daysAgo。
* 修生产事故,见下方职责边界段。
*/ */
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-26-f'; export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-27-g';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。 export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
...@@ -17,7 +20,7 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -17,7 +20,7 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
1. 输出中文,不要换行/列表/Markdown,总长 ≤70 字。 1. 输出中文,不要换行/列表/Markdown,总长 ≤70 字。
2. **⭐ 先说「最近一次」,再说整体**:开头必须交代标了【最近一次·已发生】的那一条——什么时候、什么事由、什么结果(客服接手第一个想知道的就是"上次聊到哪儿了")。其余记录压成半句趋势即可。没有任何【最近一次·已发生】条目时(全是未来排程/无日期),直接讲整体,不要硬凑。 2. **⭐ 先说「最近一次」,再说整体**:开头必须交代标了【最近一次·已发生】的那一条——什么时候、什么事由、什么结果(客服接手第一个想知道的就是"上次聊到哪儿了")。其余记录压成半句趋势即可。没有任何【最近一次·已发生】条目时(全是未来排程/无日期),直接讲整体,不要硬凑。
3. **是"重点提炼"不是"总结罗列"**:整体那半句抓最值得注意的一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进"。**不要**把所有回访类型都列出来。 3. **是"重点提炼"不是"总结罗列"**:整体那半句抓最值得注意的一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进"。**不要**把所有回访类型都列出来。
4. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。 4. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 多次未留结果 / 刚成交别打扰 等)。
5. 客观,**禁止**承诺疗效、编造、给医疗建议。 5. 客观,**禁止**承诺疗效、编造、给医疗建议。
6. 严格按 JSON schema 输出,只有一个 key:summary。 6. 严格按 JSON schema 输出,只有一个 key:summary。
...@@ -28,6 +31,22 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -28,6 +31,22 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
- 某条只有事由没有结果(常见)→ 如实说"未留结果 / 结果未记录",**不要**推断当时聊了什么、患者什么态度。 - 某条只有事由没有结果(常见)→ 如实说"未留结果 / 结果未记录",**不要**推断当时聊了什么、患者什么态度。
- **禁止**输出"患者表示…""沟通中提到…"这类原话式表述,除非结果字段里原文就这么写。 - **禁止**输出"患者表示…""沟通中提到…"这类原话式表述,除非结果字段里原文就这么写。
# ⭐ 职责边界(硬约束,违反即错)
本卡叫「历史联系」,**只讲回访这件事本身**:哪次联系了、事由是什么、结果记了什么。
输入里的全部内容就是回访任务记录 —— **没有任何到诊/就诊/复诊数据**,一条都没有。
- **禁止**判断患者是否到诊、是否失联、是否沉睡、多久没来院。
这些结论有专门的地方出(画像卡的「末诊 N 天」是按真实接诊记录算的),这里说了必错。
- 回访结果字段里出现"已就诊""已到院"这类字,那是**当时那次回访记下的一句话**,
只能当"该次回访的结果"引述(如"3月回访记录已就诊"),**不得**当成患者的到诊状态,
更不得据此推断"上次到诊是X年X月"。
- "没有回访记录" ≠ "没来过" —— 回访是诊所主动打电话,患者自己来院不产生回访任务。
**禁止**用"回访少/没回访"推出"长期未到诊/失联"。
生产事故背景(2026-07-27):795 条摘要里 162 条断言"失联/沉睡/未到诊",
其中 88 条(54%)患者实际 180 天内来过。最离谱一条写"自2018年至今无到诊或有效联系,
属长期失联状态",而该患者上个月刚到诊。另一条把 2023 年回访结果里的"8月已就诊"
写成"上次有效到诊为2023年8月",实际末次接诊是 2025-12-20,差 2 年 4 个月。
# ⭐ 时间纪律(硬约束,违反即错) # ⭐ 时间纪律(硬约束,违反即错)
记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。 记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。
- 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"—— - 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"——
...@@ -35,12 +54,20 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -35,12 +54,20 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
- 只有标了【已过期·未执行】的条目才算真正漏做。 - 只有标了【已过期·未执行】的条目才算真正漏做。
- "最近一次""最新一次"这类说法**只能指标了【最近一次·已发生】的那一条**,不要用未来排程当"最近一次"。 - "最近一次""最新一次"这类说法**只能指标了【最近一次·已发生】的那一条**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期、哪条是最近一次**已经替你算好了**,直接用标记,不要自己比日期。 - 判断哪条是未来、哪条已过期、哪条是最近一次**已经替你算好了**,直接用标记,不要自己比日期。
- **距今多久也算好了**:每条带「N 天前」。要说"近 X 个月""近期""半年多"时**必须**按这个数换算
(30 天≈1 个月),**不要**自己拿日期减。写"近3个月"而记录其实是 226 天前,就是错。
# 示例(风格参考,不要照抄) # 示例(风格参考,不要照抄)
- "最近一次 4 月术后回访已完成、无不适;近 3 次活动邀约均未接通,建议换企微触达。" - "最近一次 4 月术后回访已完成、无不适;近 3 次活动邀约均未接通,建议换企微触达。"
- "最近一次 2 月复查已约到诊;此前半年 5 次回访无到诊,流失风险仍高。" - "最近一次 2 月复查回访已约上;此前半年 5 次回访多未留结果,建议本次确认进度。"
- "最近一次 5 月复查洁牙提醒未执行、未留结果;近一年 3 次回访均未跟进到位。" ← 只有事由没结果的正确写法 - "最近一次 5 月复查洁牙提醒未执行、未留结果;近一年 3 次回访均未跟进到位。" ← 只有事由没结果的正确写法
- "最近一次 4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`; - "最近一次 4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法
# 反例(**不要**这么写)
- ✗ "上次有效到诊为2023年8月,已超一年未复诊" ← 输入里没有到诊数据,凭空断言
- ✗ "自2018年至今无到诊或有效联系,属长期失联状态" ← 用"没有回访"推"没来过"
- ✗ "自2024年4月到诊后已沉睡超18个月" ← 同上,且时长是自己算的
- ✓ "2023年8月回访记录已就诊" ← 同一条源数据,这样引述才对`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string { export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines = const lines =
...@@ -53,8 +80,13 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s ...@@ -53,8 +80,13 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
// 最近一次要展开讲,给它更长的正文预算;其余条目只是背景趋势,40 字够了。 // 最近一次要展开讲,给它更长的正文预算;其余条目只是背景趋势,40 字够了。
// (源里偶有几百字的方案沟通记录,一刀切 40 字会把唯一一条有信息量的记录砍没) // (源里偶有几百字的方案沟通记录,一刀切 40 字会把唯一一条有信息量的记录砍没)
const budget = it.isLatestPast ? 160 : 40; const budget = it.isLatestPast ? 160 : 40;
// 距今天数也程序算好(见 input.types.daysAgo)—— 模型自己减日期会算错。
// 未来排程已有【未来排程·尚未到期】标记,不再重复报负数天数。
const ago =
it.daysAgo != null && it.daysAgo >= 0 ? `${it.daysAgo}天前` : null;
const parts = [ const parts = [
it.taskDate ?? '—', it.taskDate ?? '—',
ago,
when, when,
it.type ?? '回访', it.type ?? '回访',
it.status, it.status,
......
...@@ -86,6 +86,11 @@ export class RecallSummaryOrchestrator { ...@@ -86,6 +86,11 @@ export class RecallSummaryOrchestrator {
// 第一条**有日期且已到期**的就是它(至多一条;全是未来排程/全无日期 → 一条都不标)。 // 第一条**有日期且已到期**的就是它(至多一条;全是未来排程/全无日期 → 一条都不标)。
// 业务第一诉求是"上次联系聊到哪儿了",摘要必须先答这条 —— 见 input.types.isLatestPast。 // 业务第一诉求是"上次联系聊到哪儿了",摘要必须先答这条 —— 见 input.types.isLatestPast。
const latestPastIndex = dated.findIndex((v) => v.taskDate != null && v.taskDate <= today); const latestPastIndex = dated.findIndex((v) => v.taskDate != null && v.taskDate <= today);
// ⭐「距今多少天」也程序算 —— 模型自己减日期会算错(2026-07-27:把 7.5 个月前的回访
// 说成"近3个月")。口径同 isFuture:按日期(UTC 零点)差,未来排程为负。
const todayMs = Date.parse(`${today}T00:00:00Z`);
const daysAgoOf = (d: string | null): number | null =>
d == null ? null : Math.round((todayMs - Date.parse(`${d}T00:00:00Z`)) / 86400_000);
const input: DraftRecallSummaryInput = { const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者', patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
...@@ -98,6 +103,7 @@ export class RecallSummaryOrchestrator { ...@@ -98,6 +103,7 @@ export class RecallSummaryOrchestrator {
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示) // 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: v.taskDate != null && v.taskDate > today, isFuture: v.taskDate != null && v.taskDate > today,
isLatestPast: i === latestPastIndex, isLatestPast: i === latestPastIndex,
daysAgo: daysAgoOf(v.taskDate),
treatmentItems: v.treatmentItems, treatmentItems: v.treatmentItems,
followContent: v.followContent, followContent: v.followContent,
result: v.result, result: v.result,
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
treatmentCategoryNameZhForTeeth, treatmentCategoryNameZhForTeeth,
treatmentCategoryNameZhFor, treatmentCategoryNameZhFor,
recommendedCategoriesForAge, recommendedCategoriesForAge,
refineCategoriesForDiagnosis,
} from '@pac/types'; } from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import type { import type {
...@@ -352,6 +353,9 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -352,6 +353,9 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.id AS signal_fact_id, sig.id AS signal_fact_id,
sig.type AS signal_type, sig.type AS signal_type,
sig.content->>'code' AS signal_code, sig.content->>'code' AS signal_code,
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块) -- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth, ${gap.toothOutput} AS tooth,
sig.content->>'extracted_by' AS extracted_by, sig.content->>'extracted_by' AS extracted_by,
...@@ -484,11 +488,17 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -484,11 +488,17 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。 // lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code }; const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code };
const isRecLead = leadTrig.type === 'recommendation'; const isRecLead = leadTrig.type === 'recommendation';
// ⭐ 建议治疗:走**源头**的年龄排序(recommendedCategoriesForAge),此处不做任何年龄分支。 // ⭐ 建议治疗:走**源头**的两道重排,此处不做任何本地分支。
// >70 岁缺牙 → 活动义齿排在种植前(并行保留,不删种植 —— 高龄非种植禁忌,由医生评身体条件)。 // ① refineCategoriesForDiagnosis(临床语义)—— K00 同码内按诊断词定方向:
// ⚠️ 只影响"建议什么"的顺序与措辞;排除闸仍用完整的 expectedCats(见上方 excludeCats)。 // 乳牙滞留→外科(有牙可拔) / 乳牙早失→正畸(牙没了,管间隙) / 先天缺→修复 / 萌出障碍→正畸。
// ② recommendedCategoriesForAge(年龄)—— >70 岁缺牙义齿前置、<18 岁种植降末位(均并行保留)。
// ⚠️ 两道都只挪位不增删;排除闸仍用完整的 expectedCats(见上方 excludeCats)。
const age = r.patient_age ?? null; const age = r.patient_age ?? null;
const recCats = recommendedCategoriesForAge(expectedCats, age); const dxNameZh = r.signal_name_zh ?? null;
const recCats = recommendedCategoriesForAge(
refineCategoriesForDiagnosis(r.signal_code, dxNameZh, expectedCats),
age,
);
const focusCategory = recCats[0] ?? null; const focusCategory = recCats[0] ?? null;
const catsStr = recCats.map((c) => treatmentCategoryNameZhFor(c, r.tooth, age)).join(' / '); const catsStr = recCats.map((c) => treatmentCategoryNameZhFor(c, r.tooth, age)).join(' / ');
hits.push({ hits.push({
...@@ -532,11 +542,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -532,11 +542,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移) // 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null, signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...expectedCats], expectedCategories: [...expectedCats],
// ⭐ 建议治疗主类目(已按年龄适配)—— 前端「目标 · X」标签据此渲染, // ⭐ 建议治疗主类目(已按诊断词 + 年龄适配)—— 前端「目标 · X」标签据此渲染,
// 不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。 // 不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。
focusCategory, focusCategory,
// 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配) // 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配)
patientAge: age, patientAge: age,
// 诊断原文词快照 —— 前端 ReasonLine 要用同一套 refineCategoriesForDiagnosis 复算
// "未启动 X / Y" 的类目串,没这个字段前端算不出跟后端一致的顺序。
dxNameZh,
}, },
priorityBreakdown: breakdown, priorityBreakdown: breakdown,
}); });
...@@ -615,6 +628,7 @@ interface HitRow { ...@@ -615,6 +628,7 @@ interface HitRow {
signal_fact_id: string; signal_fact_id: string;
signal_type: string; // 'diagnosis_record' | 'recommendation_record' signal_type: string; // 'diagnosis_record' | 'recommendation_record'
signal_code: string; signal_code: string;
signal_name_zh: string | null; // 诊断原文词(K00 细分用);历史 fact 可能没写 → null 走默认序
tooth: string | null; tooth: string | null;
extracted_by: string | null; extracted_by: string | null;
confidence: string | null; // pg 转 string,JS Number 转换 confidence: string | null; // pg 转 string,JS Number 转换
......
import { import {
DENTURE_FIRST_AGE, DENTURE_FIRST_AGE,
IMPLANT_LAST_AGE,
EARLY_ORTHO_MAX_AGE,
refineCategoriesForDiagnosis,
recommendedCategoriesForAge, recommendedCategoriesForAge,
treatmentCategoryNameZhFor, treatmentCategoryNameZhFor,
treatmentCategoryNameZhForTeeth, treatmentCategoryNameZhForTeeth,
...@@ -112,3 +115,141 @@ describe('红线②:排除闸不受年龄影响', () => { ...@@ -112,3 +115,141 @@ describe('红线②:排除闸不受年龄影响', () => {
expect(cats).toContain('prosthodontic'); expect(cats).toContain('prosthodontic');
}); });
}); });
// =============================================================
// 2026-07 线上反馈(陈芃霖 BJ0A094257):K00 细分 + 低龄种植闸 + 早矫措辞
// 三者与「活动义齿」同构 —— 只挪位 / 只改措辞,排除闸与是否召回一律不动。
// =============================================================
describe('refineCategoriesForDiagnosis — K00 同码内按诊断词定方向', () => {
const K00 = lookupDxTreatment('K00')!.categories; // ['surgical','prosthodontic','implant','orthodontic']
test('⭐ 乳牙早失 → 正畸在前(牙已经掉了,没得拔;临床是间隙保持/早矫)', () => {
// 这就是线上那条错标:9 岁患儿 K00「乳牙早失」被打成「目标 · 外科」
const r = refineCategoriesForDiagnosis('K00', '乳牙早失', K00);
expect(r[0]).toBe('orthodontic');
});
test('乳牙滞留 / 多生牙 → 外科在前(有实体牙要拔,现状即正确)', () => {
expect(refineCategoriesForDiagnosis('K00', '乳牙滞留', K00)[0]).toBe('surgical');
expect(refineCategoriesForDiagnosis('K00', '多生牙', K00)[0]).toBe('surgical');
expect(refineCategoriesForDiagnosis('K00', '滞留乳牙', K00)[0]).toBe('surgical');
});
test('先天缺失 / 釉质发育不全 → 修复在前', () => {
expect(refineCategoriesForDiagnosis('K00', '先天缺失', K00)[0]).toBe('prosthodontic');
expect(refineCategoriesForDiagnosis('K00', '先天缺牙', K00)[0]).toBe('prosthodontic');
expect(refineCategoriesForDiagnosis('K00', '釉质发育不全', K00)[0]).toBe('prosthodontic');
});
test('异位萌出 / 迟萌 → 正畸在前(助萌导萌)', () => {
expect(refineCategoriesForDiagnosis('K00', '异位萌出', K00)[0]).toBe('orthodontic');
expect(refineCategoriesForDiagnosis('K00', '迟萌', K00)[0]).toBe('orthodontic');
});
test('⭐ 匹配顺序:「乳牙早失」不能被泛化的「缺失」规则吃掉', () => {
expect(refineCategoriesForDiagnosis('K00', '乳牙早失', K00)[0]).toBe('orthodontic');
expect(refineCategoriesForDiagnosis('K00', '先天缺失', K00)[0]).toBe('prosthodontic');
});
test('红线:只挪位,集合恒等(K00 四类目一个不少)', () => {
for (const name of ['乳牙早失', '乳牙滞留', '先天缺失', '釉质发育不全', '异位萌出']) {
const r = refineCategoriesForDiagnosis('K00', name, K00);
expect([...r].sort()).toEqual([...K00].sort());
}
});
test('红线:诊断码不动 —— 本函数不产出 code,K00 永远还是 K00', () => {
// 与 canonical-codes 302 行那条 "后天缺牙 → K08" recode 是两件事:
// 那条是宿主真标错了码;这里 K00 本身没标错,只是方向要细分。
expect(lookupDxTreatment('K00')).toBeTruthy();
expect(lookupDxTreatment('K00')!.categories).toEqual(K00);
});
test('回落:非 K00 / 空诊断词 / 未命中词 → 原样返回(旧数据无回归)', () => {
const K08 = lookupDxTreatment('K08')!.categories;
expect(refineCategoriesForDiagnosis('K08', '牙列缺损', K08)).toEqual(K08);
expect(refineCategoriesForDiagnosis('K00', null, K00)).toEqual(K00);
expect(refineCategoriesForDiagnosis('K00', ' ', K00)).toEqual(K00);
expect(refineCategoriesForDiagnosis('K00', '混合牙列', K00)).toEqual(K00); // 未命中 → 原序
});
});
describe('低龄种植闸 —— 与高龄义齿同构:只降位,不删', () => {
const K08 = lookupDxTreatment('K08')!.categories;
test('⭐ <18 岁缺牙 → 种植降到末位(颌骨未发育完成)', () => {
const r = recommendedCategoriesForAge(K08, 9);
expect(r[r.length - 1]).toBe('implant');
expect(r[0]).not.toBe('implant');
});
test('红线:种植仍在集合里(不替医生下禁忌结论)', () => {
const r = recommendedCategoriesForAge(K08, 9);
expect([...r].sort()).toEqual([...K08].sort());
});
test('⭐ 边界:恰好 18 岁 → **降位**(与全仓另外三处种植年龄闸同口径 ≤18)', () => {
// 差一岁就会自相矛盾 —— 2026-07-27 生产实测:恰好 18 岁有 1,299 条 plan、
// 其中 140 条 focusCategory=implant;若这里按 <18 判,这 140 条会在同一屏上
// 出现「目标 · 种植」+ 画像「种植禁忌」(contraindication.feature.ts 是 age <= 18)。
expect(recommendedCategoriesForAge(K08, IMPLANT_LAST_AGE)[0]).not.toBe('implant');
expect(recommendedCategoriesForAge(K08, IMPLANT_LAST_AGE - 1)[0]).not.toBe('implant');
// 满 19 才恢复原序 —— 与 potential-treatment 的 `age > 18 才产潜在种植` 接得上
expect(recommendedCategoriesForAge(K08, IMPLANT_LAST_AGE + 1)[0]).toBe('implant');
});
test('高龄 / 低龄两条互斥,各走各的', () => {
expect(recommendedCategoriesForAge(K08, 90)[0]).toBe('prosthodontic'); // 高龄:义齿前置
expect(recommendedCategoriesForAge(K08, 9)[0]).toBe('prosthodontic'); // 低龄:种植后置,同样是义齿/修复顶上
expect(recommendedCategoriesForAge(K08, 35)[0]).toBe('implant'); // 中间年龄:原样
});
test('单类目不受影响(没得挪)', () => {
expect(recommendedCategoriesForAge(['implant'], 9)).toEqual(['implant']);
});
});
describe('早矫措辞 —— 与「活动义齿」同构:同类目,只改展示名', () => {
test('⭐ ≤12 岁 orthodontic → 「早期矫治」', () => {
expect(treatmentCategoryNameZhFor('orthodontic', null, 9)).toBe('早期矫治');
expect(treatmentCategoryNameZhFor('orthodontic', null, EARLY_ORTHO_MAX_AGE)).toBe('早期矫治');
});
test('>12 岁 → 回落原名(恒牙列是常规正畸)', () => {
expect(treatmentCategoryNameZhFor('orthodontic', null, EARLY_ORTHO_MAX_AGE + 1)).toBe(
treatmentCategoryNameZhForTeeth('orthodontic', null),
);
expect(treatmentCategoryNameZhFor('orthodontic', null, 30)).toBe(
treatmentCategoryNameZhForTeeth('orthodontic', null),
);
});
test('无年龄 → 回落原名(不猜)', () => {
expect(treatmentCategoryNameZhFor('orthodontic', null, null)).toBe(
treatmentCategoryNameZhForTeeth('orthodontic', null),
);
});
test('红线:只改措辞不改类目 —— K07 排除闸仍认 orthodontic', () => {
expect(lookupDxTreatment('K07')!.categories).toEqual(['orthodontic']);
});
});
describe('端到端:线上那条错标的修复(9 岁 · K00 乳牙早失 · 牙位 1C;1D;3D)', () => {
test('⭐ 修前「目标 · 外科」→ 修后「目标 · 早期矫治」', () => {
const K00 = lookupDxTreatment('K00')!.categories;
const age = 9;
// 后端 scenario 与前端 ReasonLine 走的是同一对函数、同一顺序
const recCats = recommendedCategoriesForAge(
refineCategoriesForDiagnosis('K00', '乳牙早失', K00),
age,
);
expect(recCats[0]).toBe('orthodontic'); // focusCategory
expect(treatmentCategoryNameZhFor(recCats[0]!, '1C;1D;3D', age)).toBe('早期矫治');
// 未成年 → 种植沉到末位
expect(recCats[recCats.length - 1]).toBe('implant');
// 红线:集合没变 → 排除闸/召回与否完全不受影响
expect([...recCats].sort()).toEqual([...K00].sort());
});
});
...@@ -33,6 +33,7 @@ function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) { ...@@ -33,6 +33,7 @@ function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) {
taskStatus: '已完成', taskStatus: '已完成',
isFuture: false, isFuture: false,
isLatestPast: false, isLatestPast: false,
daysAgo: 91,
treatmentItems: null, treatmentItems: null,
followContent: null, followContent: null,
result: '治疗后回访', result: '治疗后回访',
...@@ -246,3 +247,68 @@ describe('DraftRecallSummaryCall.fallback — 兜底也不许把未来排程当 ...@@ -246,3 +247,68 @@ describe('DraftRecallSummaryCall.fallback — 兜底也不许把未来排程当
expect(s).toContain('未留结果'); expect(s).toContain('未留结果');
}); });
}); });
/**
* ⭐ 职责边界 —— 「历史联系」只讲回访,不许对到诊/失联下结论。
*
* 生产事故(2026-07-27 排查):同一类错误换个轴又来一遍。上一次(07-24)是"未来 vs 过去",
* 这次是"回访 vs 到诊"和"距今多久":
* · 陈芃霖:2023-08-15 回访结果原文「8月已就诊」→ 摘要写成"上次**有效到诊**为2023年8月",
* 而 PAC 自己的接诊记录末次是 2025-12-20 —— 差 2 年 4 个月;
* 同句还有"已超一年未复诊"(实为 7 个月)、"近3个月"(该回访实为 226 天前)。
* · 全量:795 条摘要里 162 条断言"失联/沉睡/未到诊",其中 88 条(54%)患者
* 实际 180 天内来过;最离谱一条写"自2018年至今无到诊或有效联系,属长期失联状态",
* 该患者上个月刚到诊。
*
* 根因:本 AiCall 的输入**只有回访记录,没有任何到诊数据**(见 input.types),
* 模型只能从回访备注文字里猜、或用"没有回访"推"没来过"——两条路都错。
* 而旧 prompt 不但没拦,示例里还有"此前半年 5 次回访无到诊"在示范这个错法。
*
* 本组用例锁两件事:① prompt 明确禁止到诊类结论;② 距今天数由程序算好喂进去。
*/
describe('⭐ 职责边界:只摘要回访事实,不判到诊', () => {
test('system prompt 明写「没有到诊数据」并禁止相关结论', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('没有任何到诊/就诊/复诊数据');
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('禁止');
// "没有回访 ≠ 没来过" 这条推理链必须被显式掐断
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('"没有回访记录" ≠ "没来过"');
});
test('结果字段里的「已就诊」只能当那次回访的记录引述,不得当到诊状态', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('不得**当成患者的到诊状态');
});
test('⭐ 示例里不能再出现示范错法的句子(旧版"5 次回访无到诊"就是元凶之一)', () => {
const examples = DRAFT_RECALL_SUMMARY_SYSTEM.slice(DRAFT_RECALL_SUMMARY_SYSTEM.indexOf('# 示例'));
expect(examples).not.toContain('回访无到诊');
// 反例段必须把三条真实事故句列为 ✗,让模型见过错法长什么样
expect(examples).toContain('上次有效到诊为2023年8月');
expect(examples).toContain('长期失联状态');
});
test('关键要求里不再把「长期未到诊」列为要突出的信号', () => {
const head = DRAFT_RECALL_SUMMARY_SYSTEM.slice(0, DRAFT_RECALL_SUMMARY_SYSTEM.indexOf('# ⭐ 职责边界'));
expect(head).not.toContain('长期未到诊');
});
test('⭐ 距今天数由程序算好并渲染进 user prompt —— 模型不再自己减日期', () => {
const s = build([item({ taskDate: '2025-12-10', isLatestPast: true, daysAgo: 226, result: '涂氟 邀约' })]);
expect(s).toContain('226天前');
});
test('未来排程不报负数天数(已有【未来排程】标记,重复只会添乱)', () => {
const s = build([item({ taskDate: '2026-10-09', isFuture: true, daysAgo: -77 })]);
expect(s).not.toContain('-77');
expect(s).not.toContain('77天前');
expect(s).toContain('尚未到期');
});
test('无日期的条目不报天数', () => {
const s = build([item({ taskDate: null, daysAgo: null })]);
expect(s).not.toMatch(/\d+天前/);
});
test('时间纪律段要求按 daysAgo 换算相对时间', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('距今多久也算好了');
});
});
import { parsePatientsFile, splitPatientTokens } from "../src/cli/reparse-patients-file";
/**
* `--patients-file` 解析回归 —— 这个参数决定 reparse 跑 2 分钟还是 4.5 小时。
*
* 背景(2026-07-27 测试服实测):全量 reparse = 260,434 患者 / 87 批 / ~4.5 小时,
* 而时间几乎全花在"证明没变"上 —— 真正会变的:
* · 改 clinical-signals 字典 → 约 4 万患者(15%)
* · 改 planned「二期」归类规则 → 1,017 患者(0.4%)→ 收窄后 2 分钟跑完
*
* ⭐ 最要紧的一条不在本文件而在 CLI:解析结果为空必须**报错退出**。
* 服务侧 `if (!scopePatientIds?.length)` 把空数组当"不限定" →
* 一个路径写错的收窄命令会静默变成 4.5 小时全量,且日志上看不出异常。
* 这里先把"什么算解析出来了"钉死。
*/
describe('parsePatientsFile — 宽容但不猜', () => {
it('一行一个,去空行去首尾空格', () => {
expect(parsePatientsFile('a\n b \n\n\nc\n')).toEqual(['a', 'b', 'c']);
});
it('# 注释整行忽略', () => {
expect(parsePatientsFile('# 受影响患者\na\n#b\nc')).toEqual(['a', 'c']);
});
it('⭐ 行尾注释也剥掉 —— 圈人清单常顺手标注是谁', () => {
expect(parsePatientsFile('a # 张三 缺牙\nb\t# 李四')).toEqual(['a', 'b']);
});
it('去重(psql dump 混着几次查询很常见)', () => {
expect(parsePatientsFile('a\nb\na\nb\nc')).toEqual(['a', 'b', 'c']);
});
it('剥行尾逗号/分号 —— 从 SQL IN 列表直接拷出来的形态', () => {
expect(parsePatientsFile('a,\nb;\nc')).toEqual(['a', 'b', 'c']);
});
it('CRLF(Windows / 某些导出工具)', () => {
expect(parsePatientsFile('a\r\nb\r\n')).toEqual(['a', 'b']);
});
it('全是空白/注释 → 空数组(CLI 据此报错,不退化成全量)', () => {
expect(parsePatientsFile('\n\n \n# 只有注释\n')).toEqual([]);
expect(parsePatientsFile('')).toEqual([]);
});
it('认不出的原样保留 —— 不做猜测性纠错,交给库里查、查不到再报', () => {
expect(parsePatientsFile('BJ0A094257\n不是id\n123')).toEqual(['BJ0A094257', '不是id', '123']);
});
});
describe('splitPatientTokens — uuid 走 patient.id,其余当 externalId', () => {
const UUID = 'f5ac71d5-c8b8-4f39-8090-ec3478810aa0'; // 生产真实患者 陈芃霖
it('标准 uuid 归 ids', () => {
expect(splitPatientTokens([UUID])).toEqual({ ids: [UUID], externalIds: [] });
});
it('大写 uuid 也认(psql / Excel 可能改大小写)', () => {
const up = UUID.toUpperCase();
expect(splitPatientTokens([up]).ids).toEqual([up]);
});
it('病历号 / 数字 externalId 归 externalIds', () => {
expect(splitPatientTokens(['BJ0A094257', '1551051'])).toEqual({
ids: [],
externalIds: ['BJ0A094257', '1551051'],
});
});
it('⭐ 混写 —— 手上有什么用什么,两类都要能收', () => {
const r = splitPatientTokens([UUID, 'BJ0A094257', 'f5ac71d5-c8b8-4f39-8090-ec3478810aa1']);
expect(r.ids).toHaveLength(2);
expect(r.externalIds).toEqual(['BJ0A094257']);
});
it('长得像但不是 uuid(缺段/多段)→ 当 externalId,不冒充 id', () => {
expect(splitPatientTokens(['f5ac71d5-c8b8-4f39-8090']).externalIds).toHaveLength(1);
expect(splitPatientTokens(['f5ac71d5c8b84f398090ec3478810aa0']).externalIds).toHaveLength(1);
});
});
...@@ -566,3 +566,45 @@ describe('fact-block | renderTreatmentPlan(结构化 planned 主,host 自由文 ...@@ -566,3 +566,45 @@ describe('fact-block | renderTreatmentPlan(结构化 planned 主,host 自由文
expect(renderTreatmentPlan(emptyMr())).toBeNull(); expect(renderTreatmentPlan(emptyMr())).toBeNull();
}); });
}); });
// =============================================================
// 2026-07 线上反馈:年龄护栏三条(高龄义齿 / 低龄种植闸 / 替牙期早矫措辞)
// 三条同构 —— 都是"按年龄给措辞约束",都不改召回、不改类目集合。
// =============================================================
describe('fact-block | 年龄护栏(高龄义齿 / 低龄种植 / 早矫措辞)', () => {
const block = (age: number | null) => buildRichFactBlock(makeInput({ patient: { age } }));
test('⭐ 9 岁:低龄种植闸 + 早矫措辞都上,高龄块不出现', () => {
const b = block(9);
expect(b).toContain('不要主推种植');
expect(b).toContain('颌骨尚未发育完成');
expect(b).toContain('早期矫治');
expect(b).not.toContain('先讲活动义齿');
});
test('15 岁:仍禁主推种植,但已过替牙期 → 不再强制"早期矫治"措辞', () => {
const b = block(15);
expect(b).toContain('不要主推种植');
expect(b).not.toContain('处替牙期');
});
test('35 岁:三条护栏都不出现(中间年龄原样)', () => {
const b = block(35);
expect(b).not.toContain('不要主推种植');
expect(b).not.toContain('处替牙期');
expect(b).not.toContain('先讲活动义齿');
});
test('90 岁:只出高龄义齿块(与低龄块互斥)', () => {
const b = block(90);
expect(b).toContain('先讲活动义齿');
expect(b).not.toContain('颌骨尚未发育完成');
});
test('无年龄:不猜 —— 三条护栏都不出(禁拍片 belt 另有兜底,不受影响)', () => {
const b = block(null);
expect(b).not.toContain('不要主推种植');
expect(b).not.toContain('处替牙期');
expect(b).not.toContain('先讲活动义齿');
});
});
...@@ -84,4 +84,60 @@ describe('treatment_planned keyword classifier (计划:不剥建议/拟)', () => ...@@ -84,4 +84,60 @@ describe('treatment_planned keyword classifier (计划:不剥建议/拟)', () =>
expect(strip).not.toContain('建议'); expect(strip).not.toContain('建议');
expect(strip).toContain('必要时'); expect(strip).toContain('必要时');
}); });
// ─────────────────────────────────────────────────────────
// 2026-07 线上反馈(陈芃霖 BJ0A094257):裸「二期」误吞正畸。
// 修法 = 把裸「二期」从 implant 主规则降到全表最末兜底(不是收紧成「种植二期」——
// planned 侧的种植二期普遍不带"种植",收紧会丢 900+ 条,见 yaml 头注释)。
// 下面两组用例把这个**位次**锁死:动了顺序就红。
// ─────────────────────────────────────────────────────────
describe('裸「二期」位次(正畸先收 / 种植兜底)', () => {
// 组一:带正畸词 → 必须 orthodontic(prod 实测 420 条 planned 属这组)
const orthoCases = [
'正畸二期矫治', // ⭐ 线上原文:2025-12-20 的计划
'二期矫治',
'二期矫正',
'正畸二期',
'正畸二期治疗',
'早期矫正+二期矫正',
'早期矫治+二期矫治',
'酌情二期矫治',
'恒牙列期酌情二期矫治',
'二期矫治(大概率拔牙)',
];
it.each(orthoCases)('「%s」→ orthodontic(不再被种植吞)', (name) => {
expect(classifyByKeyword(name, rules, strip)).toBe('orthodontic');
});
// 组二:无正畸词 → 仍 implant(修复前后逐条一致,不能因为改顺序把它们丢了)
const implantCases = [
'二期取模', // prod 346 条,planned 里最大的一组
'二期手术',
'二期修复',
'二期',
'三个月后二期',
'3个月后二期手术',
'4-6个月后二期修复',
'二期口扫',
];
it.each(implantCases)('「%s」→ implant(种植二期常省"种植",靠末位兜底)', (name) => {
expect(classifyByKeyword(name, rules, strip)).toBe('implant');
});
it('真种植计划不受影响(含"种植"走第 1 条,不依赖兜底)', () => {
expect(classifyByKeyword('种植修复', rules, strip)).toBe('implant');
expect(classifyByKeyword('正畸后种植修复', rules, strip)).toBe('implant');
expect(classifyByKeyword('种植二期', rules, strip)).toBe('implant');
});
it('⭐ 结构约束:裸「二期」规则必须排在 orthodontic 之后(挪回文件头即误吞)', () => {
const bareErqi = rules.findIndex((r) => r.value === 'implant' && r.any.includes('二期'));
const ortho = rules.findIndex((r) => r.value === 'orthodontic');
expect(bareErqi).toBeGreaterThan(-1);
expect(ortho).toBeGreaterThan(-1);
expect(bareErqi).toBeGreaterThan(ortho);
// 且 implant 主规则(种植/植体)仍在最前,不能被降位
expect(rules.findIndex((r) => r.value === 'implant' && r.any.includes('种植'))).toBeLessThan(ortho);
});
});
}); });
...@@ -93,6 +93,8 @@ export type PlanDetailData = { ...@@ -93,6 +93,8 @@ export type PlanDetailData = {
focusCategory?: string | null; focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */ /** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null; patientAge?: number | null;
/** 触发诊断原文词快照(如「乳牙早失」);K00 细分用,旧 plan 无 → 回落原顺序 */
dxNameZh?: string | null;
} | null; } | null;
priorityScore: number; priorityScore: number;
reason: string; reason: string;
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZhFor, treatmentCategoryNameZhFor,
recommendedCategoriesForAge, recommendedCategoriesForAge,
refineCategoriesForDiagnosis,
} from '@pac/types'; } from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils'; import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
...@@ -32,6 +33,8 @@ export interface ReasonLineInput { ...@@ -32,6 +33,8 @@ export interface ReasonLineInput {
focusCategory?: string | null; focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */ /** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null; patientAge?: number | null;
/** 触发诊断原文词快照(如「乳牙早失」);K00 细分用,旧 plan 无 → 回落原顺序 */
dxNameZh?: string | null;
} | null; } | null;
} }
...@@ -54,10 +57,15 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) { ...@@ -54,10 +57,15 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
const isRecLead = trig?.type === 'recommendation'; const isRecLead = trig?.type === 'recommendation';
const subLabel = const subLabel =
isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey); isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey);
// 建议治疗:走**源头**的年龄排序(与后端同一个 recommendedCategoriesForAge), // 建议治疗:走**源头**的两道重排(与后端同一对函数,保证「目标 · X」与本行首项一致):
// >70 岁缺牙 → 「活动义齿 / 种植」(义齿在前,种植并行保留 —— 高龄非种植禁忌); // ① refineCategoriesForDiagnosis —— K00 按诊断词定方向(早失→正畸、滞留→外科…);
// 乳牙 restorative → 「充填」(去嵌体)。旧 plan 无 patientAge → 原顺序,行为不变。 // ② recommendedCategoriesForAge —— >70 岁缺牙义齿前置、<18 岁种植降末位(均并行保留);
const cats = recommendedCategoriesForAge(s.expectedCategories, s.patientAge ?? null) // 乳牙 restorative → 「充填」(去嵌体);≤12 岁 orthodontic → 「早期矫治」。
// 旧 plan 无 patientAge / dxNameZh → 原顺序,行为不变。
const cats = recommendedCategoriesForAge(
refineCategoriesForDiagnosis(trig?.code, s.dxNameZh, s.expectedCategories),
s.patientAge ?? null,
)
.map((c) => treatmentCategoryNameZhFor(c, s.toothPosition, s.patientAge ?? null)) .map((c) => treatmentCategoryNameZhFor(c, s.toothPosition, s.patientAge ?? null))
.join(' / '); .join(' / ');
return ( return (
......
...@@ -754,13 +754,100 @@ export function treatmentCategoryNameZhForTeeth( ...@@ -754,13 +754,100 @@ export function treatmentCategoryNameZhForTeeth(
export const DENTURE_FIRST_AGE = 70; export const DENTURE_FIRST_AGE = 70;
/** /**
* 「种植降位」年龄线 —— **≤ 该年龄**时把种植挪到建议序末位。
*
* 临床口径:种植体是骨结合的刚性锚,**不随颌骨生长移动** —— 颌骨发育完成前(女约 17-18、
* 男约 18-20)植入,会随邻牙继续萌出而相对埋没、咬合紊乱,是公认的相对禁忌。
*
* ⭐ 边界必须是 **≤18**,与全仓另外三处种植年龄闸同口径 —— 差一岁就会自相矛盾:
* contraindication.feature.ts `age <= 18` → 打「种植禁忌」
* potential-treatment.feature.ts `age > 18` → 才产「潜在种植」
* fact-block.ts noXray `age <= 18` → 话术禁提拍片
* 2026-07-27 生产实测:恰好 18 岁的患者有 1,299 条 plan,其中 140 条 focusCategory=implant。
* 若本条按 <18 判,这 140 条会在**同一屏**上出现「目标 · 种植」+ 画像「种植禁忌」。
*
* 处理方式与 [[DENTURE_FIRST_AGE]] 一致:**只挪位不删** —— PAC 不替医生下禁忌结论,
* 但也绝不把「目标 · 种植」摆到未成年人头上(2026-07 线上实测 1462 条,其中 731 条 <12 岁)。
*/
export const IMPLANT_LAST_AGE = 18;
/**
* 「早期矫治」措辞年龄线 —— **≤ 该年龄**时 orthodontic 的展示名说「早期矫治」。
*
* 临床口径:早矫(一期)在替牙期 / 混合牙列做,约 6-12 岁,目标是干预颌骨发育、不良习惯
* 与间隙管理;12 岁后恒牙列已成,是常规(二期)正畸,目标是排齐与咬合精调。
*
* ⚠️ 只改**措辞**不改类目 —— 两者同属 orthodontic,排除闸不受影响。
* ⚠️ 年龄只是**默认推定**,推不翻"早矫已在外院做完"这类病历事实(那需要外院治疗史通道,
* 本函数够不着);措辞层面按年龄给最可能的说法,准确度由上游事实兜。
*/
export const EARLY_ORTHO_MAX_AGE = 12;
/**
* K00 主类目细分(**同码内重排,不改诊断码**)。
*
* 背景(2026-07 线上反馈 陈芃霖 BJ0A094257):K00「牙发育和萌出障碍」是个大口袋 ——
* 乳牙滞留(该掉没掉,要拔)/ 乳牙早失(已经掉了,要管间隙)/ 先天缺失 / 釉质发育不全 /
* 萌出障碍 语义完全不同,却共用一份固定类目序 `[surgical, ...]` → focusCategory 恒为
* surgical,给「乳牙早失」也打「目标 · 外科」(没牙可拔)。线上约 2800 条这样的错标。
*
* ⚠️ **诊断码不动** —— K00 还是 K00(不像 302 行那条"后天缺牙 → K08"是真的标错了码);
* 这里只是同一个 K00 内部按诊断词把最贴切的类目提到首位,其余保持原相对顺序。
* ⚠️ 与 [[recommendedCategoriesForAge]] 同边界:只挪位,返回集合恒等于入参集合,排除闸不受影响。
*
* 匹配顺序敏感 —— 自上而下首个命中生效(「乳牙早失」须先于泛化的「缺失」)。
* 未命中(含 name_zh 为空的历史数据)→ 原样返回,行为与修复前一致。
*/
export const K00_LEAD_CATEGORY_RULES: ReadonlyArray<{
pattern: RegExp;
lead: string;
why: string;
}> = [
// 该掉没掉 / 多余牙 / 该掉在掉 —— 有实体牙要处理,外科正确(线上占比最大,现状即对)
{ pattern: /滞留|多生|松动/, lead: 'surgical', why: '有实体牙待拔' },
// 已经掉了 —— 没牙可拔,临床是间隙保持 / 早期矫治
{ pattern: /早失/, lead: 'orthodontic', why: '牙已缺失,管间隙' },
// 萌不出来 / 长歪了 —— 助萌、导萌、排齐
{ pattern: /萌出|迟萌|阻萌|埋伏|异位/, lead: 'orthodontic', why: '萌出障碍待导萌' },
// 先天没长 —— 修复方向(未成年由年龄闸把种植压后,见 recommendedCategoriesForAge)
{ pattern: /先天缺|缺失|缺牙/, lead: 'prosthodontic', why: '先天缺牙待修复' },
// 形态 / 色泽 / 结构异常 —— 冠或贴面类修复
{ pattern: /釉质|氟斑|四环素|过小牙|畸形牙|发育不全|发育不良/, lead: 'prosthodontic', why: '牙体形态色泽异常待修复' },
];
/**
* 诊断词细分建议类目(目前只作用于 K00)—— 排在年龄适配**之前**的一道语义重排。
*
* 顺序:`refineCategoriesForDiagnosis`(临床语义)→ `recommendedCategoriesForAge`(年龄)。
* 前者定"这个病该往哪个方向治",后者定"这个岁数该先讲哪个"。两道都只挪位。
*
* @param code 诊断码(非 K00 一律原样返回)
* @param nameZh 诊断原文词(DW diag message,如「乳牙早失」);空 → 原样返回
* @param categories 该诊断的候选治疗类目
*/
export function refineCategoriesForDiagnosis(
code: string | null | undefined,
nameZh: string | null | undefined,
categories: readonly string[],
): readonly string[] {
if (code !== 'K00') return categories;
const name = (nameZh ?? '').trim();
if (!name) return categories;
const hit = K00_LEAD_CATEGORY_RULES.find((r) => r.pattern.test(name));
if (!hit || !categories.includes(hit.lead)) return categories;
return [hit.lead, ...categories.filter((c) => c !== hit.lead)];
}
/**
* 诊断 → 建议治疗类目(**按年龄排序**)—— 建议侧的单一真理源。 * 诊断 → 建议治疗类目(**按年龄排序**)—— 建议侧的单一真理源。
* *
* ⚠️ 边界:只调整**顺序**,不增删类目 —— 返回集合恒等于入参集合。 * ⚠️ 边界:只调整**顺序**,不增删类目 —— 返回集合恒等于入参集合。
* 排除闸(`DiagnosisTreatmentMap[].categories`:做过其中任一即算已解决)完全不受影响。 * 排除闸(`DiagnosisTreatmentMap[].categories`:做过其中任一即算已解决)完全不受影响。
* *
* 规则:候选同时含 implant + prosthodontic(缺牙 K08 这类"种植或义齿"并行可选)时, * 两条规则(互斥 —— 年龄不可能同时 >70 与 <18):
* ① 候选同时含 implant + prosthodontic(缺牙 K08 这类"种植或义齿"并行可选)时,
* 年龄 > DENTURE_FIRST_AGE → prosthodontic 提到 implant 之前(义齿在前,种植并行保留); * 年龄 > DENTURE_FIRST_AGE → prosthodontic 提到 implant 之前(义齿在前,种植并行保留);
* ② 年龄 ≤ IMPLANT_LAST_AGE(颌骨未发育完成)→ implant 挪到**末位**(仍保留,不删)。
* 其余情况原样返回。 * 其余情况原样返回。
* *
* @param categories 该诊断的候选治疗类目 * @param categories 该诊断的候选治疗类目
...@@ -770,8 +857,13 @@ export function recommendedCategoriesForAge( ...@@ -770,8 +857,13 @@ export function recommendedCategoriesForAge(
categories: readonly string[], categories: readonly string[],
age: number | null | undefined, age: number | null | undefined,
): readonly string[] { ): readonly string[] {
if (age == null) return categories;
// ② 未成年:种植降到末位(与 ① 互斥,先判 —— 低龄不可能同时触发义齿前置)
if (age <= IMPLANT_LAST_AGE && categories.includes('implant') && categories.length > 1) {
return [...categories.filter((c) => c !== 'implant'), 'implant'];
}
// ① 高龄:活动义齿前置
const dentureFirst = const dentureFirst =
age != null &&
age > DENTURE_FIRST_AGE && age > DENTURE_FIRST_AGE &&
categories.includes('implant') && categories.includes('implant') &&
categories.includes('prosthodontic'); categories.includes('prosthodontic');
...@@ -787,6 +879,7 @@ export function recommendedCategoriesForAge( ...@@ -787,6 +879,7 @@ export function recommendedCategoriesForAge(
* 建议治疗的中文标签(年龄 + 牙位双适配)—— 前后端共用,保证目标标签 / 理由 / 话术一致。 * 建议治疗的中文标签(年龄 + 牙位双适配)—— 前后端共用,保证目标标签 / 理由 / 话术一致。
* 高龄的 prosthodontic 明确说「活动义齿」(而非泛化的"修复 / 冠桥" —— 冠桥需邻牙条件, * 高龄的 prosthodontic 明确说「活动义齿」(而非泛化的"修复 / 冠桥" —— 冠桥需邻牙条件,
* 高龄同样受限,说清是活动义齿更贴合实际推荐)。 * 高龄同样受限,说清是活动义齿更贴合实际推荐)。
* 低龄的 orthodontic 明确说「早期矫治」(替牙期做的是一期干预,不是排齐恒牙列)。
*/ */
export function treatmentCategoryNameZhFor( export function treatmentCategoryNameZhFor(
category: string, category: string,
...@@ -796,6 +889,9 @@ export function treatmentCategoryNameZhFor( ...@@ -796,6 +889,9 @@ export function treatmentCategoryNameZhFor(
if (category === 'prosthodontic' && age != null && age > DENTURE_FIRST_AGE) { if (category === 'prosthodontic' && age != null && age > DENTURE_FIRST_AGE) {
return '活动义齿'; return '活动义齿';
} }
if (category === 'orthodontic' && age != null && age <= EARLY_ORTHO_MAX_AGE) {
return '早期矫治';
}
return treatmentCategoryNameZhForTeeth(category, toothPosition); return treatmentCategoryNameZhForTeeth(category, toothPosition);
} }
......
...@@ -57,9 +57,15 @@ export const ReasonSignalsSchema = z.object({ ...@@ -57,9 +57,15 @@ export const ReasonSignalsSchema = z.object({
focusCategory: z.string().nullable().optional(), focusCategory: z.string().nullable().optional(),
/// plan 生成时的患者年龄快照(无生日 → null)。 /// plan 生成时的患者年龄快照(无生日 → null)。
/// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」)。 /// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」,低龄正畸说「早期矫治」)。
/// 存快照而非实时算:与 focusCategory 同源同刻,避免生日跨阈值后标签与理由文案打架。 /// 存快照而非实时算:与 focusCategory 同源同刻,避免生日跨阈值后标签与理由文案打架。
patientAge: z.number().int().nullable().optional(), patientAge: z.number().int().nullable().optional(),
/// 触发诊断的原文词快照(DW diag message,如「乳牙早失」;推荐类信号 → null)。
/// 用途:前端 ReasonLine 用 refineCategoriesForDiagnosis 复算类目顺序,与后端 focusCategory 同口径
/// —— K00 是大口袋(滞留/早失/先天缺/釉质…方向完全不同),只给 code 前端分不出方向。
/// optional/nullable:旧 plan 无此字段 → 细分不生效,回落原顺序(无回归)。
dxNameZh: z.string().nullable().optional(),
}); });
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>; export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;
......
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