Commit 92f32b19 by luoqi

feat(persona): 潜在治疗特征(C.1.1,复用召回 gap 核心)

- 新建 PotentialTreatmentSelector(clinical-gap):per-patient gap = 召回 gap 核心(共享 buildGapCore)
  去时间门/合规门(常态属性);按 active 诊断/建议码剪枝。
- potential_treatment 画像特征:gap → 8 业务标签(种植←K08>18 / 补牙←K02 / 根管←K04 /
  牙周←K05,K06 / 正畸←K07>12≤40 / 早矫←K07 3-12 / 修复←K03默认 / 拔牙←K01+K03残根残冠)。多标签。
- 码分组单一源 GAP_PRIMARY_GROUPS:召回 SUB_SCENARIOS 不再内联 dxCodes/recCodes,改引共享。
-  召回字节等价再验(Phase2 码分组改后 EXCEPT 双向 0 diff,1450 全等)。
- 本地 928:771 患者有潜在治疗(拔牙433/补牙341/修复289/种植184/牙周150/正畸105/根管97/早矫24),
  是召回候选超集(无时间门);正畸+早矫129<召回163 因 spec 正畸封顶40岁(符合预期)。
- 置信度=诊断1.0/建议0.8(复用)。非已丢单 PAC 未摄入,省略(follow-up)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 76516429
import { Module } from '@nestjs/common';
import { PotentialTreatmentSelector } from './potential-treatment.selector';
/**
* ClinicalGapModule — 潜在治疗 gap 选择器(facts 消费,中立层)。
* Layer 2(潜在治疗画像)与 Layer 3(召回 scenario 直接 import gap-sql 片段)共用本域。
* PrismaService 由全局 PrismaModule(@Global)提供,无需在此 import。
*/
@Module({
providers: [PotentialTreatmentSelector],
exports: [PotentialTreatmentSelector],
})
export class ClinicalGapModule {}
...@@ -39,6 +39,21 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = { ...@@ -39,6 +39,21 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = {
const RESTORATION_INELIGIBLE_NAMES = [...RESTORATION_INELIGIBLE_DX_NAMES]; const RESTORATION_INELIGIBLE_NAMES = [...RESTORATION_INELIGIBLE_DX_NAMES];
/// 诊断码 ↔ 建议码分组(单一真理源)。召回 SUB_SCENARIOS 与潜在治疗画像/selector 都引此,
/// 保证"哪些 code 同属一个 gap"口径一致。建议码 = EMR/影像AI/客服 抽出的第二信号源。
export const GAP_PRIMARY_GROUPS: Record<string, { dxCodes: string[]; recCodes: string[] }> = {
K08: { dxCodes: ['K08'], recCodes: ['IMPLANT_RECOMMENDED'] },
K07: { dxCodes: ['K07'], recCodes: ['ORTHO_CONSULT_RECOMMENDED'] },
K04: { dxCodes: ['K04'], recCodes: ['RCT_RECOMMENDED'] },
K05: { dxCodes: ['K05'], recCodes: ['SRP_RECOMMENDED'] },
K02: { dxCodes: ['K02'], recCodes: ['FILLING_RECOMMENDED'] },
K03: { dxCodes: ['K03'], recCodes: ['HARD_TISSUE_REPAIR_RECOMMENDED', 'CROWN_RECOMMENDED'] },
K06: { dxCodes: ['K06'], recCodes: ['GUM_TREATMENT_RECOMMENDED'] },
K01: { dxCodes: ['K01'], recCodes: ['EXTRACTION_RECOMMENDED'] },
K09: { dxCodes: ['K09'], recCodes: ['JAW_CYST_REMOVAL_RECOMMENDED'] },
K00: { dxCodes: ['K00'], recCodes: ['ERUPTION_INTERVENTION_RECOMMENDED'] },
};
/// 牙位字符串 → 牙位数组(只剥"牙位base+空格+牙面字母"后缀,保 FDI 数字 & Palmer 乳牙字母)。 /// 牙位字符串 → 牙位数组(只剥"牙位base+空格+牙面字母"后缀,保 FDI 数字 & Palmer 乳牙字母)。
/// 口径必须跟 tooth-position.util.ts 的 toothSet 严格一致。详见原 scenario 注释。 /// 口径必须跟 tooth-position.util.ts 的 toothSet 严格一致。详见原 scenario 注释。
export function toothArrSql( export function toothArrSql(
......
import { Injectable } from '@nestjs/common';
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
} from './potential-treatment-gap.sql';
/**
* PotentialTreatmentSelector — 潜在治疗 gap 选择器(Layer 1 facts 消费,中立)
*
* 复用召回的 gap 核心(buildGapCore),产出每患者"诊断了/建议了但没启动对应治疗"的牙位级 gap,
* 但**去掉召回的"现在能不能打"门**(cooldown / 未来预约 / 近期到诊 / 合规 DNC)——
* 因为潜在治疗是"有客观需求"的**常态属性**(画像标签),不是"现在该打"(召回)。
*
* 消费方:Layer 2 潜在治疗画像(potential-treatment.feature)。
* 单一真理源:gap 算法 / DiagnosisTreatmentMap / resolverCategoriesFor / §E flag / 码分组 全共享。
*/
@Injectable()
export class PotentialTreatmentSelector {
constructor(private readonly prisma: PrismaService) {}
/**
* 单患者 gap(按 activeCodes 剪枝:只查该患者真有 active 诊断/建议码的子场景)。
* activeCodes = 该患者 active diagnosis_record + recommendation_record 的 content.code 集合(调用方从 ctx 取)。
*/
async selectForPatient(opts: {
hostId: string;
tenantId: string;
patientId: string;
now: Date;
activeCodes: Set<string>;
}): Promise<PotentialGap[]> {
const { hostId, tenantId, patientId, now, activeCodes } = opts;
const out: PotentialGap[] = [];
for (const [primaryCode, group] of Object.entries(GAP_PRIMARY_GROUPS)) {
const allCodes = [...group.dxCodes, ...group.recCodes];
// 剪枝:患者没有该组任一码 → 跳过(0 行,免一次 SQL)
if (!allCodes.some((c) => activeCodes.has(c))) continue;
const rule = lookupDxTreatment(primaryCode);
if (!rule) continue;
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });
const rows = await this.prisma.$queryRaw<RawGapRow[]>`
SELECT
sig.content->>'code' AS code,
sig.content->>'name_zh' AS name_zh,
sig.type AS signal_type,
${gap.toothOutput} AS tooth,
sig.content->>'confidence' AS confidence,
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
FROM patients p
JOIN patient_facts sig ON sig.patient_id = p.id
${gap.lateralJoin}
WHERE p.host_id = ${hostId}::uuid
AND p.tenant_id = ${tenantId}
AND p.id = ${patientId}::uuid
AND p.active = true
AND sig.status = 'active'
AND sig.type IN ('diagnosis_record', 'recommendation_record')
AND sig.content->>'code' = ANY(${allCodes}::text[])
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
${gap.restorationIneligibleFrag}
${gap.congenitalFrag}
${gap.gapWhere}
`;
for (const r of rows) {
out.push({
primaryCode,
code: r.code,
nameZh: r.name_zh ?? null,
tooth: r.tooth ?? null,
daysSince: r.days_since,
signalType: r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis',
confidence: r.confidence
? Number(r.confidence)
: r.signal_type === 'recommendation_record'
? 0.8
: 1.0,
});
}
}
return out;
}
}
export interface PotentialGap {
primaryCode: string; // K08 / K02 / ...
code: string; // 实际触发码(K08 / IMPLANT_RECOMMENDED ...)
nameZh: string | null; // 诊断中文名(K03 拆 拔牙/修复 用)
tooth: string | null; // 剩余未治牙位(';' 分隔;全口码为 null)
daysSince: number;
signalType: 'diagnosis' | 'recommendation';
confidence: number; // 诊断 1.0 / 建议 0.8
}
interface RawGapRow {
code: string;
name_zh: string | null;
signal_type: string;
tooth: string | null;
confidence: string | null;
days_since: number;
}
import type { PersonaFeatureKey } from '@pac/types'; import type { PersonaFeatureKey } from '@pac/types';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import type { PotentialGap } from '../../clinical-gap/potential-treatment.selector';
/** /**
* Feature Extractor 接口 * Feature Extractor 接口
...@@ -44,6 +45,9 @@ export interface FeatureExtractorContext { ...@@ -44,6 +45,9 @@ export interface FeatureExtractorContext {
/// 全部 appointment_record(各 patient_facts.status,排 superseded)— 特别关注的爽约/迟到用。 /// 全部 appointment_record(各 patient_facts.status,排 superseded)— 特别关注的爽约/迟到用。
/// factsByType 只含 active/fulfilled,no_show/cancelled 预约不在其中,故单独加载。 /// factsByType 只含 active/fulfilled,no_show/cancelled 预约不在其中,故单独加载。
appointmentsAll: ActiveFact[]; appointmentsAll: ActiveFact[];
/// 潜在治疗 gap(诊断了/建议了但没启动对应治疗;牙位级)— 复用召回 gap 核心(共享单一源),
/// 去掉召回的时间门/合规门(常态属性)。potential_treatment 特征消费。
potentialGaps: PotentialGap[];
/// today 锚点(测试时可注入固定时间) /// today 锚点(测试时可注入固定时间)
now: Date; now: Date;
/// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。 /// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。
......
...@@ -15,6 +15,7 @@ import { DiscountAnchorFeatureExtractor } from './discount-anchor.feature'; ...@@ -15,6 +15,7 @@ import { DiscountAnchorFeatureExtractor } from './discount-anchor.feature';
import { SpecialAttentionFeatureExtractor } from './special-attention.feature'; import { SpecialAttentionFeatureExtractor } from './special-attention.feature';
import { TreatmentSensitivityFeatureExtractor } from './treatment-sensitivity.feature'; import { TreatmentSensitivityFeatureExtractor } from './treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './contraindication.feature'; import { ContraindicationFeatureExtractor } from './contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './potential-treatment.feature';
/** /**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。 * FeatureRegistry — 收集所有 PersonaFeature 提取器。
...@@ -42,9 +43,10 @@ export class FeatureRegistry { ...@@ -42,9 +43,10 @@ export class FeatureRegistry {
specialAttention: SpecialAttentionFeatureExtractor, specialAttention: SpecialAttentionFeatureExtractor,
treatmentSensitivity: TreatmentSensitivityFeatureExtractor, treatmentSensitivity: TreatmentSensitivityFeatureExtractor,
contraindication: ContraindicationFeatureExtractor, contraindication: ContraindicationFeatureExtractor,
potentialTreatment: PotentialTreatmentFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor, dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor, entitlement: EntitlementStatusFeatureExtractor,
) { ) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, dnc, entitlement]; this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, dnc, entitlement];
} }
} }
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import type { PotentialGap } from '../../clinical-gap/potential-treatment.selector';
/**
* potential_treatment 潜在治疗(C.1.1 转化机会)— 规则层,snapshot · 多标签
*
* 标签:潜在种植/正畸/早矫/修复/牙周/根管/拔牙/补牙。"有客观需求但未完成治疗"的转化机会。
*
* ⭐ 复用召回 gap 核心(ctx.potentialGaps,来自 PotentialTreatmentSelector → 共享 buildGapCore):
* = 召回挖的"诊断了/建议了但没启动对应治疗",**去掉召回的时间门**(常态属性)。
* 口径与召回完全一致(圈人群 = 召回候选),单一真理源。
*
* K 码 → 8 业务标签映射:
* 种植←K08(年龄>18)· 补牙←K02 · 根管←K04 · 牙周←K05/K06 ·
* 正畸←K07(>12且≤40)· 早矫←K07(3-12)·
* 修复←K03(默认)· 拔牙←K01 + K03(name 含 残根/残冠/无法保留/不能保留)
* K00 发育 / K09 囊肿 不在业务 8 标签 → 不出此标签(召回仍覆盖)。
*
* 业务 spec 对账:
* - 置信度(病历100%/影像AI 70-90%/客服勾选 50-70%)= PAC diagnosis=1.0 / recommendation=0.8(已建模)。
* - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。
* - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。
*/
@Injectable()
export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.POTENTIAL_TREATMENT;
private static readonly EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留'];
private static ageYears(birth: Date | null, now: Date): number | null {
if (!birth) return null;
let age = now.getFullYear() - birth.getFullYear();
const m = now.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
return age >= 0 && age <= 120 ? age : null;
}
/// 单个 gap → 业务标签 key(年龄/name 拆分);不命中 8 标签 → null
private classify(g: PotentialGap, age: number | null): { key: string; zh: string } | null {
switch (g.primaryCode) {
case 'K08':
return age !== null && age > 18 ? { key: 'implant', zh: '潜在种植' } : null;
case 'K02':
return { key: 'filling', zh: '潜在补牙' };
case 'K04':
return { key: 'endo', zh: '潜在根管' };
case 'K05':
case 'K06':
return { key: 'perio', zh: '潜在牙周' };
case 'K01':
return { key: 'extraction', zh: '潜在拔牙' };
case 'K03': {
const nm = g.nameZh ?? '';
const isExtract = PotentialTreatmentFeatureExtractor.EXTRACTION_NAME_KW.some((k) => nm.includes(k));
return isExtract ? { key: 'extraction', zh: '潜在拔牙' } : { key: 'restoration', zh: '潜在修复' };
}
case 'K07':
if (age === null) return null; // 年龄未知 → 无法分早矫/正畸
if (age >= 3 && age <= 12) return { key: 'early_ortho', zh: '潜在早矫' };
if (age > 12 && age <= 40) return { key: 'ortho', zh: '潜在正畸' };
return null; // >40 / <3 不召正畸
default:
return null; // K00 / K09 不在业务 8 标签
}
}
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const gaps = ctx.potentialGaps ?? [];
if (gaps.length === 0) return null;
const age = PotentialTreatmentFeatureExtractor.ageYears(ctx.patient.birthDate, ctx.now);
// 按业务标签聚合:teeth 并集 / daysSince 取最大(最早需求)/ confidence 取最大 / 来源
const agg = new Map<
string,
{ zh: string; teeth: Set<string>; daysSince: number; confidence: number; hasDx: boolean; hasRec: boolean }
>();
for (const g of gaps) {
const lbl = this.classify(g, age);
if (!lbl) continue;
const cur =
agg.get(lbl.key) ??
{ zh: lbl.zh, teeth: new Set<string>(), daysSince: 0, confidence: 0, hasDx: false, hasRec: false };
for (const t of (g.tooth ?? '').split(';').map((s) => s.trim()).filter(Boolean)) cur.teeth.add(t);
cur.daysSince = Math.max(cur.daysSince, g.daysSince);
cur.confidence = Math.max(cur.confidence, g.confidence);
if (g.signalType === 'diagnosis') cur.hasDx = true;
else cur.hasRec = true;
agg.set(lbl.key, cur);
}
if (agg.size === 0) return null;
// 稳定顺序:价值/紧迫高的在前
const ORDER = ['implant', 'ortho', 'early_ortho', 'restoration', 'perio', 'endo', 'extraction', 'filling'];
const keys = [...agg.keys()].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b));
const labels = keys.map((k) => agg.get(k)!.zh);
const detail = keys.map((k) => {
const v = agg.get(k)!;
const teeth = [...v.teeth].sort();
return {
key: k,
label: v.zh,
teeth,
daysSince: v.daysSince,
confidence: v.confidence,
source: v.hasDx && v.hasRec ? 'both' : v.hasRec ? 'recommendation' : 'diagnosis',
};
});
return {
key: this.key,
description: labels.join(' / '),
score: null,
data: { types: keys, labels, detail },
evidence: { factIds: [] },
};
}
}
...@@ -17,8 +17,11 @@ import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.featu ...@@ -17,8 +17,11 @@ import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.featu
import { SpecialAttentionFeatureExtractor } from './features/special-attention.feature'; import { SpecialAttentionFeatureExtractor } from './features/special-attention.feature';
import { TreatmentSensitivityFeatureExtractor } from './features/treatment-sensitivity.feature'; import { TreatmentSensitivityFeatureExtractor } from './features/treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './features/contraindication.feature'; import { ContraindicationFeatureExtractor } from './features/contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './features/potential-treatment.feature';
import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
@Module({ @Module({
imports: [ClinicalGapModule],
controllers: [PersonaController], controllers: [PersonaController],
providers: [ providers: [
PersonaService, PersonaService,
...@@ -37,6 +40,7 @@ import { ContraindicationFeatureExtractor } from './features/contraindication.fe ...@@ -37,6 +40,7 @@ import { ContraindicationFeatureExtractor } from './features/contraindication.fe
SpecialAttentionFeatureExtractor, SpecialAttentionFeatureExtractor,
TreatmentSensitivityFeatureExtractor, TreatmentSensitivityFeatureExtractor,
ContraindicationFeatureExtractor, ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor,
DoNotContactStatusFeatureExtractor, DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor, EntitlementStatusFeatureExtractor,
], ],
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { FeatureRegistry } from './features/feature.registry'; import { FeatureRegistry } from './features/feature.registry';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import type { import type {
ActiveFact, ActiveFact,
FeatureExtractorContext, FeatureExtractorContext,
...@@ -37,6 +38,7 @@ export class PersonaService { ...@@ -37,6 +38,7 @@ export class PersonaService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly registry: FeatureRegistry, private readonly registry: FeatureRegistry,
private readonly gapSelector: PotentialTreatmentSelector,
) {} ) {}
/** 租户内"累计净消费/患者"的 [p20,p40,p60,p80](cents)。缓存 30min;失败/无数据 → []。 */ /** 租户内"累计净消费/患者"的 [p20,p40,p60,p80](cents)。缓存 30min;失败/无数据 → []。 */
...@@ -200,6 +202,25 @@ export class PersonaService { ...@@ -200,6 +202,25 @@ export class PersonaService {
}, },
})) as ActiveFact[]; })) as ActiveFact[];
// 潜在治疗 gap(诊断了/建议了但没启动对应治疗)— 复用召回 gap 核心,去时间门(常态属性)。
// 按患者 active 诊断/建议码剪枝:无相关码 → 不查 SQL。
const activeCodes = new Set<string>();
for (const t of ['diagnosis_record', 'recommendation_record']) {
for (const f of factsByType.get(t) ?? []) {
const code = (f.content as Record<string, unknown> | null)?.code;
if (typeof code === 'string') activeCodes.add(code);
}
}
const potentialGaps = activeCodes.size
? await this.gapSelector.selectForPatient({
hostId: patient.hostId,
tenantId: patient.tenantId,
patientId: patient.id,
now,
activeCodes,
})
: [];
const latestTxn = await this.prisma.patientTransaction.findFirst({ const latestTxn = await this.prisma.patientTransaction.findFirst({
where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id }, where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
orderBy: { eventSeq: 'desc' }, orderBy: { eventSeq: 'desc' },
...@@ -233,6 +254,7 @@ export class PersonaService { ...@@ -233,6 +254,7 @@ export class PersonaService {
factsByType, factsByType,
relations, relations,
appointmentsAll, appointmentsAll,
potentialGaps,
now, now,
populationStats: { populationStats: {
monetaryQuantiles: await this.getMonetaryQuantiles( monetaryQuantiles: await this.getMonetaryQuantiles(
......
...@@ -18,7 +18,7 @@ import type { ...@@ -18,7 +18,7 @@ import type {
import { calcPriority } from '../priority-scorer'; import { calcPriority } from '../priority-scorer';
import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util'; import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装) // ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
import { buildGapCore, GAP_FLAGS_BY_PRIMARY } from '../../../clinical-gap/potential-treatment-gap.sql'; import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../../clinical-gap/potential-treatment-gap.sql';
/** /**
* 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写 * 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
...@@ -99,8 +99,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -99,8 +99,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
missing_tooth: { missing_tooth: {
base: 60, base: 60,
primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories) primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
dxCodes: ['K08'],
recCodes: ['IMPLANT_RECOMMENDED'],
label: '缺失牙未启动修复', label: '缺失牙未启动修复',
goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长', goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
// ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08'] // ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
...@@ -109,40 +107,30 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -109,40 +107,30 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
ortho_no_consult: { ortho_no_consult: {
base: 55, base: 55,
primaryCode: 'K07', primaryCode: 'K07',
dxCodes: ['K07'],
recCodes: ['ORTHO_CONSULT_RECOMMENDED'],
label: '错颌畸形未启动正畸', label: '错颌畸形未启动正畸',
goal: '邀约做正畸初诊评估,把握矫治窗口(尤其混合牙列期 8-12 岁 / 成人骨健全期)', goal: '邀约做正畸初诊评估,把握矫治窗口(尤其混合牙列期 8-12 岁 / 成人骨健全期)',
}, },
endo_no_rct: { endo_no_rct: {
base: 52, base: 52,
primaryCode: 'K04', primaryCode: 'K04',
dxCodes: ['K04'],
recCodes: ['RCT_RECOMMENDED'],
label: '牙髓炎/根尖周炎未做根管', label: '牙髓炎/根尖周炎未做根管',
goal: '邀约尽快做根管治疗,避免发展为根尖脓肿 / 拔牙', goal: '邀约尽快做根管治疗,避免发展为根尖脓肿 / 拔牙',
}, },
perio_no_srp: { perio_no_srp: {
base: 50, base: 50,
primaryCode: 'K05', primaryCode: 'K05',
dxCodes: ['K05'],
recCodes: ['SRP_RECOMMENDED'],
label: '牙周炎未做基础治疗', label: '牙周炎未做基础治疗',
goal: '邀约做牙周基础治疗(SRP / 翻瓣),控制炎症发展', goal: '邀约做牙周基础治疗(SRP / 翻瓣),控制炎症发展',
}, },
caries_no_filling: { caries_no_filling: {
base: 45, base: 45,
primaryCode: 'K02', primaryCode: 'K02',
dxCodes: ['K02'],
recCodes: ['FILLING_RECOMMENDED'],
label: '龋齿未做充填', label: '龋齿未做充填',
goal: '邀约尽快做龋齿充填,避免发展为牙髓炎', goal: '邀约尽快做龋齿充填,避免发展为牙髓炎',
}, },
hard_tissue_damage: { hard_tissue_damage: {
base: 35, base: 35,
primaryCode: 'K03', primaryCode: 'K03',
dxCodes: ['K03'],
recCodes: ['HARD_TISSUE_REPAIR_RECOMMENDED', 'CROWN_RECOMMENDED'],
// K03 复合:残根残冠→拔(surgical)/ 楔缺→充填(restorative)/ 大缺损→冠桥(prosthodontic) // K03 复合:残根残冠→拔(surgical)/ 楔缺→充填(restorative)/ 大缺损→冠桥(prosthodontic)
// 三类 actual 任一存在即排除(见 DxMap K03.categories)— 数据噪音可控 // 三类 actual 任一存在即排除(见 DxMap K03.categories)— 数据噪音可控
label: '牙体损伤未修复', label: '牙体损伤未修复',
...@@ -152,8 +140,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -152,8 +140,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
base: 35, base: 35,
primaryCode: 'K06', primaryCode: 'K06',
// K06 病种保留为 K06,不归 K05(不改诊断码原则)— 临床路径同 periodontic+surgical // K06 病种保留为 K06,不归 K05(不改诊断码原则)— 临床路径同 periodontic+surgical
dxCodes: ['K06'],
recCodes: ['GUM_TREATMENT_RECOMMENDED'],
label: '牙龈/牙槽嵴疾患未处置', label: '牙龈/牙槽嵴疾患未处置',
goal: '邀约处理牙龈/牙槽嵴问题(牙龈萎缩/增生/瘤/根分叉病变/系带异常 → 牙周治疗或手术)', goal: '邀约处理牙龈/牙槽嵴问题(牙龈萎缩/增生/瘤/根分叉病变/系带异常 → 牙周治疗或手术)',
}, },
...@@ -161,16 +147,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -161,16 +147,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
base: 30, base: 30,
primaryCode: 'K01', primaryCode: 'K01',
// 智齿阻生临床不强制拔除(无症状可观察);base 低让分数沉底,真急的患者靠 urgencyBonus 升上来 // 智齿阻生临床不强制拔除(无症状可观察);base 低让分数沉底,真急的患者靠 urgencyBonus 升上来
dxCodes: ['K01'],
recCodes: ['EXTRACTION_RECOMMENDED'],
label: '阻生牙未拔除', label: '阻生牙未拔除',
goal: '邀约评估阻生牙是否需拔除(智齿冠周炎反复 / 顶坏邻牙 / 正畸需要)', goal: '邀约评估阻生牙是否需拔除(智齿冠周炎反复 / 顶坏邻牙 / 正畸需要)',
}, },
jaw_cyst: { jaw_cyst: {
base: 50, base: 50,
primaryCode: 'K09', primaryCode: 'K09',
dxCodes: ['K09'],
recCodes: ['JAW_CYST_REMOVAL_RECOMMENDED'],
// 颌骨囊肿临床必处理(不摘除会继续扩大压迫邻牙/神经),base 中等偏高 // 颌骨囊肿临床必处理(不摘除会继续扩大压迫邻牙/神经),base 中等偏高
label: '颌骨囊肿未处理', label: '颌骨囊肿未处理',
goal: '邀约尽快做颌骨囊肿摘除术,避免囊肿扩大压迫邻牙 / 神经', goal: '邀约尽快做颌骨囊肿摘除术,避免囊肿扩大压迫邻牙 / 神经',
...@@ -180,8 +162,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -180,8 +162,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
primaryCode: 'K00', primaryCode: 'K00',
// K00 真病种(乳牙滞留 / 多生牙 / 先天缺失 / 釉质发育不全 / 萌出障碍)— 不含"乳牙列""混合牙列"等正常态(yaml 不映射) // K00 真病种(乳牙滞留 / 多生牙 / 先天缺失 / 釉质发育不全 / 萌出障碍)— 不含"乳牙列""混合牙列"等正常态(yaml 不映射)
// 儿童居多,base 最低;windowDays 365 给足观察期 // 儿童居多,base 最低;windowDays 365 给足观察期
dxCodes: ['K00'],
recCodes: ['ERUPTION_INTERVENTION_RECOMMENDED'],
label: '牙发育/萌出异常未处置', label: '牙发育/萌出异常未处置',
goal: '邀约评估处置(乳牙滞留/多生牙拔除 · 先天缺失修复 · 釉质发育不全美容修复 · 萌出障碍助萌)', goal: '邀约评估处置(乳牙滞留/多生牙拔除 · 先天缺失修复 · 釉质发育不全美容修复 · 萌出障碍助萌)',
}, },
...@@ -222,8 +202,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -222,8 +202,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
} }
const start = rule.cooldownDays; const start = rule.cooldownDays;
const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays]; const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
const dxCodes = cfg.dxCodes as readonly string[]; // 码分组 → 单一真理源 GAP_PRIMARY_GROUPS(召回 + 潜在治疗画像共用,不在 SUB_SCENARIOS 内联)
const recCodes = cfg.recCodes as readonly string[]; const grp = GAP_PRIMARY_GROUPS[cfg.primaryCode] ?? { dxCodes: [], recCodes: [] };
const dxCodes = grp.dxCodes as readonly string[];
const recCodes = grp.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes]; const allCodes = [...dxCodes, ...recCodes];
// §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用) // §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
const cfgFlags = GAP_FLAGS_BY_PRIMARY[cfg.primaryCode] ?? {}; const cfgFlags = GAP_FLAGS_BY_PRIMARY[cfg.primaryCode] ?? {};
......
...@@ -67,7 +67,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> ...@@ -67,7 +67,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.ENGAGEMENT]: { label: '触达活跃度', tone: 'sky' }, [PersonaFeatureKey.ENGAGEMENT]: { label: '触达活跃度', tone: 'sky' },
[PersonaFeatureKey.PATIENT_SEGMENT]: { label: '人群细分', tone: 'slate' }, [PersonaFeatureKey.PATIENT_SEGMENT]: { label: '人群细分', tone: 'slate' },
[PersonaFeatureKey.COMPLAINT_HISTORY]: { label: '投诉历史', tone: 'rose' }, [PersonaFeatureKey.COMPLAINT_HISTORY]: { label: '投诉历史', tone: 'rose' },
[PersonaFeatureKey.POTENTIAL_TREATMENT]: { label: '待治疗机会', tone: 'amber' }, [PersonaFeatureKey.POTENTIAL_TREATMENT]: { label: '潜在治疗', tone: 'amber' },
[PersonaFeatureKey.COOPERATION_ATTITUDE]: { label: '配合态度', tone: 'emerald' }, [PersonaFeatureKey.COOPERATION_ATTITUDE]: { label: '配合态度', tone: 'emerald' },
[PersonaFeatureKey.PRIMARY_CONTACT_RELATION]: { label: '主要联系人', tone: 'slate' }, [PersonaFeatureKey.PRIMARY_CONTACT_RELATION]: { label: '主要联系人', tone: 'slate' },
}; };
......
...@@ -289,6 +289,25 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -289,6 +289,25 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
version: 1, version: 1,
}, },
// ── C.1.1 潜在治疗(转化机会;复用召回 gap 核心)──
potential_treatment: {
key: 'potential_treatment',
nameZh: '潜在治疗',
tier: 'rule',
timeSemantics: 'snapshot', // 常态:有客观需求未完成(随诊断/治疗变化重算)
labelValues: ['潜在种植', '潜在正畸', '潜在早矫', '潜在修复', '潜在牙周', '潜在根管', '潜在拔牙', '潜在补牙'],
dataSource: '复用召回 gap 核心(PotentialTreatmentSelector → 共享 buildGapCore):诊断/建议 fact 未启动对应治疗,去召回时间门',
dataFields: ['diagnosis_record', 'recommendation_record', 'treatment_record'],
meaning: '客户"有客观需求但未完成治疗"的转化机会;以电子病历诊断/处置为核心,减少结算维度依赖。允许并列',
algorithm: [
'= 召回挖的"诊断了/建议了但没启动对应治疗"(牙位级 gap),去掉召回时间门(cooldown/预约/到诊)→ 常态属性。',
'K 码→8 标签:种植←K08(>18)/补牙←K02/根管←K04/牙周←K05,K06/正畸←K07(>12≤40)/早矫←K07(3-12)/',
'修复←K03(默认)/拔牙←K01+K03(残根残冠)。置信度=诊断1.0/建议0.8。⚠️非已丢单(sales_chance)PAC未摄入,省略。',
].join('\n'),
owner: 'pac-algo',
version: 1,
},
// ── D.2.4 禁忌标签(v1 仅种植年龄;余 Layer C)── // ── D.2.4 禁忌标签(v1 仅种植年龄;余 Layer C)──
contraindication: { contraindication: {
key: 'contraindication', key: 'contraindication',
......
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