Commit 4a7750d0 by luoqi

feat(recall+ingest): 召回质量收口 + 联系人/影像AI/回访 三个新信号源

召回质量(摄入层 yaml + 召回 scenario,host 无关引擎增强):
- C.10 治疗痕迹覆盖扩(治疗后/术中/X后/粘结中 + 修复/备牙/取模/根尖/植骨/错颌 keyword)
- treat_plan/dispose 定向合并(real_no_tooth 闸 path2:治疗无牙位→dispose 找回牙位)
- 牙位归一:全角字母数字折叠(RCT→RCT)、裸象限数字过滤(笔误 16→1 幽灵召回清零)
- keyword none 排除 + 切段含全角标点 + 若条件从句 strip
- 治疗家族 resolver / 同牙取代 / oracle 对账(差分 0)

联系人(patientpatient 自关联,替代 primaryContactType):
- 新 PatientRelation 边表(referee → 关系人即 patient,姓名/电话现取零冗余)
- 删 primaryContactType 全链路;详情页"联系人"行

影像 AI 信号源(image_analysis → diagnosis_record,code_source=image_ai):
- 源 SQL pivot+炸牙位(病种→K码),独立 subject,去重靠召回 (subKey,tooth) 聚类

回访展示(独立表,5 试点):
- 新 PatientReturnVisit 表(org∈5试点 + customer_id→patient),详情页"历史联系"滚动卡

