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: ...@@ -261,5 +261,22 @@ enum_mapping:
骨性Ⅱ类: K07 骨性Ⅱ类: K07
骨性Ⅲ类: K07 骨性Ⅲ类: K07
# 兜底:翻不出 → 空 → parser 落 code=null(不丢 fact) # 兜底:翻不出 → 交下面 keyword_mapping 含词兜底;再不中 → 空 → code=null(不丢 fact)
_default: "" _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: ...@@ -74,4 +74,23 @@ enum_mapping:
建议正畸: ORTHO_CONSULT_RECOMMENDED 建议正畸: ORTHO_CONSULT_RECOMMENDED
建议矫正: 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: ...@@ -445,19 +445,27 @@ keyword_mapping:
# ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。 # ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。
# 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。 # 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。
- { value: implant, any: [种植, 即拔即种, 植体, 种植二期] } - { value: implant, any: [种植, 即拔即种, 植体, 种植二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] } - { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓, 根尖] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓] } - { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓, 错颌, 错合] }
- { value: cosmetic, any: [贴面, 漂白, 美白] } - { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] } # 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
# 窝沟封闭提到 restorative 前:玻璃离子常作封闭剂材料("玻璃离子窝沟封闭"≠充填) # 排在 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: preventive, any: [窝沟封闭, 封闭剂, 点隙封闭] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] } # 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
- { value: restorative, any: [树脂, 玻璃离子] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜 # 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] } - { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] } - { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] } - { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] } - { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。 # actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。 # 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑] keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗
...@@ -176,6 +176,59 @@ sql_source: ...@@ -176,6 +176,59 @@ sql_source:
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
) )
# ── 影像 AI 分析(fact_emr_image_analysis_out)→ 诊断信号源(image_finding) ──
# 结构化 AI 源,例外地在源 SQL 一次性 pivot+炸牙位(下游零 transform):
# ① join file_num→client 取 patient_id+brand(image 表无 patient_id);
# ② LEFT JOIN 取患者 EMR 的 organization_id 作诊所(image 无诊所,clinic 是 transaction 立柱必填;覆盖 ~98%);
# ③ ARRAY JOIN 把 10 个病种列(每列一个牙位数组字符串 "['38']")pivot 成 (code, 数组);
# ④ replaceRegexpAll 去括号引号 + splitByChar + arrayJoin 炸成每颗牙一行;
# ⑤ code_source='image_ai' 标来源(diagnosis.parser 显式优先);externalId 带 |imgai| → 独立 subject。
# 病种→K 码映射(host 数据形态,留在 manifest yaml):cavity→K02 / 阻生·埋伏→K01 / 根尖周→K04 /
# 残根·残冠·楔状→K03 / 囊肿→K09 / 缺失→K08 / 乳牙滞留→K00。去重靠召回层 (subKey,tooth) 聚类。
image_finding_rows: |
SELECT patient_id, brand, organization_id, emr_id, rq, code, code_source, tooth,
concat(emr_id, '|imgai|', code, '|', tooth) AS diag_external_id
FROM (
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, cm.1 AS code, 'image_ai' AS code_source,
arrayJoin(splitByChar(',', replaceRegexpAll(cm.2, '[\[\] '']', ''))) AS tooth
FROM dw_group.fact_emr_image_analysis_out ia
INNER JOIN dw_group.fact_client_out c ON c.file_num = ia.file_num AND c.brand = ia.brand
LEFT JOIN (
SELECT patient_id, brand, any(organization_id) AS org
FROM dw_group.fact_emr_treatment_out WHERE notEmpty(organization_id)
GROUP BY patient_id, brand
) po ON po.patient_id = c.patient_id AND po.brand = c.brand
ARRAY JOIN [('K02', ia.cavity), ('K01', ia.impacted_tooth), ('K01', ia.embedded_tooth),
('K04', ia.root_periodontitis), ('K03', ia.root_remnant), ('K03', ia.crown_remnant),
('K03', ia.wedge_shaped_defect), ('K09', ia.cyst), ('K08', ia.tooth_loss),
('K00', ia.retained_primary_tooth)] AS cm
WHERE c.last_visit_time IS NOT NULL AND notEmpty(po.org) AND cm.2 != '[]' AND cm.2 != ''
)
WHERE tooth != ''
# ── 诊所回访任务(fact_returnvisit_out)→ patient_return_visit upsert(展示用,5 试点)──
# customer_id AS patient_id(让 cohort 过滤生效);WHERE org ∈ 5 试点(= EMR 表的 org)→ 只摄 5 家。
# 非召回信号,详情页"回访记录"块展示(常规/术后/咨询回访)。
fact_returnvisit_out: |
SELECT id, customer_id AS patient_id, brand, organization_id, task_date,
return_visit_type_name, return_visit_status_name, task_status_name,
treatment_items, follow_content, return_visit_result
FROM dw_group.fact_returnvisit_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
# ── 客户-推荐人关系(联系人/亲属边)→ patient_relation upsert ──
# 一行 = 本人 与 一个关系人(referee)的关系;referee_patient_id=0 = 无关系人,剔除。
# 本人(patient_id)限 active client(= PAC patient 才能挂靠);关系人不限(解析得到则填 link)。
fact_customer_referee_out: |
SELECT patient_id, referee_patient_id, referee_relationship, referee_relationship_name, brand
FROM dw_group.fact_customer_referee_out
WHERE referee_patient_id != 0
AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Layer A.5 transforms(SQL 干过的事搬这里,yaml 声明式) # Layer A.5 transforms(SQL 干过的事搬这里,yaml 声明式)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
...@@ -356,9 +409,10 @@ transforms: ...@@ -356,9 +409,10 @@ transforms:
starts_with: ['建议', '推荐'] starts_with: ['建议', '推荐']
# 流程性 / 复查 / 行政事项 → treatment_review_rows(本次非治疗事件;chain S4 信号) # 流程性 / 复查 / 行政事项 → treatment_review_rows(本次非治疗事件;chain S4 信号)
# 注:equals 关键词都是 normalize 后的 ASCII 形态(中标点已折叠);不要在这里写中括号/中逗号 # 注:equals 关键词都是 normalize 后的 ASCII 形态(中标点已折叠);不要在这里写中括号/中逗号
# &review_terms:YAML anchor — C.9 dispose gate 复用此表(单一真理源,判"treat_plan 无真治疗")
- output: _treatment_review_raw - output: _treatment_review_raw
when: when:
equals: equals: &review_terms
# 复查 / 检查 / 复诊 # 复查 / 检查 / 复诊
- 常规复查 - 常规复查
- 复查 - 复查
...@@ -513,19 +567,21 @@ transforms: ...@@ -513,19 +567,21 @@ transforms:
from: treat_name from: treat_name
value: '' value: ''
# ── C.9 dispose(处置自由文本)→ 补 actual 治疗(仅 treat_plan 空时)── # ── C.9 dispose(处置自由文本)→ 补 actual 治疗(仅 treat_plan 无真治疗时)──
# 背景:~15% EMR 只有处置、treat_plan 空,处置里其实已做治疗(充填/拔除/根管…)。 # 背景:处置里常已做治疗(充填/拔除/根管…)。两种关键场景:
# 思路(最简):treat_plan 空时拆 dispose,把 message 当作 treat_name **直接 union 进 # ① treat_plan 空 / 仅复查:处置是唯一真治疗源(陈昱天 17:treat_plan=["常规复查"],dispose 树脂充填@17)
# treatment_actual_rows** → 复用 treatment_actual.yaml 的 keyword_mapping(含词分类) # ② treat_plan 有真治疗:处置 ~63% 是同次重复叙述(同牙同类)→ 不能再抽(否则双计)
# + treatment.parser → 落普通 treatment_completed。不加 assembler/parser/置信度。 # gate:treat_plan **空 或 全是复查类**(blank_or_all_in &review_terms)才抽 dispose。
# gate(treat_plan 空,含 "[]")避免与结构化重复(全量实测 treat_plan 非空时处置 ~63% 是同次重复叙述)。 # —— 比旧「空才抽」放宽:场景①(treat_plan 仅"常规复查")现在也抽 → 找回被丢的真治疗(解陈昱天误召);
# 场景②(treat_plan 有真治疗)仍不抽 → 源头不产生重复(fact-writer 终态不去重,必须源头防)。
# 为何不在 parser 去重:treat_plan/dispose 双记是 host 录入习惯(host 差异)→ 处置归 yaml,parser 保持宿主无关。
- kind: filter - kind: filter
input: fact_emr_treatment_out input: fact_emr_treatment_out
output: _emr_dispose_only output: _emr_dispose_gate
where: where:
treat_plan: { empty: true } treat_plan: { blank_or_all_in: *review_terms }
- kind: split_json_array - kind: split_json_array
input: _emr_dispose_only input: _emr_dispose_gate
output: _dispose_raw output: _dispose_raw
array_field: dispose array_field: dispose
parent_keys: parent_keys:
...@@ -550,15 +606,102 @@ transforms: ...@@ -550,15 +606,102 @@ transforms:
treat_external_id: treat_external_id:
op: concat op: concat
parts: ['${emr_id}', '|disp|', '${tooth_position}', '|', '${treat_name}'] parts: ['${emr_id}', '|disp|', '${tooth_position}', '|', '${treat_name}']
category_raw: # = message;treatment_actual.yaml enum 未命中 → keyword_mapping 含词分类 category_raw: # = message 归一;normalize 折叠 CJK 标点 → keyword strip 切段(建议/必要时)生效
op: default op: normalize # 否则 "待观察,若…,建议充填治疗" 全角逗号不切段 → strip 失效 → 误判 restorative
from: treat_name from: treat_name
value: ''
# union 进现有 treatment_actual_rows → 走同一 assembler + parser,落 treatment_completed # union 进现有 treatment_actual_rows → 走同一 assembler + parser,落 treatment_completed
- kind: union - kind: union
inputs: ['treatment_actual_rows', '_dispose_tx'] inputs: ['treatment_actual_rows', '_dispose_tx']
output: treatment_actual_rows output: treatment_actual_rows
# ── C.9b dispose path 2:treat_plan 有真治疗但【全无牙位】→ 去 dispose 找回牙位 ──
# 背景:医生在 treat_plan 写了治疗名(充填)却漏填 toothPosition,而牙位写进了 dispose
# (刘小源:treat_plan=[{treatName:"充填",toothPosition:""}],dispose 16;26 树脂充填)。
# path 1(blank_or_all_in)不命中(treat_plan 非空非复查)→ 牙位丢失 → 落无牙位充填,解不了任何牙。
# gate:real_no_tooth —— 有真治疗(treatName ∉ review)且所有元素都无牙位。
# 排除空/全复查(path 1 已处理,不重复抽 dispose 双计);排除任一元素已带牙位(treat_plan 牙位齐 → 不抽)。
# ⭐ dispose 只留【带牙位】条目(tooth_position not_empty)→ 全口牙周/正畸(dispose 多无牙位)不被抽,
# 避免与 treat_plan 全口治疗双计;只把结构治疗(充填@16;26)的牙位找回。host 录入习惯归 yaml。
- kind: filter
input: fact_emr_treatment_out
output: _emr_dispose_gate2
where:
treat_plan: { real_no_tooth: *review_terms }
- kind: split_json_array
input: _emr_dispose_gate2
output: _dispose_raw2
array_field: dispose
parent_keys:
emr_id: id
patient_id: patient_id
organization_id: organization_id
brand: brand
rq: rq
user_id: user_id
doctor_name: doctor_name
created_date: created_date
updated_date: updated_date
element_fields:
treat_name: message
tooth_position: toothPosition
where:
treat_name: { not_empty: true }
tooth_position: { not_empty: true } # ⭐ 只留带牙位的 dispose(限制双计)
- kind: derive
input: _dispose_raw2
output: _dispose_tx2
fields:
treat_external_id:
op: concat
parts: ['${emr_id}', '|disp2|', '${tooth_position}', '|', '${treat_name}']
category_raw: # 归一(同 C.9/C.10):CJK 标点折叠 → strip 切段(建议/必要时/若…)生效
op: normalize
from: treat_name
- kind: union
inputs: ['treatment_actual_rows', '_dispose_tx2']
output: treatment_actual_rows
# ── C.10 ⭐ diag 里的"已治疗状态"(X术后/已充填)→ 补 actual 治疗【证据】 ──
# 背景:医生 charting 常把"已补/已根管/已种植"等【已完成治疗的状态】记进 diag 字段
# (谢玉兰 24;25 = "充填术后")。它们 code=null,作为信号本就不召;
# 但【必须 resolve 之前同牙的诊断】(如 2023 深龋),否则旧诊断永远无 resolver → 误召。
# ⚠️ 关键:不是"当噪音丢掉"(丢掉=旧深龋还在召),而是"认成已治疗"→ 去解决旧诊断。
# 思路(复用 dispose 同款机器):message 当 treat_name → treatment_actual.yaml keyword_mapping
# (充填术后→restorative、根管术后→endodontic、种植术后→implant、拔牙术后→surgical)
# → 落 treatment_completed(occurredAt=本次到访)→ ≥ 之前诊断 → family resolver 自然解决。
# host charting 习惯归 yaml,召回算法零改动。
- kind: route_by_pattern
input: _diagnosis_raw
field: message
routes:
- output: _post_treatment_raw
when:
# 已完成态(术后/已X/X后)+ 进行态(治疗中/术中/粘结中)都收:
# 本场景=treatment_initiation_recall(未启动),"根管治疗中"=已启动 → 不该召"未启动"。
# ⚠️ 必须用【复合安全词】,避开裸"后/中"误伤(后牙/中切牙/不良修复体/下颌后缩)。
# "修复后"≠"不良修复体"(后 vs 体),特意不用裸"修复"。
contains: ['术后', '已充填', '充填后', '治疗后', '治疗中', '术中',
'修复后', '备牙后', '拔牙后', '戴牙后', '取模后', '粘结中', '修整后']
- output: _post_treatment_discard # 其余诊断不在此处理(走上面 B → diagnosis_rows)
when:
default: true
- kind: derive
input: _post_treatment_raw
output: _post_tx
fields:
treat_name: # diag 的 message 当治疗名 → 交 keyword_mapping 含词分类
op: normalize # 对齐主路径 C.2.5:全角→半角 + 标点折叠(RCT治疗后→RCT…)
from: message
treat_external_id:
op: concat
parts: ['${emr_id}', '|post|', '${tooth_position}', '|', '${message}']
category_raw: # ⭐ 真正喂分类器的字段(treatment_actual.yaml: category←category_raw)
op: normalize # 必须归一,否则全角/中标点变体 enum+keyword 全漏 → category 空 → 丢
from: message
- kind: union
inputs: ['treatment_actual_rows', '_post_tx']
output: treatment_actual_rows
# ── E. EMR.file_url JSON 拆行 → 影像 metadata(image_record) ── # ── E. EMR.file_url JSON 拆行 → 影像 metadata(image_record) ──
# file_url 元素:{ check_name(影像类型), file_url(存储路径), created_gmt_at(拍摄时间) } # file_url 元素:{ check_name(影像类型), file_url(存储路径), created_gmt_at(拍摄时间) }
# PAC 不持文件本体,只落 metadata:类型 / 时间 / 关联接诊 → "拍过 CBCT=种植意向" 类信号 # PAC 不持文件本体,只落 metadata:类型 / 时间 / 关联接诊 → "拍过 CBCT=种植意向" 类信号
...@@ -692,9 +835,12 @@ transforms: ...@@ -692,9 +835,12 @@ transforms:
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
assemblers: assemblers:
- { file: assemblers/patient.yaml } - { file: assemblers/patient.yaml }
- { file: assemblers/patient_relation.yaml } # 联系人/亲属边(referee → PatientRelation upsert)
- { file: assemblers/patient_return_visit.yaml } # 诊所回访(展示用,5 试点 → PatientReturnVisit upsert)
- { file: assemblers/encounter.yaml } - { file: assemblers/encounter.yaml }
- { file: assemblers/appointment.yaml } - { file: assemblers/appointment.yaml }
- { file: assemblers/diagnosis.yaml } - { file: assemblers/diagnosis.yaml }
- { file: assemblers/image_finding.yaml } # 影像 AI 分析 → diagnosis_record(code_source=image_ai)
- { file: assemblers/treatment_planned.yaml } - { file: assemblers/treatment_planned.yaml }
- { file: assemblers/treatment_review.yaml } - { file: assemblers/treatment_review.yaml }
- { file: assemblers/treatment_actual.yaml } - { file: assemblers/treatment_actual.yaml }
......
/*
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;
...@@ -201,6 +201,12 @@ model Patient { ...@@ -201,6 +201,12 @@ model Patient {
facts PatientFact[] facts PatientFact[]
personas Persona[] personas Persona[]
plans FollowupPlan[] plans FollowupPlan[]
/// 我的联系人/亲属边(本人 关系人);关系人本身也是 patient
relationsOut PatientRelation[] @relation("PatientRelationsOut")
/// 把我当联系人的边(别人 )
relationsIn PatientRelation[] @relation("PatientRelationsIn")
/// 诊所回访任务记录(展示用,5 试点;非召回信号)
returnVisits PatientReturnVisit[]
/// 跨同步 upsert 去重:同一物理人在集团内共享一行 /// 跨同步 upsert 去重:同一物理人在集团内共享一行
@@unique([hostId, tenantId, externalId]) @@unique([hostId, tenantId, externalId])
...@@ -217,7 +223,8 @@ model Patient { ...@@ -217,7 +223,8 @@ model Patient {
/// ///
/// 拆出来的理由: /// 拆出来的理由:
/// - 主表 Patient 只装"身份"(identity:name/phone/dob ) 跨表查询时 JOIN 主表更轻 /// - 主表 Patient 只装"身份"(identity:name/phone/dob ) 跨表查询时 JOIN 主表更轻
/// - 合规字段(doNotContact / deceased / DNC)+ 描述字段(tags / notes / primaryContactType)集中 /// - 合规字段(doNotContact / deceased / DNC)+ 描述字段(tags / notes)集中
/// - 联系人/亲属:不在此(已废 primaryContactType) PatientRelation 边表
/// - 召回池硬过滤走 patient_profiles JOIN(逻辑独立、维护清晰) /// - 召回池硬过滤走 patient_profiles JOIN(逻辑独立、维护清晰)
model PatientProfile { model PatientProfile {
/// 1:1 patientId PK(也兼 FK) /// 1:1 patientId PK(也兼 FK)
...@@ -242,10 +249,6 @@ model PatientProfile { ...@@ -242,10 +249,6 @@ model PatientProfile {
/// notes 单段长文本(客服备注 / 诊所自由描述) /// notes 单段长文本(客服备注 / 诊所自由描述)
notes String? @db.Text 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) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
...@@ -256,6 +259,84 @@ model PatientProfile { ...@@ -256,6 +259,84 @@ model PatientProfile {
@@map("patient_profiles") @@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) /// 患者操作账本(append-only)
/// 一条记录回答:**"在哪个宿主、哪个 tenant、哪个诊所、对哪个主体,做了什么动作"** /// 一条记录回答:**"在哪个宿主、哪个 tenant、哪个诊所、对哪个主体,做了什么动作"**
/// 原始证据存在 rawPayload(宿主原文),canonical 化和事实视图在 patient_facts 层做。 /// 原始证据存在 rawPayload(宿主原文),canonical 化和事实视图在 patient_facts 层做。
......
...@@ -29,7 +29,6 @@ export interface FeatureExtractorContext { ...@@ -29,7 +29,6 @@ export interface FeatureExtractorContext {
deceased: boolean; deceased: boolean;
tags: string[]; tags: string[];
notes: string | null; notes: string | null;
primaryContactType: string | null;
} | null; } | null;
/// 该 patient 的所有 active facts(按 type 索引方便查) /// 该 patient 的所有 active facts(按 type 索引方便查)
factsByType: Map<string, ActiveFact[]>; factsByType: Map<string, ActiveFact[]>;
......
...@@ -168,7 +168,6 @@ export class PersonaService { ...@@ -168,7 +168,6 @@ export class PersonaService {
deceased: patient.profile.deceased, deceased: patient.profile.deceased,
tags: patient.profile.tags, tags: patient.profile.tags,
notes: patient.profile.notes, notes: patient.profile.notes,
primaryContactType: patient.profile.primaryContactType,
} }
: null, : null,
factsByType, factsByType,
......
...@@ -37,7 +37,15 @@ export class PlanAggregateService { ...@@ -37,7 +37,15 @@ export class PlanAggregateService {
} }
const patient = await this.prisma.patient.findUnique({ const patient = await this.prisma.patient.findUnique({
where: { id: plan.patientId }, 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`); if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
...@@ -70,7 +78,13 @@ export class PlanAggregateService { ...@@ -70,7 +78,13 @@ export class PlanAggregateService {
private async assemble( private async assemble(
scope: TenantScopeContext, 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, plan: Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }> | null,
) { ) {
const persona = await this.loadCurrentPersona(patient.id); const persona = await this.loadCurrentPersona(patient.id);
...@@ -143,6 +157,15 @@ export class PlanAggregateService { ...@@ -143,6 +157,15 @@ export class PlanAggregateService {
facts: facts.map(serializeFact), facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null, script: script ? serializeScript(script) : null,
recallHistory, 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( ...@@ -276,9 +299,14 @@ function serializeProfile(
profile: { profile: {
doNotContact: boolean; doNotContact: boolean;
deceased: boolean; deceased: boolean;
primaryContactType: string | null;
notes: string | null; notes: string | null;
} | 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 }>, allFacts: Array<{ type: string; content: Prisma.JsonValue; occurredAt: Date | null }>,
encounters: Array<{ occurredAt: Date | null; content: Prisma.JsonValue }>, encounters: Array<{ occurredAt: Date | null; content: Prisma.JsonValue }>,
...@@ -312,10 +340,21 @@ function serializeProfile( ...@@ -312,10 +340,21 @@ function serializeProfile(
), ),
).slice(0, 2); ).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 { return {
doNotContact: patient.profile.doNotContact, doNotContact: patient.profile.doNotContact,
deceased: patient.profile.deceased, deceased: patient.profile.deceased,
primaryContactType: patient.profile.primaryContactType,
notes: patient.profile.notes, notes: patient.profile.notes,
ltvCents, ltvCents,
paymentCount: payments.length, paymentCount: payments.length,
...@@ -325,9 +364,23 @@ function serializeProfile( ...@@ -325,9 +364,23 @@ function serializeProfile(
lastVisit: latestEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null, lastVisit: latestEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null,
daysSinceLatestVisit, daysSinceLatestVisit,
doctors, doctors,
contacts,
}; };
} }
/// 关系枚举 → 中文展示标签(本人视角:对方是本人的 X)
const RELATIONSHIP_LABEL: Record<string, string> = {
mother: '妈妈',
father: '爸爸',
grandparent: '祖辈',
spouse: '配偶',
child: '子女',
sibling: '兄弟姐妹',
grandchild: '孙辈',
friend: '朋友',
other: '亲属',
};
function serializePlan(plan: { function serializePlan(plan: {
id: string; id: string;
version: number; version: number;
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
RESTORATION_INELIGIBLE_DX_NAMES, RESTORATION_INELIGIBLE_DX_NAMES,
lookupDxTreatment, lookupDxTreatment,
resolverCategoriesFor, resolverCategoriesFor,
STRUCTURAL_DX_CODE_LIST,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZh,
} from '@pac/types'; } from '@pac/types';
...@@ -280,12 +281,15 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -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} // "1D;1E;2D" → {1D,1E,2D}(乳牙保留) "1D OD;17 D" → {1D,17}(剥面) "16;46" → {16,46}
// 注:JS 模板里反向引用要写 '\\1'(传到 SQL 为 '\1');用 POSIX [[:space:]] 避开 \s 转义坑。 // 注: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) => 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(
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].')`;
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。 // 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
// ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor): // ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
// 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)— // 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)—
...@@ -299,15 +303,44 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -299,15 +303,44 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.empty ? Prisma.empty
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`; : Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
const resolvedTeethSql = Prisma.sql` const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT rtt), ARRAY[]::text[]) (SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
SELECT rtt AS t
FROM patient_facts rtx FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE rtx.patient_id = p.id WHERE rtx.patient_id = p.id
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual' AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled') AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != '' AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(afterDx) AND rtx.content->>'category' = ANY(${resolverCats}::text[])
${afterDxFragRtx})`; ${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 = 潜在治疗新链召回) ║ // ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
......
...@@ -338,14 +338,19 @@ export function classifyByKeyword( ...@@ -338,14 +338,19 @@ export function classifyByKeyword(
let text = value; let text = value;
if (stripClauses && stripClauses.length) { if (stripClauses && stripClauses.length) {
text = value text = value
.split(/[,;。]/) .split(/[,;。,;]/) // 含全角逗号/分号:防御未归一输入,strip 切段不漏(否则整句一段 → 建议/若 没剥)
.map((s) => s.trim()) .map((s) => s.trim())
.filter((seg) => seg && !stripClauses.some((p) => seg.startsWith(p))) .filter((seg) => seg && !stripClauses.some((p) => seg.startsWith(p)))
.join(','); .join(',');
} }
if (!text) return undefined; if (!text) return undefined;
for (const rule of rules) { 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; return undefined;
} }
......
...@@ -37,6 +37,8 @@ const KeywordRuleSchema = z.object({ ...@@ -37,6 +37,8 @@ const KeywordRuleSchema = z.object({
value: z.string(), value: z.string(),
/// 含任一词即命中(子串 includes) /// 含任一词即命中(子串 includes)
any: z.array(z.string().min(1)).min(1), 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>; export type KeywordRule = z.infer<typeof KeywordRuleSchema>;
const keywordMappingForField = z.array(KeywordRuleSchema); const keywordMappingForField = z.array(KeywordRuleSchema);
......
...@@ -193,7 +193,15 @@ export class ColdImportService { ...@@ -193,7 +193,15 @@ export class ColdImportService {
// 5. 加载 assembler configs(一次,全 cohort batch 共用) // 5. 加载 assembler configs(一次,全 cohort batch 共用)
const assemblerConfigs = this.loadAllAssemblers(absDir, manifest); const assemblerConfigs = this.loadAllAssemblers(absDir, manifest);
const patientCfg = assemblerConfigs.find((c) => c.canonical === 'patient'); const patientCfg = assemblerConfigs.find((c) => c.canonical === 'patient');
const subjectCfgs = assemblerConfigs.filter((c) => c.canonical !== 'patient'); const patientRelationCfg = assemblerConfigs.find((c) => c.canonical === 'patient_relation');
const patientReturnVisitCfg = assemblerConfigs.find((c) => c.canonical === 'patient_return_visit');
// patient + patient_relation 都是 upsert 资源(非 transaction)→ 排除出 subjectCfgs
const subjectCfgs = assemblerConfigs.filter(
(c) =>
c.canonical !== 'patient' &&
c.canonical !== 'patient_relation' &&
c.canonical !== 'patient_return_visit',
);
const normalize = { amountUnit: manifest.amount_unit, timezone: manifest.timezone }; const normalize = { amountUnit: manifest.amount_unit, timezone: manifest.timezone };
// 6. 决定 cohort 模式: // 6. 决定 cohort 模式:
...@@ -246,6 +254,8 @@ export class ColdImportService { ...@@ -246,6 +254,8 @@ export class ColdImportService {
seenTenants, seenTenants,
normalize, normalize,
patientCfg, patientCfg,
patientRelationCfg,
patientReturnVisitCfg,
subjectCfgs, subjectCfgs,
syncLogId: syncLog?.id, syncLogId: syncLog?.id,
dryRun: options.dryRun ?? false, dryRun: options.dryRun ?? false,
...@@ -312,6 +322,8 @@ export class ColdImportService { ...@@ -312,6 +322,8 @@ export class ColdImportService {
seenTenants, seenTenants,
normalize, normalize,
patientCfg, patientCfg,
patientRelationCfg,
patientReturnVisitCfg,
subjectCfgs, subjectCfgs,
syncLogId: syncLog?.id, syncLogId: syncLog?.id,
dryRun: options.dryRun ?? false, dryRun: options.dryRun ?? false,
...@@ -463,7 +475,14 @@ export class ColdImportService { ...@@ -463,7 +475,14 @@ export class ColdImportService {
// 4. Assemblers // 4. Assemblers
const assemblerConfigs = this.loadAllAssemblers(absDir, manifest); const assemblerConfigs = this.loadAllAssemblers(absDir, manifest);
const patientCfg = assemblerConfigs.find((c) => c.canonical === 'patient'); const patientCfg = assemblerConfigs.find((c) => c.canonical === 'patient');
const subjectCfgs = assemblerConfigs.filter((c) => c.canonical !== 'patient'); const patientRelationCfg = assemblerConfigs.find((c) => c.canonical === 'patient_relation');
const patientReturnVisitCfg = assemblerConfigs.find((c) => c.canonical === 'patient_return_visit');
const subjectCfgs = assemblerConfigs.filter(
(c) =>
c.canonical !== 'patient' &&
c.canonical !== 'patient_relation' &&
c.canonical !== 'patient_return_visit',
);
// 5. SyncLog — resource='patient_refresh' ⭐ 跟增量隔离 ⭐ // 5. SyncLog — resource='patient_refresh' ⭐ 跟增量隔离 ⭐
// tenantId 用 resolver 已知的(单 brand → 唯一 tenant);若动态 pass-through 用 sentinel // tenantId 用 resolver 已知的(单 brand → 唯一 tenant);若动态 pass-through 用 sentinel
...@@ -497,6 +516,22 @@ export class ColdImportService { ...@@ -497,6 +516,22 @@ export class ColdImportService {
if (!firstError && stats.failed > 0) firstError ||= `patient: ${stats.failed} rows failed`; if (!firstError && stats.failed > 0) firstError ||= `patient: ${stats.failed} rows failed`;
} }
if (patientRelationCfg) {
const stats = await this.processPatientRelations(
tables, patientRelationCfg, host.id, tenantResolver, normalize, false,
);
perResource.push(stats);
totals.failed += stats.failed;
}
if (patientReturnVisitCfg) {
const stats = await this.processPatientReturnVisits(
tables, patientReturnVisitCfg, host.id, tenantResolver, normalize, false,
);
perResource.push(stats);
totals.failed += stats.failed;
}
for (const cfg of subjectCfgs) { for (const cfg of subjectCfgs) {
try { try {
const stats = await this.processSubject( const stats = await this.processSubject(
...@@ -576,6 +611,8 @@ export class ColdImportService { ...@@ -576,6 +611,8 @@ export class ColdImportService {
seenTenants: Set<string>; seenTenants: Set<string>;
normalize: { amountUnit: 'fen' | 'yuan'; timezone: string }; normalize: { amountUnit: 'fen' | 'yuan'; timezone: string };
patientCfg: AssemblerConfig | undefined; patientCfg: AssemblerConfig | undefined;
patientRelationCfg: AssemblerConfig | undefined;
patientReturnVisitCfg: AssemblerConfig | undefined;
subjectCfgs: AssemblerConfig[]; subjectCfgs: AssemblerConfig[];
syncLogId: string | undefined; syncLogId: string | undefined;
dryRun: boolean; dryRun: boolean;
...@@ -637,6 +674,40 @@ export class ColdImportService { ...@@ -637,6 +674,40 @@ export class ColdImportService {
); );
} }
// 3.5 patient_relation(联系人/亲属边)— 须在 patients 之后(两端解析靠 patient 索引)
if (args.patientRelationCfg) {
const stats = await this.processPatientRelations(
tables,
args.patientRelationCfg,
args.host.id,
args.tenantResolver,
args.normalize,
args.dryRun,
);
args.perResource.push(stats);
args.totals.failed += stats.failed;
this.logger.log(
`patient_relation: upserted=${stats.patientsUpserted} failed=${stats.failed}`,
);
}
// 3.6 patient_return_visit(诊所回访,展示用)— 同样在 patients 之后(靠 patient 索引解析)
if (args.patientReturnVisitCfg) {
const stats = await this.processPatientReturnVisits(
tables,
args.patientReturnVisitCfg,
args.host.id,
args.tenantResolver,
args.normalize,
args.dryRun,
);
args.perResource.push(stats);
args.totals.failed += stats.failed;
this.logger.log(
`patient_return_visit: upserted=${stats.patientsUpserted} failed=${stats.failed}`,
);
}
// 4. 其他资源(transaction 合成 + parser 衍生 fact) // 4. 其他资源(transaction 合成 + parser 衍生 fact)
for (const cfg of args.subjectCfgs) { for (const cfg of args.subjectCfgs) {
try { try {
...@@ -741,12 +812,10 @@ export class ColdImportService { ...@@ -741,12 +812,10 @@ export class ColdImportService {
doNotContact: (c.doNotContact as boolean) ?? false, doNotContact: (c.doNotContact as boolean) ?? false,
deceased: (c.deceased as boolean) ?? false, deceased: (c.deceased as boolean) ?? false,
// canonical 字段(若 host 没给 → null/默认) // canonical 字段(若 host 没给 → null/默认)
// tags / notes / primaryContactType 需要从 canonical 拿;v2 canonical PatientSchema 暂不显式带 // tags / notes 需要从 canonical 拿;host 通过 host_tags / host_notes → assembler.yaml 映射,此处兜底
// host 通过 host_tags / host_notes 等字段 → assembler.yaml 映射到 canonical;此处兜底 // (primaryContactType 已废弃 → 联系人改走 PatientRelation 边表,processPatientRelations)
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [], tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null, notes: (c.notes as string | undefined) ?? null,
primaryContactType:
(c.primaryContactType as string | undefined) ?? null,
}; };
try { try {
...@@ -784,6 +853,172 @@ export class ColdImportService { ...@@ -784,6 +853,172 @@ export class ColdImportService {
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// patient_relation 资源 → upsert 患者-患者关系边(联系人/亲属)
// canonical: { patientExternalId, relatedExternalId, relationship }
// 本人(patientExternalId)必须能解析到 PAC patient(否则无处挂靠 → skip);
// 关系人(relatedExternalId)解析到 PAC patient 时填 relatedPatientId(给姓名/电话),否则 null。
// ─────────────────────────────────────────────────────────
private async processPatientRelations(
tables: Record<string, unknown[]>,
config: AssemblerConfig,
hostId: string,
tenantResolver: TenantResolver,
normalize: { amountUnit: 'fen' | 'yuan'; timezone: string },
dryRun: boolean,
): Promise<PerResourceStats> {
const stats: PerResourceStats = this.zeroPerResource(config.canonical);
const assembled = this.assembler.assemble({ tables, config, normalize, hostId });
stats.fetched = assembled.stats.fetched;
stats.failed = assembled.stats.failed;
if (assembled.rows.length > 0)
stats.sampleCanonical.push(assembled.rows[0]!.canonical);
if (dryRun) return stats;
// 本租户 externalId → PAC patient id 索引(本人 + 关系人解析共用)
const indexByTenant = new Map<string, Map<string, string>>();
const getIndex = async (tenantId: string) => {
let idx = indexByTenant.get(tenantId);
if (!idx) {
idx = await this.buildPatientIndex(hostId, tenantId);
indexByTenant.set(tenantId, idx);
}
return idx;
};
for (const { canonical, rawSource } of assembled.rows) {
const c = canonical as Record<string, unknown>;
const patientExternalId = String(c.patientExternalId ?? '').trim();
const relatedExternalId = String(c.relatedExternalId ?? '').trim();
const relationship = String(c.relationship ?? '').trim();
if (!patientExternalId || !relatedExternalId || !relationship) {
stats.failed++;
continue;
}
const tenantId = tenantResolver.resolve(rawSource);
if (!tenantId) {
stats.failed++;
continue;
}
const idx = await getIndex(tenantId);
const patientId = idx.get(patientExternalId);
if (!patientId) continue; // 本人不在 PAC(非 active client)→ 无处挂靠,跳过
const relatedPatientId = idx.get(relatedExternalId) ?? null;
try {
await this.withDbRetry(
() =>
this.prisma.patientRelation.upsert({
where: {
patientId_relatedExternalId_relationship: {
patientId,
relatedExternalId,
relationship,
},
},
create: {
hostId,
tenantId,
patientId,
relatedExternalId,
relatedPatientId,
relationship,
},
update: { relatedPatientId }, // 关系人后来才入库 → 回填 link
}),
'patientRelation upsert',
);
stats.patientsUpserted++; // 复用计数字段(resource 标签区分);不并入 totals.patients
} catch (err) {
stats.failed++;
this.logger.error(
`patientRelation upsert failed ${patientExternalId}${relatedExternalId}: ${err instanceof Error ? err.message : err}`,
);
}
}
return stats;
}
// ─────────────────────────────────────────────────────────
// patient_return_visit 资源 → upsert 诊所回访记录(展示用,5 试点)
// 本人(patientExternalId=customer_id)须解析到 PAC patient(否则无处挂靠 → skip)。
// ─────────────────────────────────────────────────────────
private async processPatientReturnVisits(
tables: Record<string, unknown[]>,
config: AssemblerConfig,
hostId: string,
tenantResolver: TenantResolver,
normalize: { amountUnit: 'fen' | 'yuan'; timezone: string },
dryRun: boolean,
): Promise<PerResourceStats> {
const stats: PerResourceStats = this.zeroPerResource(config.canonical);
const assembled = this.assembler.assemble({ tables, config, normalize, hostId });
stats.fetched = assembled.stats.fetched;
stats.failed = assembled.stats.failed;
if (assembled.rows.length > 0)
stats.sampleCanonical.push(assembled.rows[0]!.canonical);
if (dryRun) return stats;
const indexByTenant = new Map<string, Map<string, string>>();
const getIndex = async (tenantId: string) => {
let idx = indexByTenant.get(tenantId);
if (!idx) {
idx = await this.buildPatientIndex(hostId, tenantId);
indexByTenant.set(tenantId, idx);
}
return idx;
};
for (const { canonical, rawSource } of assembled.rows) {
const c = canonical as Record<string, unknown>;
const externalId = String(c.externalId ?? '').trim();
const patientExternalId = String(c.patientExternalId ?? '').trim();
if (!externalId || !patientExternalId) {
stats.failed++;
continue;
}
const tenantId = tenantResolver.resolve(rawSource);
if (!tenantId) {
stats.failed++;
continue;
}
const idx = await getIndex(tenantId);
const patientId = idx.get(patientExternalId);
if (!patientId) continue; // 本人不在 PAC → skip
const data = {
clinicId: (c.clinicId as string | undefined) ?? null,
taskDate: c.taskDate ? new Date(c.taskDate as string) : null,
type: (c.type as string | undefined) ?? null,
status: (c.status as string | undefined) ?? null,
taskStatus: (c.taskStatus as string | undefined) ?? null,
treatmentItems: (c.treatmentItems as string | undefined) ?? null,
followContent: (c.followContent as string | undefined) ?? null,
result: (c.result as string | undefined) ?? null,
};
try {
await this.withDbRetry(
() =>
this.prisma.patientReturnVisit.upsert({
where: {
hostId_tenantId_externalId: { hostId, tenantId, externalId },
},
create: { hostId, tenantId, externalId, patientId, ...data },
update: { patientId, ...data },
}),
'patientReturnVisit upsert',
);
stats.patientsUpserted++;
} catch (err) {
stats.failed++;
this.logger.error(
`patientReturnVisit upsert failed ${externalId}: ${err instanceof Error ? err.message : err}`,
);
}
}
return stats;
}
// ─────────────────────────────────────────────────────────
// Subject 资源 → 合成 transaction → parser 衍生 fact // Subject 资源 → 合成 transaction → parser 衍生 fact
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
......
...@@ -81,9 +81,10 @@ const DiagnosisRecordContent = z ...@@ -81,9 +81,10 @@ const DiagnosisRecordContent = z
// 翻不出的仍落 fact(保 name_zh 原文),code=null,留给 Layer C LLM 后续补码。 // 翻不出的仍落 fact(保 name_zh 原文),code=null,留给 Layer C LLM 后续补码。
// 算法 SQL `content->>'code'='K08'` 对 null 自然不命中,无副作用。 // 算法 SQL `content->>'code'='K08'` 对 null 自然不命中,无副作用。
code: PACDiagnosisCodeSchema.nullable(), 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 code_source: z
.enum(['std_code', 'name_map', 'llm']) .enum(['std_code', 'name_map', 'llm', 'image_ai'])
.nullable() .nullable()
.optional() .optional()
.default(null), .default(null),
......
...@@ -44,9 +44,13 @@ export class DiagnosisParser implements Parser { ...@@ -44,9 +44,13 @@ export class DiagnosisParser implements Parser {
const codeParsed = PACDiagnosisCodeSchema.safeParse(rawCode); const codeParsed = PACDiagnosisCodeSchema.safeParse(rawCode);
const code = codeParsed.success ? codeParsed.data : null; 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 hasStdCode = !!String(c.stdCodeRaw ?? '').trim();
const codeSource: 'std_code' | 'name_map' | null = code const codeSource: string | null = explicitSource
? explicitSource
: code
? hasStdCode ? hasStdCode
? 'std_code' ? 'std_code'
: 'name_map' : 'name_map'
......
...@@ -60,8 +60,20 @@ export function normalizeToothPosition(input: string | null | undefined): string ...@@ -60,8 +60,20 @@ export function normalizeToothPosition(input: string | null | undefined): string
* "11 M;21 M" → Set { "11", "21" } * "11 M;21 M" → Set { "11", "21" }
* "1A;1B;1C" → Set { "1A", "1B", "1C" } * "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> { export function toothSet(input: string | null | undefined): Set<string> {
const norm = normalizeToothPosition(input); const norm = normalizeToothPosition(input);
if (!norm) return new Set(); 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 { ...@@ -232,7 +232,6 @@ export class PipelineDispatcher {
deceased: (row.deceased as boolean | undefined) ?? false, deceased: (row.deceased as boolean | undefined) ?? false,
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [], tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
notes: (row.notes as string | undefined) ?? null, notes: (row.notes as string | undefined) ?? null,
primaryContactType: (row.primaryContactType as string | undefined) ?? null,
}; };
const patient = await this.prisma.patient.upsert({ const patient = await this.prisma.patient.upsert({
......
...@@ -38,11 +38,17 @@ const CJK_PUNCT_MAP: Record<string, string> = { ...@@ -38,11 +38,17 @@ const CJK_PUNCT_MAP: Record<string, string> = {
const CJK_PUNCT_KEYS = Object.keys(CJK_PUNCT_MAP).join(''); const CJK_PUNCT_KEYS = Object.keys(CJK_PUNCT_MAP).join('');
const CJK_PUNCT_REGEX = new RegExp(`[${CJK_PUNCT_KEYS}]`, 'gu'); 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 { function normalizeText(s: string): string {
return s return s
.trim() .trim()
.replace(TRAILING_NOISE, '') .replace(TRAILING_NOISE, '')
.replace(CJK_PUNCT_REGEX, (ch) => CJK_PUNCT_MAP[ch] ?? ch) .replace(CJK_PUNCT_REGEX, (ch) => CJK_PUNCT_MAP[ch] ?? ch)
.replace(FULLWIDTH_ALNUM_REGEX, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.trim(); .trim();
} }
......
...@@ -50,6 +50,62 @@ function isBlankValue(v: unknown): boolean { ...@@ -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 { function passAll(row: Row, where: Record<string, WhereRule>): boolean {
for (const [field, rule] of Object.entries(where)) { for (const [field, rule] of Object.entries(where)) {
const v = row[field]; const v = row[field];
...@@ -60,6 +116,12 @@ function passAll(row: Row, where: Record<string, WhereRule>): boolean { ...@@ -60,6 +116,12 @@ function passAll(row: Row, where: Record<string, WhereRule>): boolean {
// [{treatName:"",toothPosition:null,toothPositionBak:"<base64>"}])。 // [{treatName:"",toothPosition:null,toothPositionBak:"<base64>"}])。
// 用于 gate "无结构化治疗" — treat_plan 空壳时也应去 dispose 找治疗。 // 用于 gate "无结构化治疗" — treat_plan 空壳时也应去 dispose 找治疗。
if (!isBlankValue(v)) return false; 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) { } else if ('in' in rule) {
const set = new Set(rule.in.map((x) => String(x))); const set = new Set(rule.in.map((x) => String(x)));
if (!set.has(String(v))) return false; if (!set.has(String(v))) return false;
......
...@@ -73,6 +73,13 @@ export type ProjectOp = z.infer<typeof ProjectOpSchema>; ...@@ -73,6 +73,13 @@ export type ProjectOp = z.infer<typeof ProjectOpSchema>;
export const WhereRuleSchema = z.union([ export const WhereRuleSchema = z.union([
z.object({ not_empty: z.literal(true) }), z.object({ not_empty: z.literal(true) }),
z.object({ empty: z.literal(true) }), // 空:null/""/"[]"(空 JSON 数组也算空)— 用于 gate "无结构化字段" 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({ in: z.array(z.union([z.string(), z.number()])).min(1) }),
z.object({ equals: z.union([z.string(), z.number()]) }), z.object({ equals: z.union([z.string(), z.number()]) }),
]); ]);
......
...@@ -57,10 +57,11 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -57,10 +57,11 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
doNotContact: real.profile?.doNotContact ?? false, doNotContact: real.profile?.doNotContact ?? false,
deceased: real.profile?.deceased ?? false, deceased: real.profile?.deceased ?? false,
// host 没给 → null,UI 用 '—' 占位;不再前端硬兜底 '本人'(误导客服以为是本人) // host 没给 → null,UI 用 '—' 占位;不再前端硬兜底 '本人'(误导客服以为是本人)
primaryContactType: real.profile?.primaryContactType ?? null,
ltv: Math.round((real.profile?.ltvCents ?? 0) / 100), ltv: Math.round((real.profile?.ltvCents ?? 0) / 100),
firstVisit: real.profile?.firstVisit ?? '', firstVisit: real.profile?.firstVisit ?? '',
lastVisit: real.profile?.lastVisit ?? '', lastVisit: real.profile?.lastVisit ?? '',
// 联系人/亲属(本人视角)— 关系人姓名/电话从 relatedPatient 现取(后端 serializeProfile)
contacts: real.profile?.contacts ?? [],
}, },
}; };
...@@ -202,6 +203,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -202,6 +203,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
: EMPTY_SCRIPT, : EMPTY_SCRIPT,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组 // 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [], recallHistory: real.recallHistory ?? [],
// 诊所回访记录(5 试点,展示用)— 后端透出;无则空数组
returnVisits: real.returnVisits ?? [],
// outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import // outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import
fmtRel, fmtRel,
}; };
......
...@@ -621,8 +621,12 @@ function factSummary(f: AdaptedFact): { title: string; note: string } { ...@@ -621,8 +621,12 @@ function factSummary(f: AdaptedFact): { title: string; note: string } {
}; };
} }
case 'recommendation_record': { case 'recommendation_record': {
const code = (c.code as string) ?? '?'; const code = (c.code as string) ?? '';
return { title: `建议 ${code}`, note: dr }; // 优先 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': { case 'encounter_record': {
const etype = (c.encounter_type as string) ?? ''; const etype = (c.encounter_type as string) ?? '';
......
...@@ -49,10 +49,24 @@ export const mockPatient = { ...@@ -49,10 +49,24 @@ export const mockPatient = {
profile: { profile: {
doNotContact: false, doNotContact: false,
deceased: false, deceased: false,
primaryContactType: '本人' as string | null,
ltv: 48200, ltv: 48200,
firstVisit: '2022-06-08', firstVisit: '2022-06-08',
lastVisit: fmtDate(daysAgo(38)), 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 = { ...@@ -89,9 +89,22 @@ export type PlanDetailAppData = {
script: typeof mockScript; script: typeof mockScript;
/// 召回历史(患者级)— 可选,缺省空数组 /// 召回历史(患者级)— 可选,缺省空数组
recallHistory?: RecallHistoryItem[]; recallHistory?: RecallHistoryItem[];
/// 诊所回访记录(5 试点,展示用)— 可选,缺省空数组
returnVisits?: ReturnVisitItem[];
fmtRel: typeof mockFmtRel; 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 = { const FALLBACK_DATA: PlanDetailAppData = {
patient: mockPatient, patient: mockPatient,
chains: mockChains, chains: mockChains,
...@@ -118,6 +131,7 @@ export function PlanDetailApp({ ...@@ -118,6 +131,7 @@ export function PlanDetailApp({
const { patient, chains, persona, plan, summaries, script, fmtRel } = data; const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
const facts = data.facts ?? []; const facts = data.facts ?? [];
const recallHistory = data.recallHistory ?? []; const recallHistory = data.recallHistory ?? [];
const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash // 话术生成模型(具体型号);默认 deepseek-v4-flash
...@@ -321,14 +335,11 @@ export function PlanDetailApp({ ...@@ -321,14 +335,11 @@ export function PlanDetailApp({
onOpenTeeth={() => setDrawerOpen('teeth')} onOpenTeeth={() => setDrawerOpen('teeth')}
/> />
<TreatmentHistoryCard facts={facts} /> <TreatmentHistoryCard facts={facts} />
{/* 历史联系 — 预留卡片(以后接客服联系记录) */}
<SidebarCard title="历史联系">
<div className="text-[11.5px] italic text-slate-400">暂无联系记录(后续接入客服联系记录)</div>
</SidebarCard>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */} {/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && ( {recallHistory.length > 0 && (
<RecallHistoryCard history={recallHistory} fmtRel={fmtRel} /> <RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
)} )}
{returnVisits.length > 0 && <ReturnVisitsCard visits={returnVisits} />}
<SidebarCard <SidebarCard
title="治疗链" title="治疗链"
meta={`${chains.length} 条`} meta={`${chains.length} 条`}
...@@ -1143,7 +1154,7 @@ function WhyCard({ ...@@ -1143,7 +1154,7 @@ function WhyCard({
// - 主治医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—' // - 主治医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—'
// - 累计消费:profile.ltv(LTV cents 已扣 refund);右侧 hint 显示 persona.value description(画像评级) // - 累计消费:profile.ltv(LTV cents 已扣 refund);右侧 hint 显示 persona.value description(画像评级)
// - 上次/首次到诊:profile.lastVisit / firstVisit // - 上次/首次到诊:profile.lastVisit / firstVisit
// - 联系人:profile.primaryContactType(host 暂无数据,统一 '—' 占位) // - 联系人:改走 PatientRelation 边表(relationsOut),读时取关系人姓名/关系;暂 '—' 占位待接前端
// hint 列:除累计消费用 persona value 外,其他用 facts 推断的主要治疗类目 top 1-2 // hint 列:除累计消费用 persona value 外,其他用 facts 推断的主要治疗类目 top 1-2
// ────────────────────────────────────────── // ──────────────────────────────────────────
function KeyFactsCard({ function KeyFactsCard({
...@@ -1214,11 +1225,20 @@ function KeyFactsCard({ ...@@ -1214,11 +1225,20 @@ function KeyFactsCard({
return { value: '-', hint: '' }; 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 = [ 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 (
...@@ -1487,6 +1507,45 @@ function RecallHistoryCard({ ...@@ -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 已有数据驱动 * 计算风险规避提示 — PAC 已有数据驱动
* *
......
...@@ -26,7 +26,6 @@ export type PlanDetailData = { ...@@ -26,7 +26,6 @@ export type PlanDetailData = {
profile: { profile: {
doNotContact: boolean; doNotContact: boolean;
deceased: boolean; deceased: boolean;
primaryContactType: string | null;
notes: string | null; notes: string | null;
ltvCents: number; ltvCents: number;
paymentCount: number; paymentCount: number;
...@@ -36,6 +35,13 @@ export type PlanDetailData = { ...@@ -36,6 +35,13 @@ export type PlanDetailData = {
lastVisit: string | null; lastVisit: string | null;
daysSinceLatestVisit: number | null; daysSinceLatestVisit: number | null;
doctors: string[]; doctors: string[];
contacts: Array<{
relationship: string;
relationshipLabel: string;
name: string | null;
phone: string | null;
linked: boolean;
}>;
} | null; } | null;
plan: { plan: {
id: string; id: string;
...@@ -191,4 +197,14 @@ export type PlanDetailData = { ...@@ -191,4 +197,14 @@ export type PlanDetailData = {
createdAt: string; createdAt: string;
planVersion: number | null; 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 { ...@@ -28,8 +28,12 @@ import {
APPT_COMPLAINT_TO_CATEGORY, APPT_COMPLAINT_TO_CATEGORY,
isRestorationIneligibleDxName, isRestorationIneligibleDxName,
resolverCategoriesFor, resolverCategoriesFor,
STRUCTURAL_DX_CODE_LIST,
treatmentCategoryNameZh, treatmentCategoryNameZh,
} from '@pac/types'; } from '@pac/types';
/// 结构码集合 —— 同牙位"取代"只认结构码(牙周/正畸不 moot 结构病)
const STRUCTURAL_CODES = new Set<string>(STRUCTURAL_DX_CODE_LIST);
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
export const WHOLE_PERIO = '全口 · 牙周'; export const WHOLE_PERIO = '全口 · 牙周';
...@@ -66,7 +70,8 @@ const CODE_TO_SUB: Record<string, { subKey: string; primary: string; label: stri ...@@ -66,7 +70,8 @@ const CODE_TO_SUB: Record<string, { subKey: string; primary: string; label: stri
export type OracleVerdictKind = export type OracleVerdictKind =
| 'recall' // 应召回 | 'recall' // 应召回
| 'resolved' // 已被同类/拔除/替代治疗解决 | 'resolved' // 已被【实际治疗】解决(同类/拔除/替代/术后证据)
| 'superseded' // 被【后续诊断/建议】取代并入(非治疗:深龋→缺失、楔缺→建议充填…)
| 'cooldown' // 诊断未过冷静期 | 'cooldown' // 诊断未过冷静期
| 'ineligible' // 废用牙/无功能牙,非修复对象 | 'ineligible' // 废用牙/无功能牙,非修复对象
| 'suppressed'; // 被患者级闸压制(未来预约/主诉预约/近期到诊) | 'suppressed'; // 被患者级闸压制(未来预约/主诉预约/近期到诊)
...@@ -86,7 +91,8 @@ export type OracleVerdict = { ...@@ -86,7 +91,8 @@ export type OracleVerdict = {
const VERDICT_META: Record<OracleVerdictKind, { zh: string; tone: string }> = { const VERDICT_META: Record<OracleVerdictKind, { zh: string; tone: string }> = {
recall: { zh: '应召回', tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }, 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' }, cooldown: { zh: '考虑期', tone: 'bg-amber-50 text-amber-700 border-amber-200' },
ineligible: { zh: '非修复', tone: 'bg-slate-100 text-slate-400 border-slate-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' }, suppressed: { zh: '被压制', tone: 'bg-slate-100 text-slate-500 border-slate-200' },
...@@ -101,6 +107,11 @@ function toothBase(t: string): string { ...@@ -101,6 +107,11 @@ function toothBase(t: string): string {
const m = /^(\d{1,2}[A-E]?)/i.exec(t.trim()); const m = /^(\d{1,2}[A-E]?)/i.exec(t.trim());
return m ? m[1]!.toUpperCase() : t.trim().toUpperCase(); 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[] { function factTeeth(c: Record<string, unknown>): string[] {
const tp = c.tooth_positions; const tp = c.tooth_positions;
const raw = Array.isArray(tp) ? tp.join(';') : String(c.tooth_position ?? tp ?? ''); const raw = Array.isArray(tp) ? tp.join(';') : String(c.tooth_position ?? tp ?? '');
...@@ -108,7 +119,8 @@ function factTeeth(c: Record<string, unknown>): string[] { ...@@ -108,7 +119,8 @@ function factTeeth(c: Record<string, unknown>): string[] {
.split(/[;,]+/) .split(/[;,]+/)
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
.map(toothBase); .map(toothBase)
.filter(isValidTooth);
} }
type Rule = { type Rule = {
...@@ -144,6 +156,10 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[ ...@@ -144,6 +156,10 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
t: number; t: number;
iso: string; iso: string;
}> = []; }> = [];
// 每颗牙的"最晚真实诊断"时间戳 —— 用于"同牙位最新诊断取代旧诊断"。
const maxDxByTooth = new Map<string, number>();
// 每颗牙的"最晚真实建议"时间戳 —— 用于"诊断 vs 建议冲突,以建议(医生决定)为准"。
const maxRecByTooth = new Map<string, number>();
for (const f of facts) { for (const f of facts) {
const c = (f.content ?? {}) as Record<string, unknown>; const c = (f.content ?? {}) as Record<string, unknown>;
...@@ -180,6 +196,32 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[ ...@@ -180,6 +196,32 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
if (vt > daysAgoMs(POST_VISIT_COOLDOWN_DAYS)) recentVisit = true; 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 ( if (
(f.type === 'diagnosis_record' || f.type === 'recommendation_record') && (f.type === 'diagnosis_record' || f.type === 'recommendation_record') &&
...@@ -211,8 +253,21 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[ ...@@ -211,8 +253,21 @@ export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[
let kind: OracleVerdictKind; let kind: OracleVerdictKind;
let detail: string; let detail: string;
// ⭐ 同牙位取代规则(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)) {
// ④' 废用牙 // ④' 废用牙
if (isRestorationIneligibleDxName(nameZh)) {
kind = 'ineligible'; kind = 'ineligible';
detail = `「${nameZh}」非修复对象(该拔除/观察)`; detail = `「${nameZh}」非修复对象(该拔除/观察)`;
} else { } else {
...@@ -334,7 +389,8 @@ export function reconcile( ...@@ -334,7 +389,8 @@ export function reconcile(
prodSet.add(`${s.subKey}|${wholeLaneForSub(s.subKey)}`); prodSet.add(`${s.subKey}|${wholeLaneForSub(s.subKey)}`);
} else { } else {
for (const t of teethRaw.split(/[;,]+/).map((x) => x.trim()).filter(Boolean)) { 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( ...@@ -345,6 +401,7 @@ export function reconcile(
suppressed: 4, suppressed: 4,
cooldown: 3, cooldown: 3,
resolved: 2, resolved: 2,
superseded: 2,
ineligible: 1, ineligible: 1,
}; };
const oracleByKey = new Map<string, OracleVerdict>(); const oracleByKey = new Map<string, OracleVerdict>();
......
'use client'; 'use client';
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types'; import { diagnosisCodeNameZh, treatmentCategoryNameZh, lookupDxTreatment } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import type { PlanReason } from './mock-data'; import type { PlanReason } from './mock-data';
...@@ -64,6 +64,13 @@ export function ToothTimeline({ ...@@ -64,6 +64,13 @@ export function ToothTimeline({
lanes.set(k, arr); lanes.set(k, arr);
}; };
for (const f of clinical) { 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); const teeth = factTeeth(f);
if (teeth.length > 0) { if (teeth.length > 0) {
for (const t of teeth) push(t, f); for (const t of teeth) push(t, f);
...@@ -313,3 +320,20 @@ function wholeMouthLane(f: AdaptedFact): string { ...@@ -313,3 +320,20 @@ function wholeMouthLane(f: AdaptedFact): string {
if (cat === 'orthodontic' || code === 'K07') return WHOLE_ORTHO; if (cat === 'orthodontic' || code === 'K07') return WHOLE_ORTHO;
return WHOLE_OTHER; 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 = [ ...@@ -274,7 +274,11 @@ export const STRUCTURAL_RESOLVER_CATEGORIES = [
] as const satisfies readonly PACTreatmentCategory[]; ] as const satisfies readonly PACTreatmentCategory[];
/// 走"结构家族 resolver"的诊断码 / 推荐码(其余 K05/K06/K07 等沿用 rule.categories) /// 走"结构家族 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', 'K00',
'K01', 'K01',
'K02', 'K02',
...@@ -290,6 +294,9 @@ const STRUCTURAL_DX_CODES = new Set<string>([ ...@@ -290,6 +294,9 @@ const STRUCTURAL_DX_CODES = new Set<string>([
'HARD_TISSUE_REPAIR_RECOMMENDED', 'HARD_TISSUE_REPAIR_RECOMMENDED',
'ERUPTION_INTERVENTION_RECOMMENDED', 'ERUPTION_INTERVENTION_RECOMMENDED',
'JAW_CYST_REMOVAL_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 ...@@ -65,12 +65,41 @@ export const PatientCanonicalSchema = z
tags: z.array(z.string()).optional().default([]), tags: z.array(z.string()).optional().default([]),
/// 产品收集:host 备注 / 客户描述自由文本 /// 产品收集:host 备注 / 客户描述自由文本
notes: z.string().optional().nullable(), notes: z.string().optional().nullable(),
/// 产品收集:主要联系人身份(self/spouse/parent/child/guardian/other)
primaryContactType: z.string().optional().nullable(),
}) })
.passthrough(); .passthrough();
export type PatientCanonical = z.infer<typeof PatientCanonicalSchema>; 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 // 3.4.2 appointments
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -300,6 +329,8 @@ export const DiagnosisCanonicalSchema = z ...@@ -300,6 +329,8 @@ export const DiagnosisCanonicalSchema = z
toothPosition: z.string().optional().nullable(), toothPosition: z.string().optional().nullable(),
severity: z.string().optional().nullable(), severity: z.string().optional().nullable(),
onsetDate: z.string().optional().nullable(), onsetDate: z.string().optional().nullable(),
/// 显式来源声明(如影像 AI 'image_ai');parser 优先用它,否则按 stdCode 推断 std_code/name_map
codeSource: z.string().optional().nullable(),
}) })
.passthrough(); .passthrough();
export type DiagnosisCanonical = z.infer<typeof DiagnosisCanonicalSchema>; export type DiagnosisCanonical = z.infer<typeof DiagnosisCanonicalSchema>;
...@@ -427,6 +458,8 @@ export type ReferralCanonical = z.infer<typeof ReferralCanonicalSchema>; ...@@ -427,6 +458,8 @@ export type ReferralCanonical = z.infer<typeof ReferralCanonicalSchema>;
*/ */
export const CanonicalResourceSchemas = { export const CanonicalResourceSchemas = {
patient: PatientCanonicalSchema, patient: PatientCanonicalSchema,
patient_relation: PatientRelationCanonicalSchema,
patient_return_visit: PatientReturnVisitCanonicalSchema,
consultation: ConsultationCanonicalSchema, consultation: ConsultationCanonicalSchema,
appointment: AppointmentCanonicalSchema, appointment: AppointmentCanonicalSchema,
visit_registration: VisitRegistrationCanonicalSchema, visit_registration: VisitRegistrationCanonicalSchema,
...@@ -472,6 +505,18 @@ export const CanonicalResourceMeta: Record< ...@@ -472,6 +505,18 @@ export const CanonicalResourceMeta: Record<
datetimeFields: ['createdAt', 'updatedAt'], datetimeFields: ['createdAt', 'updatedAt'],
booleanFields: ['doNotContact', 'deceased'], booleanFields: ['doNotContact', 'deceased'],
}, },
patient_relation: {
moneyFields: [],
moneyArrayFields: [],
datetimeFields: [],
booleanFields: [],
},
patient_return_visit: {
moneyFields: [],
moneyArrayFields: [],
datetimeFields: [],
booleanFields: [],
},
consultation: { consultation: {
moneyFields: [], moneyFields: [],
moneyArrayFields: [], 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