Commit 158facda by luoqi

feat(recall/script): 乳牙口径 + 影像粒度对齐 + 监护人 + 主治医生A + 稳健档铺路

召回口径(乳牙):
- missing_tooth 排除乳牙(FDI 51-85 + 象限 1A-4E)→ 乳牙缺失不进种植/修复召回
- 乳牙龋齿目标只显「充填」(去嵌体);前后端 + reason 文本统一走
  treatmentCategoryNameZhForTeeth(@pac/types 共享 helper)
- recall-oracle 同步乳牙规则(独立内联,守对抗纪律)

摄入:
- 影像诊断粒度对齐医生诊断:列内逐牙拆分 → 牙位 ; 拼接(一码一 fact)
- 回访 treatment_items + treatment_items_two 合并为单字段「大类·子项」
  (transforms 合并 + normalizeMergedItems 空值归 null)

详情页:
- 监护人 TEST-ONLY:儿童/老人触达 fallback,手机号旁标注(身份 姓名),
  去掉关键事实「联系人」行
- 主治医生改口径 A:触发诊断的医生(影像源→「影像AI」兜底)
- 病历快读:影像 AI 诊断加「影像」角标
- 召回理由列表页 +N 加 hover 展示其余 reason(对齐详情页)

AI 话术(稳健档铺路):
- DraftPlanScriptInput → ScriptContext(tier-agnostic,留 alias)
- 剔除治疗链字段(chain-composer 将废弃):treatmentChainSummary 删、
  ongoingChains → recentTreatments(只读 treatment_record「做过什么」)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent d4865450