裁决:referee/image/returnvisit 已接(天然试点 scope);complain/consult 全集团覆盖不全,不接。
本地 191/200/1000/2000 四样本对抗验证:幽灵召回 0、resolver 漏判 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 6d201fdf
......@@ -261,5 +261,22 @@ enum_mapping:
骨性Ⅱ类: K07
骨性Ⅲ类: K07
# 兜底:翻不出 → 空 → parser 落 code=null(不丢 fact)
# 兜底:翻不出 → 交下面 keyword_mapping 含词兜底;再不中 → 空 → code=null(不丢 fact)
_default: ""
# ─────────────────────────────────────────────────────────
# 含词兜底 —— 精确 enum 未命中时,按序含词匹配 → K 码。
# 治本长尾:诊断常是复合/变体("埋伏阻生;"、"前倾位阻生,颌面龋"、"牙齿缺失"、"窝沟龋"…),
# 精确白名单永远漏(沈建锋 28/38 阻生智齿 → null → 漏召)。引擎按 canonicalKey 通用。
# 顺序=优先级:阻生/缺失/牙髓 在 龋 之前 → 复合"阻生+龋"判 K01(智齿该评估拔除,非补)。
# ⚠️ 龋:any=[龋] + none=[风险,可疑,脱矿] —— 排除"高龋风险/可疑龋"(=风险,非龋洞,同深窝沟不召);
# 深窝沟/食物嵌塞/牙本质敏感 不含上述关键词 → 仍落 null,故意不召(无回归)。
keyword_mapping:
code:
- { value: K08, any: [缺失, 缺牙, 牙列缺损] }
- { value: K01, any: [阻生, 埋伏, 智齿] }
- { value: K09, any: [颌骨囊肿, 含牙囊肿, 根尖囊肿] }
- { value: K04, any: [牙髓炎, 牙髓坏死, 根尖周, 根尖炎, 牙髓] }
- { value: K03, any: [楔状, 残根, 残冠, 隐裂, 牙体缺损, 牙折] }
- { value: K05, any: [牙周炎, 龈炎, 牙周袋] }
- { value: K02, any: [], none: [风险, 可疑, 脱矿] }
# image_finding — 影像 AI 分析 → 诊断信号源(独立 subject,code_source=image_ai)
#
# 消费 sql_source.image_finding_rows(源 SQL 已 pivot + 炸牙位成长表:
# patient_id / brand / organization_id / emr_id / rq / code(K码) / code_source / tooth / diag_external_id)
#
# 复用 diagnosis canonical + diagnosis.parser → 落 diagnosis_record fact(跟医生诊断同类型,
# 召回 scenario 自动消费)。externalId 带 |imgai| → 跟医生诊断不同 subject(不合并,保留来源)。
# 去重:不靠 subject 合并,靠召回层 (subKey, tooth) 聚类(AI cavity@16 与 医生 K02@16 → 一条召回)。
canonical: diagnosis
emits:
action: diagnosis_recorded
subjectType: diagnosis
occurredAtField: occurredAt
primary:
table: image_finding_rows
key: diag_external_id
dedup_by: diag_external_id # 同 emr×code×tooth 去重(AI 多次拍片同发现)
field_mapping:
externalId: diag_external_id
updatedAt: rq # AI 分析日(进 source_event_id 幂等键)
createdAt: rq
patientExternalId: patient_id
clinicId: organization_id # 患者 EMR 诊所代理(image 无诊所)
occurredAt: rq
sourceEncounterExternalId: emr_id
code: code # 已是 K 码(源 SQL pivot 给定)
codeSource: code_source # 'image_ai' → diagnosis.parser 显式优先
toothPosition: tooth
# code 已是 K 码,直通校验(parser PACDiagnosisCodeSchema 闭集)。仍给 K 大类 passthrough 防意外。
enum_mapping:
code:
K00: K00
K01: K01
K02: K02
K03: K03
K04: K04
K05: K05
K08: K08
K09: K09
_default: ""
# patient_relation — 患者-患者关系边(联系人/亲属/转介绍人)
#
# 消费 fact_customer_referee_out(一行 = 本人 与 一个 referee 的关系)。
# upsert 资源(无 emits,同 patient):落 PatientRelation 表,不进 transaction/fact。
# patientExternalId = referee.patient_id (本人,须解析到 PAC patient)
# relatedExternalId = referee.referee_patient_id (关系人 host id,始终保留)
# relationship = referee_relationship_name 归一到枚举(本人视角:relatedPatient 是本人的 X)
#
# 关系人姓名/电话不在此存 —— 关系人本身也是 patient,读时从 relatedPatient 现取(零冗余)。
canonical: patient_relation
primary:
table: fact_customer_referee_out
key: patient_id # 非真 PK;去重靠 PatientRelation @@unique(patientId, relatedExternalId, relationship)
field_mapping:
patientExternalId: patient_id
relatedExternalId: referee_patient_id
relationship: referee_relationship_name # 中文关系名 → enum_mapping 归一
# host 中文关系名 → PAC 枚举(本人视角:对方是本人的 X)。全量摄入,不过滤关系类型。
enum_mapping:
relationship:
配偶: spouse
爸爸: father
妈妈: mother
子女: child
爷爷: grandparent
奶奶: grandparent
外公: grandparent
外婆: grandparent
孙子女: grandchild
外孙子女: grandchild
兄弟: sibling
姊妹: sibling
姐弟: sibling
兄妹: sibling
朋友: friend
其他亲属: other
其他: other
_default: other
# patient_return_visit — 诊所回访任务记录(展示用,5 试点)
#
# 消费 sql_source.fact_returnvisit_out(已 customer_id→patient_id + org∈5试点过滤)。
# upsert 资源(无 emits,同 patient/relation):落 PatientReturnVisit 表,不进 transaction/fact、不进召回。
canonical: patient_return_visit
primary:
table: fact_returnvisit_out
key: id
dedup_by: id
field_mapping:
externalId: id
patientExternalId: patient_id # = customer_id(源 SQL 已 alias)
clinicId: organization_id
taskDate: task_date
type: return_visit_type_name
status: return_visit_status_name
taskStatus: task_status_name
treatmentItems: treatment_items
followContent: follow_content
result: return_visit_result
......@@ -74,4 +74,23 @@ enum_mapping:
建议正畸: ORTHO_CONSULT_RECOMMENDED
建议矫正: ORTHO_CONSULT_RECOMMENDED
_default: "" # 未识别"建议X" → 丢弃(后续 Layer C LLM 抽取增强)
_default: "" # 未识别"建议X" → 交下面 keyword_mapping 含词兜底;再不中 → 丢弃
# ─────────────────────────────────────────────────────────
# 含词兜底 —— 精确 enum 未命中时,按序含词匹配 → 召回码。
# 治本长尾:"建议X"变体太多(建议充填治疗/建议根管治疗后冠修复/建议拔除后种植…),
# 精确白名单永远漏(谢玉兰 44 "建议充填治疗" 漏配 → null → 44 漏召)。引擎按 canonicalKey 通用。
# 顺序=优先级:高价值/主操作在前(种植>冠、根管>冠、拔除独立)。错配最坏=_default(现状),无回归。
keyword_mapping:
code:
- { value: IMPLANT_RECOMMENDED, any: [种植, 植体] }
- { value: RCT_RECOMMENDED, any: [根管, RCT, 牙髓] }
- { value: EXTRACTION_RECOMMENDED, any: [拔除, 拔牙] }
- { value: CROWN_RECOMMENDED, any: [, , 桩核] }
- { value: FILLING_RECOMMENDED, any: [充填, 补牙, 树脂] }
- { value: HARD_TISSUE_REPAIR_RECOMMENDED, any: [楔状, 牙体, 嵌体, 缺损] }
- { value: SRP_RECOMMENDED, any: [洁治, 洗牙, 牙周, 龈下, 刮治] }
- { value: ORTHO_CONSULT_RECOMMENDED, any: [正畸, 矫正, 矫治] }
- { value: GUM_TREATMENT_RECOMMENDED, any: [牙龈, 牙槽嵴, 系带] }
- { value: JAW_CYST_REMOVAL_RECOMMENDED, any: [囊肿] }
- { value: ERUPTION_INTERVENTION_RECOMMENDED, any: [萌出, 乳牙滞留, 多生牙] }
......@@ -445,19 +445,27 @@ keyword_mapping:
# ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。
# 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。
- { value: implant, any: [种植, 即拔即种, 植体, 种植二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓, 根尖] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓, 错颌, 错合] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
# 窝沟封闭提到 restorative 前:玻璃离子常作封闭剂材料("玻璃离子窝沟封闭"≠充填)
# 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
# 排在 implant/endo/ortho/cosmetic 之后 → 种植修复→implant、根管…修复→endo 仍被前面抢走。
# "嵌体修复"等 restorative 已由上方精确 enum 先命中,不受裸"修复"影响。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
# ⭐ 明确充填动作(充填/去腐/备洞/嵌体/垫底/补牙)压过 preventive:
# "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭"
# 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。
- { value: restorative, any: [充填, 补牙, 去腐, 备洞, 嵌体, 垫底] }
# 纯封闭(无充填动作)→ preventive:玻璃离子常作封闭剂材料("玻璃离子窝沟封闭"≠充填)
- { value: preventive, any: [窝沟封闭, 封闭剂, 点隙封闭] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] }
# 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
- { value: restorative, any: [树脂, 玻璃离子] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑]
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗
/*
Warnings:
- You are about to drop the column `primary_contact_type` on the `patient_profiles` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "patient_profiles" DROP COLUMN "primary_contact_type";
-- CreateTable
CREATE TABLE "patient_relations" (
"id" UUID NOT NULL,
"host_id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"patient_id" UUID NOT NULL,
"related_external_id" TEXT NOT NULL,
"related_patient_id" UUID,
"relationship" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "patient_relations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "patient_relations_patient_id_idx" ON "patient_relations"("patient_id");
-- CreateIndex
CREATE INDEX "patient_relations_related_patient_id_idx" ON "patient_relations"("related_patient_id");
-- CreateIndex
CREATE UNIQUE INDEX "patient_relations_patient_id_related_external_id_relationsh_key" ON "patient_relations"("patient_id", "related_external_id", "relationship");
-- AddForeignKey
ALTER TABLE "patient_relations" ADD CONSTRAINT "patient_relations_patient_id_fkey" FOREIGN KEY ("patient_id") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "patient_relations" ADD CONSTRAINT "patient_relations_related_patient_id_fkey" FOREIGN KEY ("related_patient_id") REFERENCES "patients"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "patient_return_visits" (
"id" UUID NOT NULL,
"host_id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"patient_id" UUID NOT NULL,
"external_id" TEXT NOT NULL,
"clinic_id" TEXT,
"task_date" DATE,
"type" TEXT,
"status" TEXT,
"task_status" TEXT,
"treatment_items" TEXT,
"follow_content" TEXT,
"result" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "patient_return_visits_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "patient_return_visits_patient_id_task_date_idx" ON "patient_return_visits"("patient_id", "task_date" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "patient_return_visits_host_id_tenant_id_external_id_key" ON "patient_return_visits"("host_id", "tenant_id", "external_id");
-- AddForeignKey
ALTER TABLE "patient_return_visits" ADD CONSTRAINT "patient_return_visits_patient_id_fkey" FOREIGN KEY ("patient_id") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
......@@ -172,21 +172,21 @@ model Patient {
externalId String @map("external_id")
/// 患者姓名(stub 期间可空 push 事件 payload 不一定带,后续 pull 补全)
name String?
name String?
/// 联系电话(stub 期间可空 同上;phone IS NULL 自动排除召回池)
phone String?
phone String?
/// 性别(自由文本 各宿主取值不一,不强枚举;业务用到时归一化)
gender String?
gender String?
/// 出生日期
birthDate DateTime? @map("birth_date") @db.Date
birthDate DateTime? @map("birth_date") @db.Date
/// 病历号 host 病历主键(纸质档案号),辅助标识。跟 externalId 互补:
/// externalId = host 内部业务 id(系统主键,稳定但对人无意义)
/// medicalRecordNumber = 纸质档案 / 客服沟通用的可读编号( "BA43016")
/// 普适字段(任何医疗系统都有病历号)host 不提供时留 null
medicalRecordNumber String? @map("medical_record_number")
medicalRecordNumber String? @map("medical_record_number")
/// 偏好(沟通时间、语言、就诊偏好等)。形状自由,应用层 zod 校验:
/// { contactWindow?: {start,end}, language?, preferredChannel?, ... }
preferences Json?
preferences Json?
/// 档案有效性:true = 在召回池;false = 宿主软删/归档( status='archived' 等价)
/// 合规硬过滤(doNotContact / deceased / DNC) PatientProfile 副表
......@@ -201,6 +201,12 @@ model Patient {
facts PatientFact[]
personas Persona[]
plans FollowupPlan[]
/// 我的联系人/亲属边(本人 关系人);关系人本身也是 patient
relationsOut PatientRelation[] @relation("PatientRelationsOut")
/// 把我当联系人的边(别人 )
relationsIn PatientRelation[] @relation("PatientRelationsIn")
/// 诊所回访任务记录(展示用,5 试点;非召回信号)
returnVisits PatientReturnVisit[]
/// 跨同步 upsert 去重:同一物理人在集团内共享一行
@@unique([hostId, tenantId, externalId])
......@@ -217,7 +223,8 @@ model Patient {
///
/// 拆出来的理由:
/// - 主表 Patient 只装"身份"(identity:name/phone/dob ) 跨表查询时 JOIN 主表更轻
/// - 合规字段(doNotContact / deceased / DNC)+ 描述字段(tags / notes / primaryContactType)集中
/// - 合规字段(doNotContact / deceased / DNC)+ 描述字段(tags / notes)集中
/// - 联系人/亲属:不在此(已废 primaryContactType) PatientRelation 边表
/// - 召回池硬过滤走 patient_profiles JOIN(逻辑独立、维护清晰)
model PatientProfile {
/// 1:1 patientId PK(也兼 FK)
......@@ -242,10 +249,6 @@ model PatientProfile {
/// notes 单段长文本(客服备注 / 诊所自由描述)
notes String? @db.Text
/// 产品收集:主要联系人身份(谁接电话):self / spouse / parent / child / guardian / other
/// 儿童/老年患者触达流程不同,Plan 路由要看这个字段。应用层 zod enum 校验。
primaryContactType String? @map("primary_contact_type")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......@@ -256,6 +259,84 @@ model PatientProfile {
@@map("patient_profiles")
}
/// PatientRelation 患者-患者关系边(联系人/亲属/转介绍人)
///
/// 第一性:联系人本身也是一个 patient(FHIR RelatedPerson / Patient link)。所以建成
/// 自关联边,而不是把姓名/电话快照塞进 patient —— 关系人的姓名/电话从 relatedPatient 现取,零冗余。
///
/// 来源:host "客户-推荐人关系"(referee)。一条 referee = 一条边。
/// patientId = 本人(referee.patient_id PAC patient,必须能解析,否则无处挂靠 skip)
/// relatedExternalId = 关系人 host id(referee.referee_patient_id);**始终保留**,唯一稳定引用 + 晚绑定
/// relatedPatientId = 关系人 PAC id;关系人本身也是 active patient 时才填(给姓名/电话);否则 null
/// relationship = 本人视角:relatedPatient 是本人的 X(mother/father/grandparent/...)
///
/// primaryContactType( PatientProfile 单字符串)已废弃 —— "谁主要联系"是这张表的派生投影(读时按
/// relationship + 本人年龄算),不再立柱。
model PatientRelation {
id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id")
/// 本人(FK patients)
patientId String @map("patient_id") @db.Uuid
/// 关系人 host id(始终有)
relatedExternalId String @map("related_external_id")
/// 关系人 PAC id(关系人也是 patient 时填;给姓名/电话)。落不到则 null
relatedPatientId String? @map("related_patient_id") @db.Uuid
/// 关系类型(mother/father/grandparent/spouse/child/sibling/friend/other)
relationship String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
patient Patient @relation("PatientRelationsOut", fields: [patientId], references: [id], onDelete: Cascade)
relatedPatient Patient? @relation("PatientRelationsIn", fields: [relatedPatientId], references: [id])
/// 同本人 × 同关系人 × 同关系 唯一( host 重复边,如两条"妈妈")
@@unique([patientId, relatedExternalId, relationship])
@@index([patientId])
@@index([relatedPatientId])
@@map("patient_relations")
}
/// PatientReturnVisit 诊所回访任务记录(展示用, 5 试点 org)
///
/// 性质 = 诊所自己的外呼/回访运营记录(常规/术后/咨询回访),**不是临床 fact,不进召回信号**
/// 详情页"回访记录"块按 taskDate 倒序展示,让客服看到诊所已做/已排的回访 避免重复外呼 + 上下文。
/// 独立表( transaction/fact):摄入按 organization_id 5 试点 + customer_idpatient 解析 upsert
model PatientReturnVisit {
id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id")
patientId String @map("patient_id") @db.Uuid
/// host 回访记录 id(幂等键)
externalId String @map("external_id")
clinicId String? @map("clinic_id")
/// 回访任务日期(含未来排程)
taskDate DateTime? @map("task_date") @db.Date
/// 类型:常规回访 / 术后回访 / 咨询回访(host 原文)
type String?
/// 回访状态:已回访 / 未回访
status String?
/// 任务状态:已完成 / 未完成 / 创建新回访
taskStatus String? @map("task_status")
/// 关联治疗项( 种植)
treatmentItems String? @map("treatment_items")
/// 回访内容(术后年度复查 / 洁牙提醒 / 活动邀约…)
followContent String? @map("follow_content")
/// 回访结果
result String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
@@unique([hostId, tenantId, externalId])
@@index([patientId, taskDate(sort: Desc)])
@@map("patient_return_visits")
}
/// 患者操作账本(append-only)
/// 一条记录回答:**"在哪个宿主、哪个 tenant、哪个诊所、对哪个主体,做了什么动作"**
/// 原始证据存在 rawPayload(宿主原文),canonical 化和事实视图在 patient_facts 层做。
......@@ -607,7 +688,7 @@ model PersonaFeature {
/// :权益身份 { commercialInsurers:[...], lastCommercialInsuranceAt, monthsSinceLastCommercial }
/// evidence 区分:evidence = 为什么(证据溯源),data = 是什么(结构化特征值)
/// 形状由各 feature 应用层自管(zod);DB 不强约束(db-review §6 JSON 列纪律)
data Json?
data Json?
/// 证据引用:{ factIds, agentInvocationIds, ruleIds, promptVersion }
/// 任何 persona 都能反查到"为什么得出这个结论"
......@@ -736,7 +817,7 @@ model FollowupPlan {
/// Plan 引擎建议:**本次召回业务目标**(自然语言,1-2 句话)
/// :"邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长"
/// 来源:`ScenarioHit.goal`( priority 最高的 hit goal);UI 详情页 SuggestionCard "本次目标" 渲染
goal String? @db.Text
goal String? @db.Text
/// Plan 引擎建议:**触达时间**( fact 层有 planned review_plan 等医嘱事实,展示时事实优先,推断兜底)
recommendedAt DateTime? @map("recommended_at") @db.Timestamptz(3)
......@@ -778,8 +859,8 @@ model FollowupPlan {
/// PlanExecution.recall_feedback 迁来:反馈是对 plan 本身的判断,不属于某次执行。
/// 2 列即可:recallFeedback 立柱(聚合"哪些子场景被 👎"反推算法问题)+ note 文字(人读)
/// 不立 at/by(at plan.updatedAt 兜底,by 价值有限 轻量反馈不做审计)
recallFeedback String? @map("recall_feedback") // 'up' | 'down'
recallFeedbackNote String? @map("recall_feedback_note") @db.Text
recallFeedback String? @map("recall_feedback") // 'up' | 'down'
recallFeedbackNote String? @map("recall_feedback_note") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......
......@@ -29,7 +29,6 @@ export interface FeatureExtractorContext {
deceased: boolean;
tags: string[];
notes: string | null;
primaryContactType: string | null;
} | null;
/// 该 patient 的所有 active facts(按 type 索引方便查)
factsByType: Map<string, ActiveFact[]>;
......
......@@ -168,7 +168,6 @@ export class PersonaService {
deceased: patient.profile.deceased,
tags: patient.profile.tags,
notes: patient.profile.notes,
primaryContactType: patient.profile.primaryContactType,
}
: null,
factsByType,
......
......@@ -37,7 +37,15 @@ export class PlanAggregateService {
}
const patient = await this.prisma.patient.findUnique({
where: { id: plan.patientId },
include: { profile: true },
include: {
profile: true,
// 联系人/亲属边 → 关系人姓名/电话从 relatedPatient 现取(patient↔patient,零冗余)
relationsOut: {
include: { relatedPatient: { select: { name: true, phone: true } } },
},
// 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
returnVisits: { orderBy: { taskDate: 'desc' }, take: 100 },
},
});
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
......@@ -70,7 +78,13 @@ export class PlanAggregateService {
private async assemble(
scope: TenantScopeContext,
patient: Prisma.PatientGetPayload<{ include: { profile: true } }>,
patient: Prisma.PatientGetPayload<{
include: {
profile: true;
relationsOut: { include: { relatedPatient: { select: { name: true; phone: true } } } };
returnVisits: true;
};
}>,
plan: Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }> | null,
) {
const persona = await this.loadCurrentPersona(patient.id);
......@@ -143,6 +157,15 @@ export class PlanAggregateService {
facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null,
recallHistory,
returnVisits: (patient.returnVisits ?? []).map((r) => ({
taskDate: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null,
type: r.type,
status: r.status,
taskStatus: r.taskStatus,
treatmentItems: r.treatmentItems,
followContent: r.followContent,
result: r.result,
})),
};
}
......@@ -276,9 +299,14 @@ function serializeProfile(
profile: {
doNotContact: boolean;
deceased: boolean;
primaryContactType: string | null;
notes: string | null;
} | null;
relationsOut?: Array<{
relationship: string;
relatedExternalId: string;
relatedPatientId: string | null;
relatedPatient: { name: string | null; phone: string | null } | null;
}>;
},
allFacts: Array<{ type: string; content: Prisma.JsonValue; occurredAt: Date | null }>,
encounters: Array<{ occurredAt: Date | null; content: Prisma.JsonValue }>,
......@@ -312,10 +340,21 @@ function serializeProfile(
),
).slice(0, 2);
// 联系人/亲属:本人视角(relatedPatient 是本人的 X)。姓名/电话从 relatedPatient 现取。
// linked=false(关系人未建档)→ 无姓名,前端展示时跳过。已建档的排前。
const contacts = (patient.relationsOut ?? [])
.map((r) => ({
relationship: r.relationship,
relationshipLabel: RELATIONSHIP_LABEL[r.relationship] ?? r.relationship,
name: r.relatedPatient?.name ?? null,
phone: r.relatedPatient?.phone ?? null,
linked: !!r.relatedPatientId,
}))
.sort((a, b) => Number(b.linked) - Number(a.linked));
return {
doNotContact: patient.profile.doNotContact,
deceased: patient.profile.deceased,
primaryContactType: patient.profile.primaryContactType,
notes: patient.profile.notes,
ltvCents,
paymentCount: payments.length,
......@@ -325,9 +364,23 @@ function serializeProfile(
lastVisit: latestEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null,
daysSinceLatestVisit,
doctors,
contacts,
};
}
/// 关系枚举 → 中文展示标签(本人视角:对方是本人的 X)
const RELATIONSHIP_LABEL: Record<string, string> = {
mother: '妈妈',
father: '爸爸',
grandparent: '祖辈',
spouse: '配偶',
child: '子女',
sibling: '兄弟姐妹',
grandchild: '孙辈',
friend: '朋友',
other: '亲属',
};
function serializePlan(plan: {
id: string;
version: number;
......
......@@ -7,6 +7,7 @@ import {
RESTORATION_INELIGIBLE_DX_NAMES,
lookupDxTreatment,
resolverCategoriesFor,
STRUCTURAL_DX_CODE_LIST,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
......@@ -280,12 +281,15 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ② 去残留空格 ③ 按 ';' 拆。例:
// "1D;1E;2D" → {1D,1E,2D}(乳牙保留) "1D OD;17 D" → {1D,17}(剥面) "16;46" → {16,46}
// 注:JS 模板里反向引用要写 '\\1'(传到 SQL 为 '\1');用 POSIX [[:space:]] 避开 \s 转义坑。
// ⭐ 合法牙过滤(口径同 util.isValidToothToken):只留【数字开头 + ≥2 字符】的 token。
// 裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")非法 → 丢,否则造幽灵召回 caries@1。
// `x ~ '^[0-9].'`:数字开头且其后还有字符(裸 "1" 仅 1 字符 → 不匹配 → 滤掉)。
const toothArrSql = (expr: Prisma.Sql) =>
Prisma.sql`array_remove(string_to_array(
Prisma.sql`(SELECT COALESCE(array_agg(x), ARRAY[]::text[]) FROM unnest(array_remove(string_to_array(
regexp_replace(
regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'),
'[[:space:]]+', '', 'g'),
';'), '')`;
';'), '')) AS x WHERE x ~ '^[0-9].')`;
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
// ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
// 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)—
......@@ -299,15 +303,44 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.empty
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT rtt), ARRAY[]::text[])
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE rtx.patient_id = p.id
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(afterDx)
${afterDxFragRtx})`;
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
SELECT rtt AS t
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE rtx.patient_id = p.id
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[])
${afterDxFragRtx}
UNION
-- (b) ⭐ 同牙位以【最新诊断】为准:某牙存在比本信号更晚的真实诊断 → 旧诊断对该牙失效。
-- 解:深龋(2023)→ 缺失(2024)缺失取代龋(牙没了,补龋/拔牙都 moot);龋→牙髓炎(进展)只召根管;
-- 复发同病只认最新那次(daysSince 从最新算)。证据是【诊断】本身,无需治疗记录。
-- 只算"真实码"(非空码,排除深窝沟等 null 噪音);严格更晚(> 不含同刻,避免自我取代)。
-- wholeMouth 码(K05/K07)走 category 级、sig_teeth 空 → 不经此路径,不受影响。
SELECT ldt AS t
FROM patient_facts ldx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ldx.content->>'tooth_position'`)}) AS ldt
WHERE ldx.patient_id = p.id
AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
AND ldx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[]) -- 仅结构码取代(牙周/正畸不 moot 结构病)
AND ldx.occurred_at > COALESCE(sig.occurred_at, sig.planned_for)
UNION
-- (c) ⭐ 诊断 vs 建议冲突,以【建议】为准(医生治疗决定 > 诊断):
-- 某牙存在 ≥ 本诊断时间的真实建议(建议拔除/种植…)→ 该诊断对该牙失效,听医生的决定。
-- 18:深龋(诊断)+ 建议拔除(同日)→ 龋失效,只留拔除。只作用于本 sig 是【诊断】时
-- (建议本身不被同牙建议取代;建议被更晚诊断取代已由 (b) 覆盖)。
SELECT rdt AS t
FROM patient_facts rdx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rdx.content->>'tooth_position'`)}) AS rdt
WHERE rdx.patient_id = p.id
AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[]) -- 仅结构建议(拔除/种植/充填…)取代;牙周/正畸建议不 moot 结构病
AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
AND sig.type = 'diagnosis_record'
) u)`;
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
......
......@@ -338,14 +338,19 @@ export function classifyByKeyword(
let text = value;
if (stripClauses && stripClauses.length) {
text = value
.split(/[,;。]/)
.split(/[,;。,;]/) // 含全角逗号/分号:防御未归一输入,strip 切段不漏(否则整句一段 → 建议/若 没剥)
.map((s) => s.trim())
.filter((seg) => seg && !stripClauses.some((p) => seg.startsWith(p)))
.join(',');
}
if (!text) return undefined;
for (const rule of rules) {
if (rule.any.some((kw) => kw && text.includes(kw))) return rule.value;
const hit = rule.any.some((kw) => kw && text.includes(kw));
if (!hit) continue;
// none:排除词(命中 any 但也含 none 中任一 → 不算命中,继续下一规则)。
// 用于"含词但语义反转"——如 龋 any=[龋] none=[风险,可疑](高龋风险/可疑龋是风险/可疑,非龋洞)。
if (rule.none && rule.none.some((kw) => kw && text.includes(kw))) continue;
return rule.value;
}
return undefined;
}
......
......@@ -37,6 +37,8 @@ const KeywordRuleSchema = z.object({
value: z.string(),
/// 含任一词即命中(子串 includes)
any: z.array(z.string().min(1)).min(1),
/// 排除词:命中 any 但也含 none 中任一 → 不算命中(语义反转,如 龋 any=[龋] none=[风险,可疑])
none: z.array(z.string().min(1)).optional(),
});
export type KeywordRule = z.infer<typeof KeywordRuleSchema>;
const keywordMappingForField = z.array(KeywordRuleSchema);
......
......@@ -81,9 +81,10 @@ const DiagnosisRecordContent = z
// 翻不出的仍落 fact(保 name_zh 原文),code=null,留给 Layer C LLM 后续补码。
// 算法 SQL `content->>'code'='K08'` 对 null 自然不命中,无副作用。
code: PACDiagnosisCodeSchema.nullable(),
/// 码来源溯源:'std_code'(host ICD 码)/ 'name_map'(中文名白名单翻译)/ 'llm'(W5+ 抽取)/ null(未定码)
/// 码来源溯源:'std_code'(host ICD 码)/ 'name_map'(中文名白名单翻译)/ 'llm'(W5+ 抽取)/
/// 'image_ai'(影像 AI 分析,fact_emr_image_analysis_out)/ null(未定码)
code_source: z
.enum(['std_code', 'name_map', 'llm'])
.enum(['std_code', 'name_map', 'llm', 'image_ai'])
.nullable()
.optional()
.default(null),
......
......@@ -44,13 +44,17 @@ export class DiagnosisParser implements Parser {
const codeParsed = PACDiagnosisCodeSchema.safeParse(rawCode);
const code = codeParsed.success ? codeParsed.data : null;
// code_source 溯源
// code_source 溯源:① 上游显式声明(如影像 AI codeSource='image_ai')优先;
// ② 否则按 stdCode 有无推断(std_code / name_map);③ 无码 → null。通用,非宿主特化。
const explicitSource = String(c.codeSource ?? '').trim();
const hasStdCode = !!String(c.stdCodeRaw ?? '').trim();
const codeSource: 'std_code' | 'name_map' | null = code
? hasStdCode
? 'std_code'
: 'name_map'
: null;
const codeSource: string | null = explicitSource
? explicitSource
: code
? hasStdCode
? 'std_code'
: 'name_map'
: null;
const toothPosition = normalizeToothPosition(c.toothPosition as string | undefined);
const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null;
......
......@@ -60,8 +60,20 @@ export function normalizeToothPosition(input: string | null | undefined): string
* "11 M;21 M" → Set { "11", "21" }
* "1A;1B;1C" → Set { "1A", "1B", "1C" }
*/
/**
* 合法牙位 token 判定:必须【数字开头 + 至少 2 字符】。
* 合法:FDI 恒牙 11-48 / FDI 乳牙 51-85(两位数字) · Palmer 乳牙 1A-8E(数字+字母)。
* 非法:裸单数字 1-8(象限号,医生笔误漏打,如 "16"→"1")—— 任何记号体系都不是合法牙,
* 留着会造永不可解的幽灵召回(caries@1)。丢弃 ≠ 解读(不猜它本该是哪颗),只是不对垃圾下手。
*/
export function isValidToothToken(t: string): boolean {
return t.length >= 2 && /^\d/.test(t);
}
export function toothSet(input: string | null | undefined): Set<string> {
const norm = normalizeToothPosition(input);
if (!norm) return new Set();
return new Set(norm.split(';').map((t) => t.trim()).filter(Boolean));
return new Set(
norm.split(';').map((t) => t.trim()).filter(Boolean).filter(isValidToothToken),
);
}
......@@ -232,7 +232,6 @@ export class PipelineDispatcher {
deceased: (row.deceased as boolean | undefined) ?? false,
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
notes: (row.notes as string | undefined) ?? null,
primaryContactType: (row.primaryContactType as string | undefined) ?? null,
};
const patient = await this.prisma.patient.upsert({
......
......@@ -38,11 +38,17 @@ const CJK_PUNCT_MAP: Record<string, string> = {
const CJK_PUNCT_KEYS = Object.keys(CJK_PUNCT_MAP).join('');
const CJK_PUNCT_REGEX = new RegExp(`[${CJK_PUNCT_KEYS}]`, 'gu');
/// 全角字母/数字 → 半角(RCT→RCT、0-9→0-9、A-Z/a-z→ASCII)。
/// host EMR free-text 常夹全角英数(RCT治疗后),不折叠则 enum/keyword 字典(半角)永远漏。
/// FF10-19 数字 / FF21-3A 大写 / FF41-5A 小写,统一 -0xFEE0 落到 ASCII;通用,非宿主特化。
const FULLWIDTH_ALNUM_REGEX = /[0-9A-Za-z]/gu;
function normalizeText(s: string): string {
return s
.trim()
.replace(TRAILING_NOISE, '')
.replace(CJK_PUNCT_REGEX, (ch) => CJK_PUNCT_MAP[ch] ?? ch)
.replace(FULLWIDTH_ALNUM_REGEX, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.trim();
}
......
......@@ -50,6 +50,62 @@ function isBlankValue(v: unknown): boolean {
}
}
/// blank_or_all_in:空,或 JSON 数组里**每个**元素的 treatName ∈ 列表(空 treatName 也算通过)。
/// 用于 dispose gate:treat_plan 空 / 全是复查类(无真治疗)→ 才去 dispose 抽治疗(陈昱天案例)。
/// 任一元素 treatName 不在列表(= 真治疗)→ false → 不抽 dispose(避免与结构化重复)。
/// 匹配 = trim 后精确(对齐 C.3 route 的 equals 语义;review 词多无标点,raw≈normalize)。
function isBlankOrAllIn(v: unknown, list: Array<string | number>): boolean {
if (isBlankValue(v)) return true;
if (typeof v !== 'string') return false;
const t = v.trim();
if (!t.startsWith('[')) return false;
let arr: unknown;
try {
arr = JSON.parse(t);
} catch {
return false;
}
if (!Array.isArray(arr)) return false;
if (arr.length === 0) return true;
const set = new Set(list.map((x) => String(x).trim()));
return arr.every((el) => {
if (!el || typeof el !== 'object') return true;
const name = String((el as Record<string, unknown>).treatName ?? '').trim();
return name === '' || set.has(name);
});
}
/// real_no_tooth:JSON 数组里【有 ≥1 个真治疗(treatName 非空且 ∉ review 列表)】
/// **且 没有任何元素带牙位(toothPosition 全空)**。
/// 用于 dispose gate 的 path 2:treat_plan 有真治疗(充填)但牙位漏填(刘小源"充填"无牙位)→
/// treat_plan 给不出牙位 → 去 dispose 找回牙位。
/// 刻意排除:① 空 / 全复查(交 path 1 的 blank_or_all_in 处理,避免两路重复抽 dispose 双计);
/// ② 任一元素已带牙位(treat_plan 牙位齐全 → 不抽,避免同次重复)。
function isRealNoTooth(v: unknown, list: Array<string | number>): boolean {
if (typeof v !== 'string') return false;
const t = v.trim();
if (!t.startsWith('[')) return false;
let arr: unknown;
try {
arr = JSON.parse(t);
} catch {
return false;
}
if (!Array.isArray(arr) || arr.length === 0) return false;
const review = new Set(list.map((x) => String(x).trim()));
let hasReal = false;
let anyTooth = false;
for (const el of arr) {
if (!el || typeof el !== 'object') continue;
const o = el as Record<string, unknown>;
const name = String(o.treatName ?? '').trim();
const tooth = String(o.toothPosition ?? '').trim();
if (name !== '' && !review.has(name)) hasReal = true;
if (tooth !== '') anyTooth = true;
}
return hasReal && !anyTooth;
}
function passAll(row: Row, where: Record<string, WhereRule>): boolean {
for (const [field, rule] of Object.entries(where)) {
const v = row[field];
......@@ -60,6 +116,12 @@ function passAll(row: Row, where: Record<string, WhereRule>): boolean {
// [{treatName:"",toothPosition:null,toothPositionBak:"<base64>"}])。
// 用于 gate "无结构化治疗" — treat_plan 空壳时也应去 dispose 找治疗。
if (!isBlankValue(v)) return false;
} else if ('blank_or_all_in' in rule) {
// 空 或 JSON 数组每个元素 treatName ∈ 列表 → 通过(treat_plan 无真治疗 → 去 dispose 抽)。
if (!isBlankOrAllIn(v, rule.blank_or_all_in)) return false;
} else if ('real_no_tooth' in rule) {
// treat_plan 有真治疗但全无牙位 → 通过(去 dispose 找回牙位;path 2)。
if (!isRealNoTooth(v, rule.real_no_tooth)) return false;
} else if ('in' in rule) {
const set = new Set(rule.in.map((x) => String(x)));
if (!set.has(String(v))) return false;
......
......@@ -73,6 +73,13 @@ export type ProjectOp = z.infer<typeof ProjectOpSchema>;
export const WhereRuleSchema = z.union([
z.object({ not_empty: z.literal(true) }),
z.object({ empty: z.literal(true) }), // 空:null/""/"[]"(空 JSON 数组也算空)— 用于 gate "无结构化字段"
// blank_or_all_in:JSON 数组字段"空 或 每个元素 treatName ∈ 列表"。用于 dispose gate:
// treat_plan 空 / 全是复查类(无真治疗)→ 才去 dispose 抽治疗(陈昱天:treat_plan=["常规复查"])。
// 列表 = C.3 路由的 review 词表(YAML anchor 复用,单一真理源)。
z.object({ blank_or_all_in: z.array(z.union([z.string(), z.number()])).min(1) }),
// real_no_tooth:JSON 数组"有真治疗(treatName 非空且 ∉ 列表)但全无牙位"。dispose gate path 2:
// treat_plan 有治疗名却漏填牙位(刘小源"充填"无牙位)→ 去 dispose 找回牙位。列表 = review 词表。
z.object({ real_no_tooth: z.array(z.union([z.string(), z.number()])).min(1) }),
z.object({ in: z.array(z.union([z.string(), z.number()])).min(1) }),
z.object({ equals: z.union([z.string(), z.number()]) }),
]);
......
......@@ -57,10 +57,11 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
doNotContact: real.profile?.doNotContact ?? false,
deceased: real.profile?.deceased ?? false,
// host 没给 → null,UI 用 '—' 占位;不再前端硬兜底 '本人'(误导客服以为是本人)
primaryContactType: real.profile?.primaryContactType ?? null,
ltv: Math.round((real.profile?.ltvCents ?? 0) / 100),
firstVisit: real.profile?.firstVisit ?? '',
lastVisit: real.profile?.lastVisit ?? '',
// 联系人/亲属(本人视角)— 关系人姓名/电话从 relatedPatient 现取(后端 serializeProfile)
contacts: real.profile?.contacts ?? [],
},
};
......@@ -202,6 +203,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
: EMPTY_SCRIPT,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [],
// 诊所回访记录(5 试点,展示用)— 后端透出;无则空数组
returnVisits: real.returnVisits ?? [],
// outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import
fmtRel,
};
......
......@@ -621,8 +621,12 @@ function factSummary(f: AdaptedFact): { title: string; note: string } {
};
}
case 'recommendation_record': {
const code = (c.code as string) ?? '?';
return { title: `建议 ${code}`, note: dr };
const code = (c.code as string) ?? '';
// 优先 host 原文中文(name_zh/name,如"建议种植修复"),再字典翻译(IMPLANT_RECOMMENDED→建议种植),
// 最后才裸 code 兜底 —— 不再直接展示英文枚举码。
const name = (c.name_zh as string) || (c.name as string) || '';
const zh = code ? diagnosisCodeNameZh(code) : '';
return { title: name || zh || (code ? `建议 ${code}` : '医生建议'), note: dr };
}
case 'encounter_record': {
const etype = (c.encounter_type as string) ?? '';
......
......@@ -49,10 +49,24 @@ export const mockPatient = {
profile: {
doNotContact: false,
deceased: false,
primaryContactType: '本人' as string | null,
ltv: 48200,
firstVisit: '2022-06-08',
lastVisit: fmtDate(daysAgo(38)),
contacts: [
{
relationship: 'mother',
relationshipLabel: '妈妈',
name: '伍晴晴',
phone: '13800000000',
linked: true,
},
] as Array<{
relationship: string;
relationshipLabel: string;
name: string | null;
phone: string | null;
linked: boolean;
}>,
},
};
......
......@@ -89,9 +89,22 @@ export type PlanDetailAppData = {
script: typeof mockScript;
/// 召回历史(患者级)— 可选,缺省空数组
recallHistory?: RecallHistoryItem[];
/// 诊所回访记录(5 试点,展示用)— 可选,缺省空数组
returnVisits?: ReturnVisitItem[];
fmtRel: typeof mockFmtRel;
};
/// 回访记录一条 — 后端 plan-aggregate.returnVisits
export type ReturnVisitItem = {
taskDate: string | null;
type: string | null;
status: string | null;
taskStatus: string | null;
treatmentItems: string | null;
followContent: string | null;
result: string | null;
};
const FALLBACK_DATA: PlanDetailAppData = {
patient: mockPatient,
chains: mockChains,
......@@ -118,6 +131,7 @@ export function PlanDetailApp({
const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
const facts = data.facts ?? [];
const recallHistory = data.recallHistory ?? [];
const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash
......@@ -321,14 +335,11 @@ export function PlanDetailApp({
onOpenTeeth={() => setDrawerOpen('teeth')}
/>
<TreatmentHistoryCard facts={facts} />
{/* 历史联系 — 预留卡片(以后接客服联系记录) */}
<SidebarCard title="历史联系">
<div className="text-[11.5px] italic text-slate-400">暂无联系记录(后续接入客服联系记录)</div>
</SidebarCard>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && (
<RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
)}
{returnVisits.length > 0 && <ReturnVisitsCard visits={returnVisits} />}
<SidebarCard
title="治疗链"
meta={`${chains.length} 条`}
......@@ -1143,7 +1154,7 @@ function WhyCard({
// - 主治医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—'
// - 累计消费:profile.ltv(LTV cents 已扣 refund);右侧 hint 显示 persona.value description(画像评级)
// - 上次/首次到诊:profile.lastVisit / firstVisit
// - 联系人:profile.primaryContactType(host 暂无数据,统一 '—' 占位)
// - 联系人:改走 PatientRelation 边表(relationsOut),读时取关系人姓名/关系;暂 '—' 占位待接前端
// hint 列:除累计消费用 persona value 外,其他用 facts 推断的主要治疗类目 top 1-2
// ──────────────────────────────────────────
function KeyFactsCard({
......@@ -1214,11 +1225,20 @@ function KeyFactsCard({
return { value: '-', hint: '' };
})();
// 4 行(user 指定):主治医生 / 专属客服 / 累计消费 / 保险客户(显示保险名称)
// ─ 联系人 ─ 取已建档(linked,有姓名)的关系人,展示"关系 姓名";多于一位 hint 显示 +N
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 = [
{ label: '主治医生', value: attendingDoctor, hint: mainCategories || '' },
{ label: '专属客服', value: dedicatedCs, hint: '' },
{ label: '累计消费', value: ${ltvYuan}`, hint: valueHint },
{ label: '联系人', value: contactValue, hint: contactHint },
{ label: '保险客户', value: insurance.value, hint: insurance.hint },
];
return (
......@@ -1487,6 +1507,45 @@ function RecallHistoryCard({
);
}
/// ReturnVisitsCard — 诊所回访记录(5 试点,展示用)。标题"历史联系"(替代原占位卡)。
/// 价值:客服看得到诊所已做/已排的回访(术后/常规/咨询)→ 避免重复外呼 + 拿上下文。
function ReturnVisitsCard({ visits }: { visits: ReturnVisitItem[] }) {
return (
<SidebarCard title="历史联系" meta={`${visits.length} 条`}>
<div className="space-y-1.5 max-h-[260px] overflow-y-auto pr-1">
{visits.map((v, i) => {
const done = v.status === '已回访' || v.taskStatus === '已完成';
return (
<div key={i} className="flex items-start gap-2 text-[11px] py-0.5">
<span
className={`flex-none mt-[3px] w-1.5 h-1.5 rounded-full ${done ? 'bg-teal-400' : 'bg-slate-300'}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-slate-600">{v.type ?? '回访'}</span>
{v.status && <span className="text-slate-400">· {v.status}</span>}
{v.taskDate && <span className="text-slate-400 tabular-nums">{v.taskDate}</span>}
{v.treatmentItems && (
<span className="text-teal-700/70">· {v.treatmentItems}</span>
)}
</div>
{v.followContent && (
<div className="text-slate-500 leading-snug break-words mt-0.5">
{v.followContent}
</div>
)}
{v.result && (
<div className="text-slate-400 leading-snug break-words mt-0.5">结果:{v.result}</div>
)}
</div>
</div>
);
})}
</div>
</SidebarCard>
);
}
/**
* 计算风险规避提示 — PAC 已有数据驱动
*
......
......@@ -26,7 +26,6 @@ export type PlanDetailData = {
profile: {
doNotContact: boolean;
deceased: boolean;
primaryContactType: string | null;
notes: string | null;
ltvCents: number;
paymentCount: number;
......@@ -36,6 +35,13 @@ export type PlanDetailData = {
lastVisit: string | null;
daysSinceLatestVisit: number | null;
doctors: string[];
contacts: Array<{
relationship: string;
relationshipLabel: string;
name: string | null;
phone: string | null;
linked: boolean;
}>;
} | null;
plan: {
id: string;
......@@ -191,4 +197,14 @@ export type PlanDetailData = {
createdAt: string;
planVersion: number | null;
}>;
/// 诊所回访记录(5 试点,展示用)
returnVisits?: Array<{
taskDate: string | null;
type: string | null;
status: string | null;
taskStatus: string | null;
treatmentItems: string | null;
followContent: string | null;
result: string | null;
}>;
};
......@@ -28,8 +28,12 @@ import {
APPT_COMPLAINT_TO_CATEGORY,
isRestorationIneligibleDxName,
resolverCategoriesFor,
STRUCTURAL_DX_CODE_LIST,
treatmentCategoryNameZh,
} from '@pac/types';
/// 结构码集合 —— 同牙位"取代"只认结构码(牙周/正畸不 moot 结构病)
const STRUCTURAL_CODES = new Set<string>(STRUCTURAL_DX_CODE_LIST);
import type { AdaptedFact } from './adapt-data';
export const WHOLE_PERIO = '全口 · 牙周';
......@@ -66,7 +70,8 @@ const CODE_TO_SUB: Record<string, { subKey: string; primary: string; label: stri
export type OracleVerdictKind =
| 'recall' // 应召回
| 'resolved' // 已被同类/拔除/替代治疗解决
| 'resolved' // 已被【实际治疗】解决(同类/拔除/替代/术后证据)
| 'superseded' // 被【后续诊断/建议】取代并入(非治疗:深龋→缺失、楔缺→建议充填…)
| 'cooldown' // 诊断未过冷静期
| 'ineligible' // 废用牙/无功能牙,非修复对象
| 'suppressed'; // 被患者级闸压制(未来预约/主诉预约/近期到诊)
......@@ -86,7 +91,8 @@ export type OracleVerdict = {
const VERDICT_META: Record<OracleVerdictKind, { zh: string; tone: string }> = {
recall: { zh: '应召回', tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
resolved: { zh: '已解决', tone: 'bg-slate-100 text-slate-500 border-slate-200' },
resolved: { zh: '已治疗', tone: 'bg-slate-100 text-slate-500 border-slate-200' },
superseded: { zh: '被取代', tone: 'bg-slate-100 text-slate-400 border-slate-200' },
cooldown: { zh: '考虑期', tone: 'bg-amber-50 text-amber-700 border-amber-200' },
ineligible: { zh: '非修复', tone: 'bg-slate-100 text-slate-400 border-slate-200' },
suppressed: { zh: '被压制', tone: 'bg-slate-100 text-slate-500 border-slate-200' },
......@@ -101,6 +107,11 @@ function toothBase(t: string): string {
const m = /^(\d{1,2}[A-E]?)/i.exec(t.trim());
return m ? m[1]!.toUpperCase() : t.trim().toUpperCase();
}
// 合法牙位 token:数字开头 + ≥2 字符(口径同后端 util.isValidToothToken)。
// 裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")丢弃 → 不造幽灵召回。
function isValidTooth(t: string): boolean {
return t.length >= 2 && /^\d/.test(t);
}
function factTeeth(c: Record<string, unknown>): string[] {
const tp = c.tooth_positions;
const raw = Array.isArray(tp) ? tp.join(';') : String(c.tooth_position ?? tp ?? '');
......@@ -108,7 +119,8 @@ function factTeeth(c: Record<string, unknown>): string[] {
.split(/[;,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map(toothBase);
.map(toothBase)
.filter(isValidTooth);
}
type Rule = {
......@@ -144,6 +156,10 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
t: number;
iso: string;
}> = [];
// 每颗牙的"最晚真实诊断"时间戳 —— 用于"同牙位最新诊断取代旧诊断"。
const maxDxByTooth = new Map<string, number>();
// 每颗牙的"最晚真实建议"时间戳 —— 用于"诊断 vs 建议冲突,以建议(医生决定)为准"。
const maxRecByTooth = new Map<string, number>();
for (const f of facts) {
const c = (f.content ?? {}) as Record<string, unknown>;
......@@ -180,6 +196,32 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
if (vt > daysAgoMs(POST_VISIT_COOLDOWN_DAYS)) recentVisit = true;
}
}
// ⭐ 同牙位最新诊断 —— 收每颗牙的【最晚真实诊断】时间(任意真实码,不止召回码)。
// 用于"后续诊断取代旧诊断":某牙存在比信号更晚的真实诊断 → 旧信号对该牙失效。
// 只收【结构码】的诊断/建议(牙周 K05/K06 / 正畸 K07 不取代结构病)
if (f.type === 'diagnosis_record' && f.status === 'active') {
const code = String(c.code ?? '').trim();
const iso = f.occurredAt ?? f.plannedFor;
if (STRUCTURAL_CODES.has(code) && iso) {
const ts = new Date(iso).getTime();
for (const t of factTeeth(c)) {
const cur = maxDxByTooth.get(t);
if (cur == null || ts > cur) maxDxByTooth.set(t, ts);
}
}
}
// 每颗牙最晚【结构建议】(医生治疗决定)→ 诊断 vs 建议冲突时压过诊断
if (f.type === 'recommendation_record' && f.status === 'active') {
const code = String(c.code ?? '').trim();
const iso = f.occurredAt ?? f.plannedFor;
if (STRUCTURAL_CODES.has(code) && iso) {
const ts = new Date(iso).getTime();
for (const t of factTeeth(c)) {
const cur = maxRecByTooth.get(t);
if (cur == null || ts > cur) maxRecByTooth.set(t, ts);
}
}
}
// 信号(诊断 / 推荐)
if (
(f.type === 'diagnosis_record' || f.type === 'recommendation_record') &&
......@@ -211,8 +253,21 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
let kind: OracleVerdictKind;
let detail: string;
// ④' 废用牙
if (isRestorationIneligibleDxName(nameZh)) {
// ⭐ 同牙位取代规则(wholeMouth toothFilter=null 不走此路径):
// (b) 该牙存在更晚的真实诊断 → 旧信号失效(深龋→缺失、龋→牙髓炎、复发只认最新)。
// (c) 诊断 vs 建议冲突,以建议(医生决定)为准:本 sig 是诊断 且 该牙有 ≥ 本诊断时间的建议 → 诊断失效。
const sigIsDx = /^K\d/i.test(sig.code);
const laterDx = toothFilter != null ? maxDxByTooth.get(toothFilter) : undefined;
const recOnTooth = toothFilter != null ? maxRecByTooth.get(toothFilter) : undefined;
const supersededByDx = laterDx != null && laterDx > sig.t;
const supersededByRec = sigIsDx && recOnTooth != null && recOnTooth >= sig.t;
if (supersededByDx || supersededByRec) {
kind = 'superseded';
detail = supersededByRec
? `被该牙建议(医生治疗决定)取代,非实际治疗`
: `被该牙后续诊断取代(同牙位以最新诊断为准),非实际治疗`;
} else if (isRestorationIneligibleDxName(nameZh)) {
// ④' 废用牙
kind = 'ineligible';
detail = `「${nameZh}」非修复对象(该拔除/观察)`;
} else {
......@@ -334,7 +389,8 @@ export function reconcile(
prodSet.add(`${s.subKey}|${wholeLaneForSub(s.subKey)}`);
} else {
for (const t of teethRaw.split(/[;,]+/).map((x) => x.trim()).filter(Boolean)) {
prodSet.add(`${s.subKey}|${toothBase(t)}`);
const tb = toothBase(t);
if (isValidTooth(tb)) prodSet.add(`${s.subKey}|${tb}`);
}
}
}
......@@ -345,6 +401,7 @@ export function reconcile(
suppressed: 4,
cooldown: 3,
resolved: 2,
superseded: 2,
ineligible: 1,
};
const oracleByKey = new Map<string, OracleVerdict>();
......
'use client';
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { diagnosisCodeNameZh, treatmentCategoryNameZh, lookupDxTreatment } from '@pac/types';
import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data';
import type { PlanReason } from './mock-data';
......@@ -64,6 +64,13 @@ export function ToothTimeline({
lanes.set(k, arr);
};
for (const f of clinical) {
// ⭐ 全口病(牙周 K05 / 正畸 K07 诊断、periodontic/orthodontic 治疗)无视 dx 自带牙位,
// 直接进全口泳道 —— 跟召回 wholeMouth 口径一致。否则慢性牙龈炎(全口诊断列了 28 颗牙)
// 会被拆进每颗牙,让 11/12/13 等只有全口病的牙位凭空出现单独泳道(显示假象)。
if (isWholeMouthFact(f)) {
push(wholeMouthLane(f), f);
continue;
}
const teeth = factTeeth(f);
if (teeth.length > 0) {
for (const t of teeth) push(t, f);
......@@ -313,3 +320,20 @@ function wholeMouthLane(f: AdaptedFact): string {
if (cat === 'orthodontic' || code === 'K07') return WHOLE_ORTHO;
return WHOLE_OTHER;
}
/// 全口病:无视 fact 自带牙位,归全口泳道(跟召回 wholeMouth 口径一致)。
/// - 诊断/建议:code 的 wholeMouth 标志(K05 牙周 / K07 正畸 / SRP_/ORTHO_CONSULT_RECOMMENDED)
/// - 治疗:category = periodontic / orthodontic(洁牙/刮治/正畸 全口性)
/// K06(牙龈/牙槽嵴)非 wholeMouth → 仍按牙;K02 龋等结构病按牙。
function isWholeMouthFact(f: AdaptedFact): boolean {
const c = (f.content ?? {}) as Record<string, unknown>;
if (f.type === 'treatment_record') {
const cat = String(c.category ?? '');
return cat === 'periodontic' || cat === 'orthodontic';
}
if (f.type === 'diagnosis_record' || f.type === 'recommendation_record') {
const code = String(c.code ?? '');
return !!lookupDxTreatment(code)?.wholeMouth;
}
return false;
}
......@@ -274,7 +274,11 @@ export const STRUCTURAL_RESOLVER_CATEGORIES = [
] as const satisfies readonly PACTreatmentCategory[];
/// 走"结构家族 resolver"的诊断码 / 推荐码(其余 K05/K06/K07 等沿用 rule.categories)
const STRUCTURAL_DX_CODES = new Set<string>([
/// 结构性诊断/推荐码(按牙的牙体/牙髓/缺牙/外科问题)。
/// 用途①:resolverCategoriesFor 区分结构 vs 牙周/正畸。
/// 用途②:同牙位"取代"规则 —— 只有【结构码】能取代结构诊断;牙周(K05/K06)/正畸(K07)
/// 是不同组织的并存病,不互相 moot(否则全口牙周诊断会误销一片龋齿)。
export const STRUCTURAL_DX_CODE_LIST = [
'K00',
'K01',
'K02',
......@@ -290,6 +294,9 @@ const STRUCTURAL_DX_CODES = new Set<string>([
'HARD_TISSUE_REPAIR_RECOMMENDED',
'ERUPTION_INTERVENTION_RECOMMENDED',
'JAW_CYST_REMOVAL_RECOMMENDED',
] as const;
const STRUCTURAL_DX_CODES = new Set<string>([
...STRUCTURAL_DX_CODE_LIST,
]);
/**
......
......@@ -65,12 +65,41 @@ export const PatientCanonicalSchema = z
tags: z.array(z.string()).optional().default([]),
/// 产品收集:host 备注 / 客户描述自由文本
notes: z.string().optional().nullable(),
/// 产品收集:主要联系人身份(self/spouse/parent/child/guardian/other)
primaryContactType: z.string().optional().nullable(),
})
.passthrough();
export type PatientCanonical = z.infer<typeof PatientCanonicalSchema>;
/// patient_relation canonical — 患者-患者关系边(联系人/亲属/转介绍人)。
/// upsert 形态(无 emits,同 patient)。关系人本身也是 patient(FHIR RelatedPerson/link)。
export const PatientRelationCanonicalSchema = z
.object({
/// 本人 host id(referee.patient_id)
patientExternalId: z.string().min(1),
/// 关系人 host id(referee.referee_patient_id)
relatedExternalId: z.string().min(1),
/// 关系类型(已归一:mother/father/grandparent/spouse/child/sibling/friend/other)
relationship: z.string().min(1),
})
.passthrough();
export type PatientRelationCanonical = z.infer<typeof PatientRelationCanonicalSchema>;
/// patient_return_visit canonical — 诊所回访任务记录(展示用,5 试点)。upsert 形态(无 emits)。
export const PatientReturnVisitCanonicalSchema = z
.object({
externalId: z.string().min(1),
patientExternalId: z.string().min(1),
clinicId: z.string().optional().nullable(),
taskDate: z.string().optional().nullable(),
type: z.string().optional().nullable(),
status: z.string().optional().nullable(),
taskStatus: z.string().optional().nullable(),
treatmentItems: z.string().optional().nullable(),
followContent: z.string().optional().nullable(),
result: z.string().optional().nullable(),
})
.passthrough();
export type PatientReturnVisitCanonical = z.infer<typeof PatientReturnVisitCanonicalSchema>;
// ─────────────────────────────────────────────────────────
// 3.4.2 appointments
// ─────────────────────────────────────────────────────────
......@@ -300,6 +329,8 @@ export const DiagnosisCanonicalSchema = z
toothPosition: z.string().optional().nullable(),
severity: z.string().optional().nullable(),
onsetDate: z.string().optional().nullable(),
/// 显式来源声明(如影像 AI 'image_ai');parser 优先用它,否则按 stdCode 推断 std_code/name_map
codeSource: z.string().optional().nullable(),
})
.passthrough();
export type DiagnosisCanonical = z.infer<typeof DiagnosisCanonicalSchema>;
......@@ -427,6 +458,8 @@ export type ReferralCanonical = z.infer<typeof ReferralCanonicalSchema>;
*/
export const CanonicalResourceSchemas = {
patient: PatientCanonicalSchema,
patient_relation: PatientRelationCanonicalSchema,
patient_return_visit: PatientReturnVisitCanonicalSchema,
consultation: ConsultationCanonicalSchema,
appointment: AppointmentCanonicalSchema,
visit_registration: VisitRegistrationCanonicalSchema,
......@@ -472,6 +505,18 @@ export const CanonicalResourceMeta: Record<
datetimeFields: ['createdAt', 'updatedAt'],
booleanFields: ['doNotContact', 'deceased'],
},
patient_relation: {
moneyFields: [],
moneyArrayFields: [],
datetimeFields: [],
booleanFields: [],
},
patient_return_visit: {
moneyFields: [],
moneyArrayFields: [],
datetimeFields: [],
booleanFields: [],
},
consultation: {
moneyFields: [],
moneyArrayFields: [],
......
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