Commit 956a42d0 by luoqi

feat(ingest): 摄入咨询主体 consultation_record(fact_consult_out,5 试点意向源)

- 定义 PAC 咨询主体:一次咨询/初诊事件;intents=患者意向(potential_cure,主观意愿,非诊断)。
  区别 diagnosis(医生客观)/recommendation(医生建议)— 意向不进召回,喂 treatment_intent。
- 链路:fact_consult_out 全 144 诊所,过滤到 5 试点(org IN EMR orgs + patient_register_id=patient_id,
  CH 允许 WHERE 引用别名,同 returnvisit);94.4% 命中。无 id → consult_external_id=(patient,appo,date) concat;
  无 updated_date → 不入 per_query,每轮全量按 org+patient 过滤(幂等,同 returnvisit)。
- canonical-codes:CONSULT_INTENT_TO_CATEGORY(种植→implant…拔功能牙→surgical/早期矫正→ortho/美白→cosmetic)
  + parsePotentialCure(解析 Python list 串)。parser/schema/assembler/manifest 配齐。
- 本地 928:5993 facts,0 未映射,失败0。意向类别:正畸1006/种植733/预防383/充填350/根管316…
- ️ map 改后需 truncate consult+重摄才生效(reparse 缺口 task #46);部署是干净全摄,自动生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 2cdab12b
# consult — PAC 咨询主体(fact_consult_out → consultation_record)
# 一次咨询/初诊事件;potential_cure=患者意向(主观意愿,非诊断)。5 试点诊所内。
canonical: consultation
emits:
action: consultation_created
subjectType: consultation
occurredAtField: occurredAt
primary:
table: fact_consult_out
key: consult_external_id
dedup_by: consult_external_id
field_mapping:
externalId: consult_external_id
# 无 updated_date → 用 appointment_date 作幂等键(同 consult 同日再拉 → dedup;编辑漏更新见 follow-up)
updatedAt: appointment_date
patientExternalId: patient_id # = patient_register_id(SQL alias;5 诊所内 = patient_id)
clinicId: organization_id
occurredAt: appointment_date
intentRaw: potential_cure # Python list 串 ['种植','洁牙'] → parser 解析意向
taskDirector: task_director # 跟进人
firstVisit: first_visit # 初诊标记(0/1)
doctorId: doctor_user_id
appointmentExternalId: appo_id # 关联预约
...@@ -228,6 +228,21 @@ sql_source: ...@@ -228,6 +228,21 @@ sql_source:
FROM dw_group.fact_returnvisit_out FROM dw_group.fact_returnvisit_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out) WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
# ── 咨询主体(fact_consult_out)→ consultation_record(意向源,5 试点)──
# patient_register_id = patient_id(5 诊所内 94.4% 命中;CH 允许 WHERE 引用别名,同 returnvisit)。
# 无 updated_date/无 id → consult_external_id 用 (patient,appo,date) concat(全唯一);
# 不入 per_query(无可靠 cursor)→ 每轮全量按 org+patient 过滤再拉(幂等 upsert,同 returnvisit)。
# potential_cure = 患者咨询意向(主观,非诊断;不进召回)。
fact_consult_out: |
SELECT patient_register_id AS patient_id, organization_id, brand,
appointment_date, first_visit, task_director, potential_cure, doctor_user_id, appo_id,
concat(toString(patient_register_id), '|', toString(appo_id), '|', toString(appointment_date)) AS consult_external_id
FROM dw_group.fact_consult_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
AND (patient_register_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
)
# ── 客户-推荐人关系(联系人/亲属边)→ patient_relation upsert ── # ── 客户-推荐人关系(联系人/亲属边)→ patient_relation upsert ──
# 一行 = 本人 与 一个关系人(referee)的关系;referee_patient_id=0 = 无关系人,剔除。 # 一行 = 本人 与 一个关系人(referee)的关系;referee_patient_id=0 = 无关系人,剔除。
# 本人(patient_id)限 active client(= PAC patient 才能挂靠);关系人不限(解析得到则填 link)。 # 本人(patient_id)限 active client(= PAC patient 才能挂靠);关系人不限(解析得到则填 link)。
...@@ -859,6 +874,7 @@ assemblers: ...@@ -859,6 +874,7 @@ assemblers:
- { file: assemblers/patient.yaml } - { file: assemblers/patient.yaml }
- { file: assemblers/patient_relation.yaml } # 联系人/亲属边(referee → PatientRelation upsert) - { file: assemblers/patient_relation.yaml } # 联系人/亲属边(referee → PatientRelation upsert)
- { file: assemblers/patient_return_visit.yaml } # 诊所回访(展示用,5 试点 → PatientReturnVisit upsert) - { file: assemblers/patient_return_visit.yaml } # 诊所回访(展示用,5 试点 → PatientReturnVisit upsert)
- { file: assemblers/consult.yaml } # 咨询主体(意向源,5 试点 → consultation_record)
- { file: assemblers/encounter.yaml } - { file: assemblers/encounter.yaml }
- { file: assemblers/appointment.yaml } - { file: assemblers/appointment.yaml }
- { file: assemblers/diagnosis.yaml } - { file: assemblers/diagnosis.yaml }
......
...@@ -271,13 +271,25 @@ const AppointmentRecordContent = z ...@@ -271,13 +271,25 @@ const AppointmentRecordContent = z
.passthrough(); .passthrough();
/** /**
* consultation_record — 咨询(400 / 门店初诊登记) * consultation_record — PAC 咨询主体(C.1.2 / 意向源)
*
* 定义:一次"咨询/初诊登记"事件(患者来咨询某些治疗意向)。区别于:
* - diagnosis_record(医生客观诊断)/ recommendation_record(医生建议)— 那是【临床事实】,喂召回;
* - consultation_record 的 intents = 【患者主观意愿】(咨询时想做啥)— 软信号,喂 treatment_intent,不进召回。
* 数据源:fact_consult_out(5 试点诊所内,patient_register_id=patient_id;potential_cure=意向)。
*/ */
const ConsultationRecordContent = z const ConsultationRecordContent = z
.object({ .object({
consultation_external_id: z.string().min(1), consultation_external_id: z.string().min(1),
source_channel: nullableString(), consult_date: nullableString(), // 咨询日(occurredAt 也带)
intent: nullableString(), source_channel: nullableString(), // 来源渠道(consult 暂无 → null,留扩展)
intents: z.array(z.string()).optional().default([]), // 意向项目原文(种植/正畸/洁牙…)
intent_categories: z.array(z.string()).optional().default([]), // 归一到 PAC 治疗类别
is_first_visit: z.boolean().nullable().optional().default(null), // 初诊
task_director: nullableString(), // 跟进人
doctor_id: nullableString(),
appointment_external_id: nullableString(), // 关联预约 appo_id
intent: nullableString(), // 兼容旧字段(意向 join 串,展示用)
content: nullableString(), content: nullableString(),
}) })
.passthrough(); .passthrough();
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Action, FactKind, FactStatus, FactType } from '@pac/types'; import { Action, FactKind, FactStatus, FactType, parsePotentialCure } from '@pac/types';
import type { FactDraft, Parser, ParserContext } from './parser.interface'; import type { FactDraft, Parser, ParserContext } from './parser.interface';
/** /**
* ConsultationParser — `consultation_created` 解析器 * ConsultationParser — `consultation_created` 解析器
* *
* 产 1 个 `consultation_record`(actual)— 咨询事实(400 / 接待中心 / 在线咨询入口)。 * 产 1 个 `consultation_record`(actual)= PAC 咨询主体(fact_consult_out)。
* 启治召回(treatment_initiation_recall)的核心输入信号之一。 * intents = 患者咨询意向(potential_cure 解析,种植/正畸/洁牙…)→ 主观意愿信号(喂 treatment_intent)。
* ⚠️ 意向**不进召回**(召回只认医生诊断/建议);此处只忠实记录"患者来咨询想做啥"。
*/ */
@Injectable() @Injectable()
export class ConsultationParser implements Parser { export class ConsultationParser implements Parser {
...@@ -22,8 +23,14 @@ export class ConsultationParser implements Parser { ...@@ -22,8 +23,14 @@ export class ConsultationParser implements Parser {
} }
const occurredAt = c.occurredAt ? new Date(c.occurredAt as string) : null; const occurredAt = c.occurredAt ? new Date(c.occurredAt as string) : null;
const channel = (c.channel as string | undefined) ?? null; const { intents, categories } = parsePotentialCure(c.intentRaw as string | null | undefined);
const notes = (c.notes as string | undefined) ?? null; const taskDirector = (c.taskDirector as string | undefined)?.trim() || null;
const isFirstVisit =
c.firstVisit === undefined || c.firstVisit === null
? null
: String(c.firstVisit) === '1' || c.firstVisit === 1 || c.firstVisit === true;
const consultDate = occurredAt ? occurredAt.toISOString().slice(0, 10) : null;
const intentJoin = intents.length ? intents.join('/') : null;
return [ return [
{ {
...@@ -33,12 +40,20 @@ export class ConsultationParser implements Parser { ...@@ -33,12 +40,20 @@ export class ConsultationParser implements Parser {
status: FactStatus.ACTIVE, status: FactStatus.ACTIVE,
clinicId: ctx.transaction.clinicId, clinicId: ctx.transaction.clinicId,
occurredAt, occurredAt,
title: `咨询 ${externalId}${channel ? '(' + channel + ')' : ''}`, title: `咨询${intentJoin ? '·' + intentJoin : ''}${isFirstVisit ? '(初诊)' : ''}`,
summary: notes, summary: taskDirector ? `跟进:${taskDirector}` : null,
content: { content: {
consultation_external_id: externalId, consultation_external_id: externalId,
channel, consult_date: consultDate,
notes, source_channel: null,
intents,
intent_categories: categories,
is_first_visit: isFirstVisit,
task_director: taskDirector,
doctor_id: (c.doctorId as string | undefined) ?? null,
appointment_external_id: (c.appointmentExternalId as string | undefined) ?? null,
intent: intentJoin,
content: null,
}, },
}, },
]; ];
......
...@@ -133,6 +133,47 @@ export function parseComplaintCategories(raw: string | null | undefined): PACTre ...@@ -133,6 +133,47 @@ export function parseComplaintCategories(raw: string | null | undefined): PACTre
} }
return [...out]; return [...out];
} }
/// 咨询意向项目(fact_consult_out.potential_cure)→ PAC 治疗类别(单一真理源)。
/// ⚠️ 这是【患者主观意愿】(咨询时想做什么),区别于 diagnosis(医生客观诊断)/ recommendation(医生建议)。
/// 仅入 consultation_record.content,**不进召回**(召回只认医生诊断/建议)。
export const CONSULT_INTENT_TO_CATEGORY: Record<string, PACTreatmentCategory> = {
种植: 'implant',
正畸: 'orthodontic',
早矫: 'orthodontic',
早期矫正: 'orthodontic',
洁牙: 'preventive',
涂氟: 'preventive',
窝沟封闭: 'preventive',
充填: 'restorative',
补牙: 'restorative',
修复: 'prosthodontic',
智齿拔除: 'surgical',
拔牙: 'surgical',
拔功能牙: 'surgical',
牙周: 'periodontic',
根管: 'endodontic',
复杂根管: 'endodontic',
美白: 'cosmetic',
儿科: 'pediatric',
// '无' / 其他 → 不映射(非有效意向)
};
/// 解析 potential_cure 串(Python list repr,单引号:`['种植','洁牙']`)→ { intents 原文, categories 归一 }。
/// '无' 及未知值剔除;categories 去重。
export function parsePotentialCure(raw: string | null | undefined): {
intents: string[];
categories: PACTreatmentCategory[];
} {
if (!raw) return { intents: [], categories: [] };
const intents = raw
.replace(/^\[|\]$/g, '')
.split(',')
.map((s) => s.trim().replace(/^['"]|['"]$/g, '').trim())
.filter((s) => s && s !== '无');
const categories = [...new Set(intents.map((i) => CONSULT_INTENT_TO_CATEGORY[i]).filter(Boolean))] as PACTreatmentCategory[];
return { intents, categories };
}
export const PACTreatmentCategorySchema = z.enum( export const PACTreatmentCategorySchema = z.enum(
Object.keys(PACTreatmentCategories) as [ Object.keys(PACTreatmentCategories) as [
PACTreatmentCategory, PACTreatmentCategory,
......
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