...@@ -17,6 +17,6 @@ field_mapping: ...@@ -17,6 +17,6 @@ field_mapping:
type: return_visit_type_name type: return_visit_type_name
status: return_visit_status_name status: return_visit_status_name
taskStatus: task_status_name taskStatus: task_status_name
treatmentItems: treatment_items treatmentItems: treatment_items_full # 大类·子项 合并(transforms H);单字段展示,不镜像 host 两列
followContent: follow_content followContent: follow_content
result: return_visit_result result: return_visit_result
...@@ -190,7 +190,10 @@ sql_source: ...@@ -190,7 +190,10 @@ sql_source:
concat(emr_id, '|imgai|', code, '|', tooth) AS diag_external_id concat(emr_id, '|imgai|', code, '|', tooth) AS diag_external_id
FROM ( FROM (
SELECT patient_id, brand, organization_id, emr_id, rq, cm.1 AS code, 'image_ai' AS code_source, SELECT patient_id, brand, organization_id, emr_id, rq, cm.1 AS code, 'image_ai' AS code_source,
arrayJoin(splitByChar(',', replaceRegexpAll(cm.2, '[\[\] '']', ''))) AS tooth -- ⭐ 牙位整组保留一行(用 ; 拼接),对齐医生诊断粒度(一个诊断码一条 fact,非逐牙拆行)。
-- '['15','25']' → '15;25';召回侧 union-find 仍按牙逐颗扣除/聚类。
-- 注:同 K 码跨影像列(K01 阻生+埋伏 / K03 三列)仍各列一条 fact,union-find 会重聚类。
replaceRegexpAll(replaceRegexpAll(cm.2, '[\[\] '']', ''), ',', ';') AS tooth
FROM ( FROM (
SELECT c.patient_id AS patient_id, c.brand AS brand, po.org AS organization_id, SELECT c.patient_id AS patient_id, c.brand AS brand, po.org AS organization_id,
ia.emr_id AS emr_id, ia.rq AS rq, ia.emr_id AS emr_id, ia.rq AS rq,
...@@ -221,7 +224,7 @@ sql_source: ...@@ -221,7 +224,7 @@ sql_source:
fact_returnvisit_out: | fact_returnvisit_out: |
SELECT id, customer_id AS patient_id, brand, organization_id, task_date, SELECT id, customer_id AS patient_id, brand, organization_id, task_date,
return_visit_type_name, return_visit_status_name, task_status_name, return_visit_type_name, return_visit_status_name, task_status_name,
treatment_items, follow_content, return_visit_result treatment_items, treatment_items_two, follow_content, return_visit_result
FROM dw_group.fact_returnvisit_out FROM dw_group.fact_returnvisit_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out) WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
...@@ -838,6 +841,17 @@ transforms: ...@@ -838,6 +841,17 @@ transforms:
op: concat op: concat
parts: ['${patient_id}', '|ref|', '${recommend_id}'] parts: ['${patient_id}', '|ref|', '${recommend_id}']
# ── H. fact_returnvisit_out:治疗项 大类(treatment_items)·子项(treatment_items_two)合一 ──
# host 把治疗项拆成两列(大类「外科」+ 子项「简单拔牙」),实测 5 试点两列总是成对(只填一列=0)。
# 回访是纯展示,PAC 只存一个可读字段 treatmentItems —— "两列"的 host 怪癖在此合并,PAC schema 不变。
- kind: derive
input: fact_returnvisit_out
output: fact_returnvisit_out
fields:
treatment_items_full:
op: concat
parts: ['${treatment_items}', ' · ', '${treatment_items_two}']
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# PAC 写的 assembler 配置(每个 canonical resource 一份) # PAC 写的 assembler 配置(每个 canonical resource 一份)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
......
...@@ -7,7 +7,10 @@ ...@@ -7,7 +7,10 @@
* 装配责任在 orchestrator,见 plan-script.orchestrator.ts。 * 装配责任在 orchestrator,见 plan-script.orchestrator.ts。
*/ */
export interface DraftPlanScriptInput { // ⭐ 话术上下文(tier-agnostic):稳健/标准/深度三档共用同一份 ScriptContext;
// 也被实时教练复用(realtime-coach-context)。脊柱 = AiCallRunner,策略 = 各档 AiCall。
// 设计见 docs/algorithm/ai-script-generation.md。
export interface ScriptContext {
/** 患者信息(已脱敏:nameMasked, phone 不传) */ /** 患者信息(已脱敏:nameMasked, phone 不传) */
patient: { patient: {
nameMasked: string; nameMasked: string;
...@@ -68,13 +71,13 @@ export interface DraftPlanScriptInput { ...@@ -68,13 +71,13 @@ export interface DraftPlanScriptInput {
lastVisitSummary: string | null; // 上次到店做了什么(一句话) lastVisitSummary: string | null; // 上次到店做了什么(一句话)
/** 上次就诊主诉(emr_record.illness_desc 原文,自由文本直喂 LLM 当上下文;可空) */ /** 上次就诊主诉(emr_record.illness_desc 原文,自由文本直喂 LLM 当上下文;可空) */
lastChiefComplaint?: string | null; lastChiefComplaint?: string | null;
pendingTreatments: string[]; // 待做治疗(简短描述,牙位已转患者俗称,如"缺失牙(下门牙)") pendingTreatments: string[]; // 待做治疗(reason 派生,即"未启动治疗";牙位已转俗称)
treatmentChainSummary: string | null; // 治疗链当前阶段一句话
/** 主治医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */ /** 主治医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */
primaryDoctorName: string | null; primaryDoctorName: string | null;
/** ⭐ 正在进行的治疗链摘要(每条一句:"牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27") /** ⭐ 近期做过的治疗(每条一句:"做过牙周 · 上次龈上洁治 · 吴医生 · 2024.04")
* LLM 用于:① 不重复邀约已在管的治疗 ② 引用历史治疗显出"诊所记得 ta" */ * 来源 treatment_record(非治疗链);LLM 用于:① 不重复邀约已做过的 ② 引用历史显"诊所记得 ta"。
ongoingChains: string[]; * ⚠️ 治疗链(chain-composer)已废弃 → 这里只读"做过什么治疗",不含链/阶段概念。 */
recentTreatments: string[];
/** ⭐ 已做治疗总次数(信任锚);LLM 用于:老客可以更家常,新客需自报家门更详细 */ /** ⭐ 已做治疗总次数(信任锚);LLM 用于:老客可以更家常,新客需自报家门更详细 */
completedTreatmentCount: number; completedTreatmentCount: number;
}; };
...@@ -94,6 +97,9 @@ export interface DraftPlanScriptInput { ...@@ -94,6 +97,9 @@ export interface DraftPlanScriptInput {
* - 流式仍然可行(generateObject partial 阶段每段 string 逐字符流出) * - 流式仍然可行(generateObject partial 阶段每段 string 逐字符流出)
* - Flash 模型友好 — schema 字段少,LLM 单字段长 markdown 比多字段嵌套对象更稳 * - Flash 模型友好 — schema 字段少,LLM 单字段长 markdown 比多字段嵌套对象更稳
*/ */
/** @deprecated 用 ScriptContext —— 稳健档输入 = 通用 ScriptContext(保留别名,兼容现有引用) */
export type DraftPlanScriptInput = ScriptContext;
export interface DraftPlanScriptOutput { export interface DraftPlanScriptOutput {
/** 整体语气标签(给客服参考) */ /** 整体语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent'; tone: 'warm' | 'professional' | 'urgent';
......
...@@ -433,11 +433,10 @@ export class PlanScriptOrchestrator { ...@@ -433,11 +433,10 @@ export class PlanScriptOrchestrator {
lastVisitSummary: summarizeLastVisit(latestEnc), lastVisitSummary: summarizeLastVisit(latestEnc),
lastChiefComplaint, lastChiefComplaint,
// pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗" // pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗"
// 旧版用 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07,导致阻生牙/牙体损伤等场景空 // reasons 是 SQL 算出的权威集(旧版 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07)
// reasons 是 SQL 算出的权威集,数据已对齐 chain-composer
pendingTreatments: extractPendingFromReasons(plan.reasons), pendingTreatments: extractPendingFromReasons(plan.reasons),
treatmentChainSummary: summarizeChain(visitFacts), // ⚠️ 治疗链(chain-composer)已废弃 → 不再给"链阶段/在管"摘要,只给"做过什么治疗"
ongoingChains: summarizeOngoingChains(facts), recentTreatments: summarizeRecentTreatments(facts),
completedTreatmentCount: countCompletedTreatments(facts), completedTreatmentCount: countCompletedTreatments(facts),
primaryDoctorName: extractPrimaryDoctor(facts), primaryDoctorName: extractPrimaryDoctor(facts),
}, },
...@@ -751,26 +750,15 @@ function extractPrimaryDoctor(facts: FactRow[]): string | null { ...@@ -751,26 +750,15 @@ function extractPrimaryDoctor(facts: FactRow[]): string | null {
return idToName.get(topId) ?? null; return idToName.get(topId) ?? null;
} }
function summarizeChain(encounters: FactRow[]): string | null {
if (encounters.length === 0) return null;
// v2.1:encounter_record 只元数据,不再有 treatment_stage / treatment_category 嵌套
return ` ${encounters.length} 次就诊记录`;
}
/** /**
* 正在进行的治疗链摘要(每个 category 一句): * 近期做过的治疗(每个 category 一句):"做过牙周 · 上次龈上洁治 · 吴医生 · 2024.04"
* "牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27"
*
* 算法:
* - 按 category 分组 actual treatments(排除 review)
* - 每组取最新 1 条(occurredAt 最大)作 cluster 代表
* - 按最新时间 DESC 排,取前 4 条
* *
* 给 LLM 的用处: * ⚠️ 治疗链(chain-composer)已废弃 → 这里**只读 treatment_record**("做过什么"),
* ① 避免话术重复邀约已经在管的治疗(牙周已在做就不该再说"建议来做牙周") * 不含链/阶段/在管概念。
* ② 引用历史治疗(吴医生 4 月给做过洁牙)体现"诊所记得 ta" * 算法:按 category 分组 actual treatments(排除 review),每组取最新 1 条,时间 DESC 取前 4。
* 给 LLM 用处:① 不重复邀约已做过的治疗 ② 引用历史治疗体现"诊所记得 ta"。
*/ */
function summarizeOngoingChains(facts: FactRow[]): string[] { function summarizeRecentTreatments(facts: FactRow[]): string[] {
const latestByCategory = new Map<string, FactRow>(); const latestByCategory = new Map<string, FactRow>();
for (const f of facts) { for (const f of facts) {
if (f.type !== 'treatment_record' || f.kind !== 'actual') continue; if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
...@@ -793,7 +781,7 @@ function summarizeOngoingChains(facts: FactRow[]): string[] { ...@@ -793,7 +781,7 @@ function summarizeOngoingChains(facts: FactRow[]): string[] {
const when = latest.occurredAt const when = latest.occurredAt
? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}` ? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}`
: '近期'; : '近期';
const bits = [`${treatmentCategoryNameZh(cat)}在管`]; const bits = [`做过${treatmentCategoryNameZh(cat)}`];
if (subtype) bits.push(`上次 ${subtype}`); if (subtype) bits.push(`上次 ${subtype}`);
if (doctor) bits.push(`${doctor}医生`); if (doctor) bits.push(`${doctor}医生`);
bits.push(when); bits.push(when);
......
...@@ -296,6 +296,7 @@ function serializePatient(patient: { ...@@ -296,6 +296,7 @@ function serializePatient(patient: {
function serializeProfile( function serializeProfile(
patient: { patient: {
birthDate: Date | null;
profile: { profile: {
doNotContact: boolean; doNotContact: boolean;
deceased: boolean; deceased: boolean;
...@@ -352,6 +353,11 @@ function serializeProfile( ...@@ -352,6 +353,11 @@ function serializeProfile(
})) }))
.sort((a, b) => Number(b.linked) - Number(a.linked)); .sort((a, b) => Number(b.linked) - Number(a.linked));
// ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人手机 fallback(详见 pickGuardianTestOnly 注释)。
// 真实手机号宿主未提供(现随机生成),此判定仅用于验证"本人无手机→打监护人"的 UX。
// 上线前必须移除/重做:① 宿主提供真实手机 + 专门的监护人/紧急联系人字段;② 确认业务规则。
const guardian = pickGuardianTestOnly(patient.birthDate ? calcAge(patient.birthDate) : null, contacts);
return { return {
doNotContact: patient.profile.doNotContact, doNotContact: patient.profile.doNotContact,
deceased: patient.profile.deceased, deceased: patient.profile.deceased,
...@@ -365,7 +371,44 @@ function serializeProfile( ...@@ -365,7 +371,44 @@ function serializeProfile(
daysSinceLatestVisit, daysSinceLatestVisit,
doctors, doctors,
contacts, contacts,
guardian, // ⚠️ TEST ONLY
};
}
/// ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人判定 — 真实手机号到位前仅用于演示 fallback UX,勿当正式特性。
/// 目的:本人(儿童/老人)往往无自己手机,触达要打监护人。从「已关联(关系人已建档,
/// 故有姓名+电话)」联系人里按人群挑监护人:
/// · 儿童(<18):父母 > 祖辈 · 老人(>=60):配偶 > 子女 > 孙辈
/// · 成人不判(本人自有手机)
/// 注:当前 phone 是随机生成的假数据,此处只演示"手机号旁标注(身份 姓名)"的交互。
function pickGuardianTestOnly(
age: number | null,
contacts: Array<{
relationship: string;
relationshipLabel: string;
name: string | null;
phone: string | null;
linked: boolean;
}>,
): { relationship: string; relationshipLabel: string; name: string | null; phone: string | null } | null {
if (age == null) return null;
let priority: string[];
if (age < 18) priority = ['mother', 'father', 'grandparent', 'guardian'];
else if (age >= 60) priority = ['spouse', 'child', 'grandchild'];
else return null;
const linked = contacts.filter((c) => c.linked && c.name);
for (const rel of priority) {
const hit = linked.find((c) => c.relationship === rel);
if (hit) {
return {
relationship: hit.relationship,
relationshipLabel: hit.relationshipLabel,
name: hit.name,
phone: hit.phone,
}; };
}
}
return null;
} }
/// 关系枚举 → 中文展示标签(本人视角:对方是本人的 X) /// 关系枚举 → 中文展示标签(本人视角:对方是本人的 X)
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
STRUCTURAL_DX_CODE_LIST, STRUCTURAL_DX_CODE_LIST,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZh,
treatmentCategoryNameZhForTeeth,
} from '@pac/types'; } from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import type { import type {
...@@ -111,6 +112,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -111,6 +112,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
recCodes: ['IMPLANT_RECOMMENDED'], recCodes: ['IMPLANT_RECOMMENDED'],
label: '缺失牙未启动修复', label: '缺失牙未启动修复',
goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长', goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
// ⭐ 乳牙不进种植/修复召回:乳牙缺失/晚萌是生理现象,恒牙会替换,不可能种植/做冠桥。
// 只对本子场景(缺牙→成人修复)生效;龋齿(乳牙也补)/正畸/萌出 不受影响。
// 乳牙判定:FDI 51-85 + 宿主象限记法 1A-4E(见 toothArrSql dropDeciduous)。
excludeDeciduous: true,
}, },
ortho_no_consult: { ortho_no_consult: {
base: 55, base: 55,
...@@ -271,6 +276,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -271,6 +276,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const sigToothExpr = rule.wholeMouth const sigToothExpr = rule.wholeMouth
? Prisma.sql`NULL::text` ? Prisma.sql`NULL::text`
: Prisma.sql`sig.content->>'tooth_position'`; : Prisma.sql`sig.content->>'tooth_position'`;
// 是否全口码(K05/K07)— 编译期已知,显式传入。
// ⚠️ 不能再用 `cardinality(sig_teeth)=0` 推断全口:非全口码若牙位全是非法(裸象限数字
// "1;2" 笔误,被 toothArrSql 过滤成空)也会 cardinality=0 → 误入全口分支 → 输出原始裸数字
// (phantom 召回 endo_no_rct@1;)。改用 rule.wholeMouth:非全口码牙位全非法 → remaining 空 → 不召。
const wholeMouthFlag = rule.wholeMouth ? Prisma.sql`TRUE` : Prisma.sql`FALSE`;
// ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制) // ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制)
// 牙位字符串 → 牙位数组,**只剥"空格+牙面字母"后缀,保留 FDI 数字 & Palmer 乳牙字母**。 // 牙位字符串 → 牙位数组,**只剥"空格+牙面字母"后缀,保留 FDI 数字 & Palmer 乳牙字母**。
...@@ -284,12 +294,19 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -284,12 +294,19 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⭐ 合法牙过滤(口径同 util.isValidToothToken):只留【数字开头 + ≥2 字符】的 token。 // ⭐ 合法牙过滤(口径同 util.isValidToothToken):只留【数字开头 + ≥2 字符】的 token。
// 裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")非法 → 丢,否则造幽灵召回 caries@1。 // 裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")非法 → 丢,否则造幽灵召回 caries@1。
// `x ~ '^[0-9].'`:数字开头且其后还有字符(裸 "1" 仅 1 字符 → 不匹配 → 滤掉)。 // `x ~ '^[0-9].'`:数字开头且其后还有字符(裸 "1" 仅 1 字符 → 不匹配 → 滤掉)。
const toothArrSql = (expr: Prisma.Sql) => // ⭐ dropDeciduous(仅 missing_tooth 信号牙位用):剔除乳牙 —— 乳牙缺失/损伤不进种植/修复召回。
Prisma.sql`(SELECT COALESCE(array_agg(x), ARRAY[]::text[]) FROM unnest(array_remove(string_to_array( // 乳牙 = FDI 51-85(`^[5-8][1-5]$`)+ 宿主象限记法 1A-4E(`^[1-4][A-Ea-e]$`)。
// 恒牙 FDI 11-48(象限 1-4 + 1-8 数字)不匹配这两条,不误伤。全乳牙 → st 空 → 不召(同空数组闸)。
const toothArrSql = (expr: Prisma.Sql, dropDeciduous = false) => {
const deciduousFilter = dropDeciduous
? Prisma.sql`AND x !~ '^[5-8][1-5]$' AND x !~ '^[1-4][A-Ea-e]$'`
: Prisma.empty;
return Prisma.sql`(SELECT COALESCE(array_agg(x), ARRAY[]::text[]) FROM unnest(array_remove(string_to_array(
regexp_replace( regexp_replace(
regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'), regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'),
'[[:space:]]+', '', 'g'), '[[:space:]]+', '', 'g'),
';'), '')) AS x WHERE x ~ '^[0-9].')`; ';'), '')) AS x WHERE x ~ '^[0-9].' ${deciduousFilter})`;
};
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。 // 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
// ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor): // ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
// 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)— // 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)—
...@@ -412,7 +429,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -412,7 +429,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.content->>'code' AS signal_code, sig.content->>'code' AS signal_code,
-- ⭐ 牙位级相减:有牙位信号 → 输出"剩余未治牙位"(诊断牙位 − 已治牙位); -- ⭐ 牙位级相减:有牙位信号 → 输出"剩余未治牙位"(诊断牙位 − 已治牙位);
-- 全口信号(sig_teeth 空)→ 原样(NULL,下游归 whole cluster) -- 全口信号(sig_teeth 空)→ 原样(NULL,下游归 whole cluster)
CASE WHEN cardinality(lat.sig_teeth) = 0 CASE WHEN ${wholeMouthFlag}
THEN ${sigToothExpr} THEN ${sigToothExpr}
ELSE array_to_string(lat.remaining_teeth, ';') END AS tooth, ELSE array_to_string(lat.remaining_teeth, ';') END AS tooth,
sig.content->>'extracted_by' AS extracted_by, sig.content->>'extracted_by' AS extracted_by,
...@@ -429,7 +446,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -429,7 +446,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
(SELECT COALESCE(array_agg(x), ARRAY[]::text[]) (SELECT COALESCE(array_agg(x), ARRAY[]::text[])
FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth
FROM ( FROM (
SELECT COALESCE(${toothArrSql(sigToothExpr)}, ARRAY[]::text[]) AS st, SELECT COALESCE(${toothArrSql(sigToothExpr, (cfg as { excludeDeciduous?: boolean }).excludeDeciduous === true)}, ARRAY[]::text[]) AS st,
${resolvedTeethSql} AS rt ${resolvedTeethSql} AS rt
) base ) base
) lat ON true ) lat ON true
...@@ -452,7 +469,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -452,7 +469,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 有牙位信号 → "剩余未治牙位"非空才召(⑤a 同类 / ⑤c 拔除 / ⑤e 替代 已折进 resolved_teeth 按牙相减) -- 有牙位信号 → "剩余未治牙位"非空才召(⑤a 同类 / ⑤c 拔除 / ⑤e 替代 已折进 resolved_teeth 按牙相减)
-- 例 龚靖舜 浅龋@16;26;46;36 只补了 16 → remaining={26,36,46} 非空 → 仍召这三颗 -- 例 龚靖舜 浅龋@16;26;46;36 只补了 16 → remaining={26,36,46} 非空 → 仍召这三颗
AND ( AND (
CASE WHEN cardinality(lat.sig_teeth) = 0 THEN CASE WHEN ${wholeMouthFlag} THEN
NOT EXISTS ( NOT EXISTS (
SELECT 1 FROM patient_facts tx SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id WHERE tx.patient_id = p.id
...@@ -554,7 +571,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -554,7 +571,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
patientId, patientId,
patientExternalId: r.patient_external_id, patientExternalId: r.patient_external_id,
// reason 文本兜底(AI prompt / 调试用,前端不依赖此字段) // reason 文本兜底(AI prompt / 调试用,前端不依赖此字段)
reason: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${expectedCats.map(treatmentCategoryNameZh).join(' / ')}`, // 乳牙 restorative 只显「充填」(去嵌体),跟前端 ReasonLine/目标 tag 同口径(treatmentCategoryNameZhForTeeth)
reason: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${expectedCats.map((c) => treatmentCategoryNameZhForTeeth(c, r.tooth)).join(' / ')}`,
priorityScore: score, priorityScore: score,
goal: cfg.goal, goal: cfg.goal,
recommendedRole: 'staff', recommendedRole: 'staff',
......
...@@ -60,7 +60,7 @@ export class RealtimeCoachContextService { ...@@ -60,7 +60,7 @@ export class RealtimeCoachContextService {
`- 上次就诊发现 / 待处理:${cc.pendingTreatments.join('、') || input.plan.reasons[0]?.reason || '(无)'}`, `- 上次就诊发现 / 待处理:${cc.pendingTreatments.join('、') || input.plan.reasons[0]?.reason || '(无)'}`,
`- 主治医生:${cc.primaryDoctorName ?? '(未知)'} · 距上次到店:${cc.daysSinceLastVisit ?? '未知'} `, `- 主治医生:${cc.primaryDoctorName ?? '(未知)'} · 距上次到店:${cc.daysSinceLastVisit ?? '未知'} `,
cc.lastVisitSummary ? `- 上次到店:${cc.lastVisitSummary}` : '', cc.lastVisitSummary ? `- 上次到店:${cc.lastVisitSummary}` : '',
cc.ongoingChains.length ? `- 在管治疗:${cc.ongoingChains.join(' / ')}` : '', cc.recentTreatments.length ? `- 近期做过的治疗:${cc.recentTreatments.join(' / ')}` : '',
`- 老客/新客:已完成 ${cc.completedTreatmentCount} 次治疗`, `- 老客/新客:已完成 ${cc.completedTreatmentCount} 次治疗`,
] ]
.filter(Boolean) .filter(Boolean)
......
...@@ -991,7 +991,8 @@ export class ColdImportService { ...@@ -991,7 +991,8 @@ export class ColdImportService {
type: (c.type as string | undefined) ?? null, type: (c.type as string | undefined) ?? null,
status: (c.status as string | undefined) ?? null, status: (c.status as string | undefined) ?? null,
taskStatus: (c.taskStatus as string | undefined) ?? null, taskStatus: (c.taskStatus as string | undefined) ?? null,
treatmentItems: (c.treatmentItems as string | undefined) ?? null, // 大类·子项 合并值(transforms H);两列皆空时合并出来只剩 " · " 分隔符 → 归一为 null(回访常无治疗项)
treatmentItems: normalizeMergedItems(c.treatmentItems as string | undefined),
followContent: (c.followContent as string | undefined) ?? null, followContent: (c.followContent as string | undefined) ?? null,
result: (c.result as string | undefined) ?? null, result: (c.result as string | undefined) ?? null,
}; };
...@@ -1696,6 +1697,15 @@ function synthesizeDemoPhone(externalId: string): string { ...@@ -1696,6 +1697,15 @@ function synthesizeDemoPhone(externalId: string): string {
return `138${last8}`; return `138${last8}`;
} }
/// 回访治疗项「大类 · 子项」合并值归一(transforms H 的 concat)。
/// 两列皆空 → 合并出来只剩 " · "(纯分隔符/空白)→ 视为无治疗项,归 null;有真实内容则 trim 返回。
function normalizeMergedItems(v: string | undefined): string | null {
if (!v) return null;
const trimmed = v.trim();
// 去掉所有分隔符/空白后仍有内容才保留(避免 "·" / " · " / "" 这类空壳入库)
return trimmed.replace(/[·\s/]+/g, '') ? trimmed : null;
}
export interface ImportRunResult { export interface ImportRunResult {
runId: string; runId: string;
hostId: string; hostId: string;
......
...@@ -62,6 +62,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -62,6 +62,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
lastVisit: real.profile?.lastVisit ?? '', lastVisit: real.profile?.lastVisit ?? '',
// 联系人/亲属(本人视角)— 关系人姓名/电话从 relatedPatient 现取(后端 serializeProfile) // 联系人/亲属(本人视角)— 关系人姓名/电话从 relatedPatient 现取(后端 serializeProfile)
contacts: real.profile?.contacts ?? [], contacts: real.profile?.contacts ?? [],
// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示
guardian: real.profile?.guardian ?? null,
}, },
}; };
......
...@@ -262,6 +262,8 @@ function EmrSection({ ...@@ -262,6 +262,8 @@ function EmrSection({
// 医生原始诊断文字(name_zh)— 与标准名不同才显示(如"无功能牙"→标准名"牙列丢失/缺牙") // 医生原始诊断文字(name_zh)— 与标准名不同才显示(如"无功能牙"→标准名"牙列丢失/缺牙")
const rawName = String(dc.name_zh ?? dc.name ?? '').trim(); const rawName = String(dc.name_zh ?? dc.name ?? '').trim();
const showRaw = rawName && rawName !== badgeText; const showRaw = rawName && rawName !== badgeText;
// 影像 AI 抽出的诊断(非医生手写)— 标记区分,客服知道这是影像识别的信号
const fromImage = String(dc.code_source ?? '') === 'image_ai';
// 跟"本次治疗"同结构:[标签] 原文(深色) · 牙位 // 跟"本次治疗"同结构:[标签] 原文(深色) · 牙位
return ( return (
<li key={dx.id} className="text-[12px] text-slate-700 leading-relaxed"> <li key={dx.id} className="text-[12px] text-slate-700 leading-relaxed">
...@@ -270,6 +272,11 @@ function EmrSection({ ...@@ -270,6 +272,11 @@ function EmrSection({
</span> </span>
{showRaw && <span className="font-medium">{rawName}</span>} {showRaw && <span className="font-medium">{rawName}</span>}
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth, 999)}</span>} {tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth, 999)}</span>}
{fromImage && (
<span className="ml-1.5 px-1 py-px bg-sky-50 text-sky-600 rounded text-[10px] font-medium align-middle">
影像
</span>
)}
</li> </li>
); );
})} })}
......
...@@ -67,6 +67,13 @@ export const mockPatient = { ...@@ -67,6 +67,13 @@ export const mockPatient = {
phone: string | null; phone: string | null;
linked: boolean; linked: boolean;
}>, }>,
// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示
guardian: {
relationship: 'mother',
relationshipLabel: '妈妈',
name: '伍晴晴',
phone: '13800000000',
} as { relationship: string; relationshipLabel: string; name: string | null; phone: string | null } | null,
}, },
}; };
......
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
import { import {
PersonaFeatureKey, PersonaFeatureKey,
treatmentCategoryNameZh, treatmentCategoryNameZh,
treatmentCategoryNameZhForTeeth,
diagnosisCodeNameZh, diagnosisCodeNameZh,
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
RECALL_FEEDBACK_OPTIONS, RECALL_FEEDBACK_OPTIONS,
...@@ -232,7 +233,9 @@ export function PlanDetailApp({ ...@@ -232,7 +233,9 @@ export function PlanDetailApp({
})(); })();
const focusTreatment = (() => { const focusTreatment = (() => {
const cat = focusedReason?.signals?.expectedCategories?.[0]; const cat = focusedReason?.signals?.expectedCategories?.[0];
return cat ? treatmentCategoryNameZh(cat) : null; if (!cat) return null;
// 乳牙龋齿只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
return treatmentCategoryNameZhForTeeth(cat, focusedReason?.signals?.toothPosition);
})(); })();
const submitOutcome = async (formData: { const submitOutcome = async (formData: {
...@@ -331,6 +334,7 @@ export function PlanDetailApp({ ...@@ -331,6 +334,7 @@ export function PlanDetailApp({
patient={patient} patient={patient}
persona={persona} persona={persona}
facts={facts} facts={facts}
focusedReason={focusedReason}
onOpenDetail={() => setDrawerOpen('facts')} onOpenDetail={() => setDrawerOpen('facts')}
onOpenTeeth={() => setDrawerOpen('teeth')} onOpenTeeth={() => setDrawerOpen('teeth')}
/> />
...@@ -1072,6 +1076,14 @@ function IdentityCard({ ...@@ -1072,6 +1076,14 @@ function IdentityCard({
</svg> </svg>
)} )}
</button> </button>
{/* ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人标注 — 儿童/老人本人常无手机,触达打监护人。
真实手机号宿主未提供(现随机),此处仅演示"手机号旁标注(身份 姓名)"。上线前移除/重做。 */}
{patient.profile.guardian && (
<span className="ml-0.5 text-[11px] text-amber-700" title="监护人">
{patient.profile.guardian.relationshipLabel}
{patient.profile.guardian.name ? ` ${patient.profile.guardian.name}` : ''}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
...@@ -1161,28 +1173,45 @@ function KeyFactsCard({ ...@@ -1161,28 +1173,45 @@ function KeyFactsCard({
patient, patient,
persona, persona,
facts, facts,
focusedReason,
onOpenDetail, onOpenDetail,
onOpenTeeth, onOpenTeeth,
}: { }: {
patient: typeof mockPatient; patient: typeof mockPatient;
persona: typeof mockPersona; persona: typeof mockPersona;
facts: AdaptedFact[]; facts: AdaptedFact[];
/// 本次召回聚焦的 reason(priorityScore 最高那条)— 主治医生取它触发诊断的医生
focusedReason?: PlanReason;
onOpenDetail: () => void; onOpenDetail: () => void;
onOpenTeeth: () => void; onOpenTeeth: () => void;
}) { }) {
// ─ 主治医生 ─ 取 facts 中 doctor_id 出现频次最高的,并解析 doctor_name // ─ 主治医生(口径 A)─ = 触发本次召回的诊断的医生(focusedReason 的证据 fact)。
// 同 chain-composer.buildDoctorMap 逻辑同源:同 patient (id,name) 双全的 fact 学一遍 map // 而非"全量最高频医生"——召回是冲某诊断来的,该露出做出该诊断的医生。
// 兜底:① 触发诊断只来自影像 AI(无人类医生)→ '影像AI';
// ② 无 focusedReason(异常)→ 退回全量最高频医生;③ 都没有 → '—'。
const attendingDoctor = (() => { const attendingDoctor = (() => {
const ev = focusedReason?.evidence ?? [];
if (ev.length > 0) {
const evidenceIds = new Set(ev.map((e) => e.id));
let sawImageDx = false;
for (const f of facts) {
if (f.type !== 'diagnosis_record' || !evidenceIds.has(f.id)) continue;
const c = f.content as Record<string, unknown> | null;
const name = c?.doctor_name ? String(c.doctor_name) : '';
if (name) return name; // A:触发诊断的医生
if (String(c?.code_source ?? '') === 'image_ai') sawImageDx = true;
}
if (sawImageDx) return '影像AI'; // 只被影像分析诊断,无人类主诊
}
// 兜底:全量 fact 最高频医生(同 chain-composer.buildDoctorMap)
const idCount = new Map<string, number>(); const idCount = new Map<string, number>();
const idToName = new Map<string, string>(); const idToName = new Map<string, string>();
for (const f of facts) { for (const f of facts) {
const c = f.content as Record<string, unknown> | null; const c = f.content as Record<string, unknown> | null;
if (!c) continue; const id = c?.doctor_id ? String(c.doctor_id) : '';
const id = c.doctor_id ? String(c.doctor_id) : '';
const name = c.doctor_name ? String(c.doctor_name) : '';
if (!id) continue; if (!id) continue;
idCount.set(id, (idCount.get(id) ?? 0) + 1); idCount.set(id, (idCount.get(id) ?? 0) + 1);
if (name) idToName.set(id, name); if (c?.doctor_name) idToName.set(id, String(c.doctor_name));
} }
if (idCount.size === 0) return '—'; if (idCount.size === 0) return '—';
const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0]; const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0];
...@@ -1225,20 +1254,11 @@ function KeyFactsCard({ ...@@ -1225,20 +1254,11 @@ function KeyFactsCard({
return { value: '-', hint: '' }; return { value: '-', hint: '' };
})(); })();
// ─ 联系人 ─ 取已建档(linked,有姓名)的关系人,展示"关系 姓名";多于一位 hint 显示 +N // 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ TEST ONLY,见 PatientHeader)
const namedContacts = (patient.profile.contacts ?? []).filter((c) => c.name);
const primaryContact = namedContacts[0];
const contactValue = primaryContact
? `${primaryContact.relationshipLabel} ${primaryContact.name}`
: '—';
const contactHint =
namedContacts.length > 1 ? `+${namedContacts.length - 1} 位` : (primaryContact?.phone ?? '');
const rows = [ const rows = [
{ label: '主治医生', value: attendingDoctor, hint: mainCategories || '' }, { label: '主治医生', value: attendingDoctor, hint: mainCategories || '' },
{ label: '专属客服', value: dedicatedCs, hint: '' }, { label: '专属客服', value: dedicatedCs, hint: '' },
{ label: '累计消费', value: ${ltvYuan}`, hint: valueHint }, { label: '累计消费', value: ${ltvYuan}`, hint: valueHint },
{ label: '联系人', value: contactValue, hint: contactHint },
{ label: '保险客户', value: insurance.value, hint: insurance.hint }, { label: '保险客户', value: insurance.value, hint: insurance.hint },
]; ];
return ( return (
......
...@@ -42,6 +42,13 @@ export type PlanDetailData = { ...@@ -42,6 +42,13 @@ export type PlanDetailData = {
phone: string | null; phone: string | null;
linked: boolean; linked: boolean;
}>; }>;
/// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback)。真实手机号到位前仅演示用。
guardian: {
relationship: string;
relationshipLabel: string;
name: string | null;
phone: string | null;
} | null;
} | null; } | null;
plan: { plan: {
id: string; id: string;
......
...@@ -4,7 +4,7 @@ import { ...@@ -4,7 +4,7 @@ import {
subLabelZh, subLabelZh,
triggerTypeLabelZh, triggerTypeLabelZh,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZhForTeeth,
} from '@pac/types'; } from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils'; import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
...@@ -43,7 +43,10 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) { ...@@ -43,7 +43,10 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
? triggerTypeLabelZh(trig.type) ? triggerTypeLabelZh(trig.type)
: ''; : '';
const subLabel = subLabelZh(reason.scenario, s.subKey); const subLabel = subLabelZh(reason.scenario, s.subKey);
const cats = s.expectedCategories.map(treatmentCategoryNameZh).join(' / '); // 乳牙 restorative 只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
const cats = s.expectedCategories
.map((c) => treatmentCategoryNameZhForTeeth(c, s.toothPosition))
.join(' / ');
return ( return (
<span> <span>
<strong className="text-slate-900">{subLabel}</strong> <strong className="text-slate-900">{subLabel}</strong>
......
...@@ -334,7 +334,18 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[ ...@@ -334,7 +334,18 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
sub.primary === 'K05' ? WHOLE_PERIO : sub.primary === 'K07' ? WHOLE_ORTHO : WHOLE_OTHER; sub.primary === 'K05' ? WHOLE_PERIO : sub.primary === 'K07' ? WHOLE_ORTHO : WHOLE_OTHER;
evalUnit(laneKey, true, null); evalUnit(laneKey, true, null);
} else { } else {
const teeth = [...new Set(factTeeth(sig.c))]; let teeth = [...new Set(factTeeth(sig.c))];
let skipAll = false;
// 乳牙不进种植/修复召回(同生产 missing_tooth.excludeDeciduous)。
// 独立内联实现(不共享生产的判定):乳牙 = FDI 51-85 或 宿主象限 1A-4E。
// 全乳牙缺失 → 不召(乳牙会被恒牙替换),且不可塌成全口召回。
if (sub.subKey === 'missing_tooth') {
const isDecid = (t: string) => /^[5-8][1-5]$/.test(t) || /^[1-4][A-E]$/.test(t);
const kept = teeth.filter((t) => !isDecid(toothBase(t)));
if (teeth.length > 0 && kept.length === 0) skipAll = true;
teeth = kept;
}
if (!skipAll) {
if (teeth.length === 0) { if (teeth.length === 0) {
evalUnit(WHOLE_OTHER, true, null); evalUnit(WHOLE_OTHER, true, null);
} else { } else {
...@@ -342,6 +353,7 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[ ...@@ -342,6 +353,7 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
} }
} }
} }
}
return out; return out;
} }
......
...@@ -35,6 +35,7 @@ import { plansApi } from './plans-api'; ...@@ -35,6 +35,7 @@ import { plansApi } from './plans-api';
import { usePlansList } from './use-plans-list'; import { usePlansList } from './use-plans-list';
import { usePlanCounts } from './use-plan-counts'; import { usePlanCounts } from './use-plan-counts';
import { ReasonLine } from '@/components/plan-detail/reason-line'; import { ReasonLine } from '@/components/plan-detail/reason-line';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover'; import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
...@@ -782,9 +783,34 @@ function PatientPlanCard({ ...@@ -782,9 +783,34 @@ function PatientPlanCard({
{/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。 {/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。
列表只展示 primary(MAX priorityScore)一条,卡片保持紧凑;多 reason 全量在详情页看。 */} 列表只展示 primary(MAX priorityScore)一条,卡片保持紧凑;多 reason 全量在详情页看。 */}
<div className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}> <div className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
{/* primary = reasons[0](后端按 priorityScore desc 返回 → 最高优先级);其余 +N hover 展示 */}
{p.reasons[0] ? <ReasonLine reason={p.reasons[0]} /> : <span>{p.inclusionReason}</span>} {p.reasons[0] ? <ReasonLine reason={p.reasons[0]} /> : <span>{p.inclusionReason}</span>}
{p.reasons.length > 1 && ( {p.reasons.length > 1 && (
<span className="ml-1.5 text-[10.5px] text-slate-400">+{p.reasons.length - 1}</span> <HoverCard openDelay={120} closeDelay={80}>
<HoverCardTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="ml-1.5 align-middle rounded px-1 text-[10.5px] text-slate-400 hover:text-teal-700 hover:bg-slate-100 cursor-default"
>
+{p.reasons.length - 1}
</button>
</HoverCardTrigger>
<HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
<p className="mb-1.5 text-[11px] font-medium text-slate-500">
其余 {p.reasons.length - 1} 项应治未治
</p>
<ul className="space-y-1.5 text-[12px] leading-relaxed text-slate-700">
{p.reasons.slice(1).map((r) => (
<li key={r.id} className="flex gap-1.5">
<span className="mt-[2px] flex-none text-rose-400"></span>
<div className="min-w-0 flex-1">
<ReasonLine reason={r} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
)} )}
</div> </div>
......
...@@ -639,6 +639,36 @@ export function treatmentCategoryNameZh(category: string): string { ...@@ -639,6 +639,36 @@ export function treatmentCategoryNameZh(category: string): string {
); );
} }
/**
* 乳牙判定(单牙位 token)— FDI 51-85(`^[5-8][1-5]$`)+ 宿主象限记法 1A-4E(`^[1-4][A-E]$`)。
* 恒牙 FDI 11-48(象限 1-4 + 数字 1-8)不匹配。前后端 / 召回 SQL 同口径(单一真理源)。
*/
export function isDeciduousTooth(token: string): boolean {
const t = token.trim();
return /^[5-8][1-5]$/.test(t) || /^[1-4][A-Ea-e]$/.test(t);
}
/** 牙位字符串是否"全为乳牙"(非空且每颗都是乳牙)。空串 → false。 */
export function isAllDeciduous(toothPosition: string | null | undefined): boolean {
const teeth = (toothPosition ?? '')
.split(/[;,]/)
.map((x) => x.trim())
.filter(Boolean);
return teeth.length > 0 && teeth.every(isDeciduousTooth);
}
/**
* 治疗类别 → 中文名,**按牙位裁剪**:乳牙的 restorative 只做直接充填(嵌体是恒牙间接修复体)
* → 返回「充填」而非「充填 / 嵌体」。其余类别 / 恒牙不变。前后端共用,保证文案一致。
*/
export function treatmentCategoryNameZhForTeeth(
category: string,
toothPosition: string | null | undefined,
): string {
if (category === 'restorative' && isAllDeciduous(toothPosition)) return '充填';
return treatmentCategoryNameZh(category);
}
// ============================================================= // =============================================================
// scenario 子规则 / 触发类型 → 中文标签(前后端共用) // scenario 子规则 / 触发类型 → 中文标签(前后端共用)
// ============================================================= // =============================================================
......
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