Commit d05d4b0f by luoqi

refactor(plan): 到诊派生字段落 patient_profiles —— 不进画像、不加宽主表

返工上一版:visit_recency 曾做成 persona 特征,当天被否 ——「上次到诊 2022-06-19」是
**原始事实**,而画像该是「重要价值 / 流失客 / 禁忌」这类判断。做成特征会混进患者详情的
画像标签抽屉、并喂给画像摘要 LLM。该特征一行都没写进生产就撤了。

【落在哪】patient_profiles(1:1 副表),不是 patients:
  profile 本来就装派生 / 摄入属性(获客渠道、转介绍统计),patients 是主数据。
  派生的归派生,主表不被越搞越宽,也不用新建一张表。

【为什么必须物化】跨全池筛选没有不物化的:否则「上次到诊在 3-6 个月」要对 2,500 万行
  patient_facts 做 GROUP BY … HAVING max(occurred_at),交互式扛不住。
  画像筛选也一样物化了 —— 它的柱子就叫 persona_features,只是立得早、又用 JSONB 做成了
  免 DDL 的扩展点,所以加维度时感觉不到成本。这三个字段不属于画像,没有现成柱子可搭。

【 存日期不存分档 —— 零定时任务】
  存 `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月)→ 得配到期重扫
  (上一版为此实现了 nextBoundaryAt);存日期则**只有患者又来了才会变**,而"又来了"必然
  先写到诊 fact → 推进事实水位 → 触发该患者画像重算 → 三列顺路刷新。触发时机与既有机制
  天然重合,自洽。0-3 / 3-6 / … 的区间在**查询时**按 now 现算(visitRecencyRange)。

【 到诊口径 = encounter + 实际治疗 + 挂号 + 病历】
  本地实测:17,637 个有事实的患者里 **8,586 个(48.7%)只有病历、没有 encounter/治疗/挂号**
  (宿主 appointment.in_time 缺失等)。只用 visitFactsOf 会让近一半患者「上次到诊」为空 ——
  本轮实测正是如此(487 → 加病历后 2,408,近 5 倍)。医生写了病历,患者必然到过场;
  详情页 lastVisit 早就是这个并集口径,这里与它对齐。
  ️ 顺带发现:rfm / lifecycle_stage / urgency_level 仍用窄口径 visitFactsOf,
    同一批患者会因此少算就诊次数 —— 值得另开一件事核实,本次不动。

【卡片与筛选同源】两者都读这三列,不做"取不到就现算"的兜底 ——
  那会让展示值与筛选口径再次分叉,正是本次返工要消灭的问题。
  首次重算前为 NULL:卡片留空、筛选筛不到、医生名单为空,都是可接受的降级。

其余:PersonaTagFilterDim 加 source/patientField 分流查询;医生名单接口改查 patient_profiles;
写入挂在画像重算遍历上(顺路,失败只告警不中断 —— 它不是画像的一部分);
persona-spec-drift 的 key 校验跳过 source='patient' 维度。

655 tests / 42 suites 全过;types / service / web typecheck 通过;
本地重算实测卡片显示「2022-06-19 · 张 敏」,与筛选同源。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 5885da71
-- CreateIndex —— 新筛选维度的偏索引:上次时间 / 上次医生 / 偏好医生
--
-- 与 20260728020000 那 14 条同一套路数、同一理由,不重复长篇:
-- Prisma 把画像筛选编译成 `key = 'visit_recency' AND (data #> '{X}') = '"…"'`,
-- 没有索引,planner 只能按 key 捞出**全部** visit_recency 行再回堆过滤 jsonb。
-- 2026-07-29 生产实测过后果:某维度 190,210 行里只中 2,156 行,读 1.3GB 堆、单次筛人 20+ 秒。
--
-- ⚠️ **加筛选维度必须同时加索引** —— persona-tag-filters.ts 顶部写死的纪律,这三条就是它的兑现。
--
-- 形态:全是标量等值 → btree (表达式, persona_id)。
-- 第二列 persona_id 不是排序用,是让内层 EXISTS 走 **index-only scan**
-- (它只 SELECT persona_id,索引自带就不用回堆;实测数据见 20260728020000)。
--
-- ⭐ 医生两条同样是**等值**索引,不是模糊匹配索引 —— 这是刻意的:
-- 前端输入框在 /plans/doctors 拉到的名单上做客户端模糊,选中后发**精确姓名**。
-- 若改成后端 ILIKE '%x%',这两条索引立刻失效、退回全分区扫。要做真·后端模糊
-- 得先上 pg_trgm + GIN,并确认托管 PG 允许装扩展。
--
-- 【为什么不用 CONCURRENTLY】visit_recency 是新特征,建索引时表里一行都没有
-- (要等下一轮画像重算才写入),普通 CREATE INDEX 瞬间完成、锁不到任何东西。
-- 等数据铺开后再加别的维度索引,才需要 CONCURRENTLY + 部署前手工先建。
CREATE INDEX IF NOT EXISTS "persona_features_visit_recency_bucket_idx"
ON "persona_features" (((data #> '{bucket}'::text[])), "persona_id") WHERE "key" = 'visit_recency';
CREATE INDEX IF NOT EXISTS "persona_features_visit_recency_last_doctor_idx"
ON "persona_features" (((data #> '{lastDoctor}'::text[])), "persona_id") WHERE "key" = 'visit_recency';
CREATE INDEX IF NOT EXISTS "persona_features_visit_recency_pref_doctor_idx"
ON "persona_features" (((data #> '{preferredDoctor}'::text[])), "persona_id") WHERE "key" = 'visit_recency';
-- 到诊派生字段落 patient_profiles ——「上次到诊 / 上次医生 / 偏好医生」
--
-- 【为什么要物化】跨全池的筛选没有不物化的:否则「上次到诊在 3-6 个月」这个条件
-- 要对 2,500 万行 patient_facts 做 GROUP BY patient_id HAVING max(occurred_at) …,交互式查询扛不住。
-- 画像筛选也一样物化了 —— 它的"柱子"就叫 persona_features,只是立得早、又用 JSONB 做成了
-- 免 DDL 的扩展点,所以加维度时感觉不到成本。这三个字段不属于画像,没有现成柱子可搭。
--
-- 【为什么落 profiles 而不是 patients】profile 本来就装派生 / 摄入属性(获客渠道、转介绍统计),
-- patients 是主数据。派生的归派生,主表不被越搞越宽。
--
-- 【为什么不是画像特征】2026-07-29 第一版做成 persona 特征 visit_recency,当天被否:
-- 它会混进患者详情的画像标签抽屉、并喂给画像摘要 LLM。
-- 「上次到诊 2022-06-19」是原始事实;画像该是「重要价值 / 流失客 / 禁忌」这类判断。
-- (那版一行都没写进生产就撤了,无需清理。)
--
-- 【⭐ 存日期不存分档 —— 不需要任何定时任务】
-- 存 `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月)→ 得配到期重扫机制;
-- 存日期则**只有患者又来了才会变**,而"又来了"必然先写到诊 fact → 推进事实水位 →
-- 触发该患者画像重算 → 这三列顺路刷新。触发时机与已有机制天然重合,自洽,零额外调度。
-- 0-3 / 3-6 / … 的区间在**查询时**按 now 现算(visitRecencyRange),语义永远对得上今天。
--
-- 【索引】profile 是 1:1 表(PK = patient_id),行数 = 患者数,不需要 host/tenant 前缀
-- (筛选先经 patients 的 host/tenant 圈定,再 join 过来)。
-- · last_visit_at → 区间查询 · last_visit_doctor / preferred_doctor → 等值查询
-- ⭐ 是**等值**不是模糊:前端在 /plans/doctors 的名单上做客户端模糊,选中后发精确姓名。
-- 改成后端 ILIKE '%x%' 索引立刻失效、退回全表扫(见 2026-07-29「圈人 20 秒」事故)。
--
-- 【存量】新列全 NULL,等下一轮画像重算填。在那之前:卡片"上次到诊"留空、三个筛选筛不到人、
-- 医生名单接口返回空数组(前端显示「暂无医生名单」)。刻意**不做**"取不到就现算"的兜底 ——
-- 那会让卡片展示值与筛选口径再次分叉,正是本次返工要消灭的问题。
ALTER TABLE "patient_profiles"
ADD COLUMN IF NOT EXISTS "last_visit_at" date,
ADD COLUMN IF NOT EXISTS "last_visit_doctor" text,
ADD COLUMN IF NOT EXISTS "preferred_doctor" text;
CREATE INDEX IF NOT EXISTS "patient_profiles_last_visit_at_idx" ON "patient_profiles" ("last_visit_at");
CREATE INDEX IF NOT EXISTS "patient_profiles_last_visit_doctor_idx" ON "patient_profiles" ("last_visit_doctor");
CREATE INDEX IF NOT EXISTS "patient_profiles_preferred_doctor_idx" ON "patient_profiles" ("preferred_doctor");
......@@ -293,6 +293,40 @@ model PatientProfile {
referralCount Int? @map("referral_count")
referralAmountCents Int? @map("referral_amount_cents")
// ── 到诊派生字段 ——「上次到诊 / 上次医生 / 偏好医生」────────────────────────────────
// 放这张副表而不是 patients:profile 本来就装**派生 / 摄入属性**(获客渠道、转介绍统计),
// patients 是主数据。派生的归派生,主数据不被越搞越宽。
//
// ⚠️ **不是画像** —— 2026-07-29 曾一度做成 persona 特征 visit_recency,当天被否:
// 它会混进患者详情的画像标签抽屉、并喂给画像摘要 LLM。「上次到诊 2022-06-19」是原始事实,
// 画像该是「重要价值 / 流失客 / 禁忌」这类判断。
//
// **存日期不存分档**,这是不用任何定时任务的关键:
// `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月) 得配到期重扫;
// 存日期则**只有患者又来了才会变**,"又来了"必然先写到诊 fact 推进事实水位
// 触发该患者画像重算 这三个字段顺路刷新。触发时机天然重合,自洽。
// 0-3/3-6/ 的区间在**查询时** now 现算(visitRecencyRange),所以语义永远对得上今天。
//
// 卡片展示与筛选**都读这三列**,同源 —— 否则会出现"卡片显示 A、筛选按 B 匹配"
// 首次画像重算前为 NULL:卡片留空、筛选筛不到。刻意不做"取不到就现算"的兜底,
// 那会让展示值与筛选口径再次分叉。
/// 最近一次到诊日期。口径 = encounter + 实际治疗 + 挂号 + **病历**的并集。
/// ⚠️ 并集里的"病历"不能省:本地实测 48.7% 的患者只有病历、没有 encounter/治疗/挂号记录
/// (宿主 appointment.in_time 缺失等),漏掉就有近一半患者的"上次到诊"是空的。
/// 医生写了病历,患者必然到过场。详情页 lastVisit 早就是这个口径。
lastVisitAt DateTime? @map("last_visit_at") @db.Date
/// **上次到诊当天**那份病历的接诊医生。跨天不认 —— 否则「日期是A次、医生是B次」,客服照着说会露馅。
lastVisitDoctor String? @map("last_visit_doctor")
/// 主治医生 = 病历里出现频次最高的医生。当前用作业务要的「偏好医生」的代替 ——
/// DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;接入后换值即可,消费方不用改。
preferredDoctor String? @map("preferred_doctor")
/// 筛选索引:上次时间走区间、两个医生走等值(前端在名单上做模糊,选中发精确姓名)
@@index([lastVisitAt])
@@index([lastVisitDoctor])
@@index([preferredDoctor])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......
......@@ -15,7 +15,6 @@ import { SpecialAttentionFeatureExtractor } from './special-attention.feature';
import { TreatmentSensitivityFeatureExtractor } from './treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './potential-treatment.feature';
import { VisitRecencyFeatureExtractor } from './visit-recency.feature';
import { UrgencyLevelFeatureExtractor } from './urgency-level.feature';
/**
......@@ -49,7 +48,6 @@ export const FEATURE_EXTRACTOR_PROVIDERS = [
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
EntitlementStatusFeatureExtractor,
VisitRecencyFeatureExtractor,
];
@Injectable()
......@@ -73,8 +71,7 @@ export class FeatureRegistry {
potentialTreatment: PotentialTreatmentFeatureExtractor,
urgencyLevel: UrgencyLevelFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
visitRecency: VisitRecencyFeatureExtractor,
) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, urgencyLevel, entitlement, visitRecency];
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, urgencyLevel, entitlement];
}
}
import { Injectable } from '@nestjs/common';
import { FactType, PersonaFeatureKey, VISIT_RECENCY_BUCKETS } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { visitFactsOf, visitSpanOf } from './visit-facts';
/**
* visit_recency 上次到诊 —— 时间分档 + 那次的医生 + 主治医生。
*
* 一个特征喂三处,刻意合并而不是拆三个:
* ① 列表卡片的「2022-06-19 · 张敏」;
* ② 圈人筛选的「上次时间」(0-3 / 3-6 / 6-12 / 12-18 / 18+ 月);
* ③ 圈人筛选的「上次医生 / 偏好医生」(数据已就位,面板待接可搜索下拉,见 persona-tag-filters)。
* 合并的理由:三者同源于同一次就诊。拆开算迟早各走各的 —— 卡片写 A 医生、筛选筛出 B,
* 客服当场就发现。
*
* 【到诊口径】用 visitFactsOf(encounter + actual treatment + 挂号)——
* 与 rfm / lifecycle_stage / urgency_level **同一口径**。
* ⚠️ 不用「encounter ∪ emr」那套(plan-aggregate 详情页的口径):同一个患者两套口径会算出
* 两个"末诊",画像标签与详情页打架。要改口径就改 visit-facts.ts,四个特征一起动。
*
* 【医生口径】doctor_name 只有 emr_record 带(encounter_record 实测两个宿主都 0% 有名):
* · lastDoctor = **末诊当天**那份病历的医生。跨天的不认 —— 否则会出现
* "日期是 A 次、医生是 B 次"的错配,客服照着说就露馅,宁可留空。
* · preferredDoctor = 全部病历里出现**频次最高**的医生(并列取最近一次的)。
* 这是拿「主治医生」代替业务要的「偏好医生」—— DW 的 fact_client_out.favor_doctor_id
* 才是真·偏好医生,但 PAC 尚未摄入。等摄入后换成真值,消费方不用改。
*
* 【随时间自行改档】纯时间流逝就会跨桶(今天 2 个月、下个月就 3 个月),
* 所以必须实现 nextBoundaryAt,否则水位闸永远不放行、分档永远停在算的那一刻。
*/
@Injectable()
export class VisitRecencyFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.VISIT_RECENCY;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const visits = visitFactsOf(ctx);
const span = visitSpanOf(visits);
if (!span.last) return null; // 从没到过诊 → 不打这个标签(潜客由 lifecycle_stage 负责)
const days = daysBetween(span.last, ctx.now);
const bucket = bucketOf(days);
const emrs = ctx.factsByType.get(FactType.EMR_RECORD) ?? [];
const lastDoctor = doctorOfSameDay(emrs, span.last);
const preferredDoctor = mostFrequentDoctor(emrs);
const lastVisitAt = span.last.toISOString().slice(0, 10);
const bucketZh = VISIT_RECENCY_BUCKETS.find((b) => b.value === bucket)?.zh ?? bucket;
return {
key: this.key,
description:
`上次到诊 ${lastVisitAt}(${bucketZh})` +
(lastDoctor ? `,接诊医生 ${lastDoctor}` : '') +
(preferredDoctor && preferredDoctor !== lastDoctor ? `;主治医生 ${preferredDoctor}` : ''),
score: days,
data: { bucket, lastVisitAt, daysSince: days, lastDoctor, preferredDoctor },
evidence: { factIds: [span.lastFactId].filter((x): x is string => !!x) },
};
}
/** 跨到下一个桶的时刻 —— 纯时间流逝改档,必须报,否则水位闸不放行(见类注释) */
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
const span = visitSpanOf(visitFactsOf(ctx));
if (!span.last) return null;
const days = daysBetween(span.last, ctx.now);
const nextEdge = BUCKET_EDGES.find((d) => d > days);
if (nextEdge === undefined) return null; // 已在最后一档(18 个月以上)→ 不会再变
return new Date(span.last.getTime() + nextEdge * 86400_000);
}
}
/// 桶边界(天)。与 VISIT_RECENCY_BUCKETS 的 value 一一对应,改一处要改两处。
const BUCKET_EDGES = [90, 180, 365, 547] as const;
function bucketOf(days: number): string {
if (days < 90) return '0_3m';
if (days < 180) return '3_6m';
if (days < 365) return '6_12m';
if (days < 547) return '12_18m'; // 18 个月 ≈ 547 天
return '18m_plus';
}
function daysBetween(from: Date, to: Date): number {
return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86400_000));
}
function doctorNameOf(f: ActiveFact): string | null {
const n = (f.content as Record<string, unknown> | null)?.doctor_name;
const s = typeof n === 'string' ? n.trim() : '';
return s || null;
}
/** 末诊**当天**那份病历的医生;跨天不认(见类注释的错配说明) */
function doctorOfSameDay(emrs: ActiveFact[], lastVisit: Date): string | null {
const day = lastVisit.toISOString().slice(0, 10);
for (const f of emrs) {
if (!f.occurredAt || f.occurredAt.toISOString().slice(0, 10) !== day) continue;
const n = doctorNameOf(f);
if (n) return n;
}
return null;
}
/** 出现频次最高的医生;并列取"最近一次出现"的那个(而不是任意一个,保证结果稳定可复现) */
function mostFrequentDoctor(emrs: ActiveFact[]): string | null {
const stat = new Map<string, { n: number; latest: number }>();
for (const f of emrs) {
const name = doctorNameOf(f);
if (!name) continue;
const t = f.occurredAt?.getTime() ?? 0;
const cur = stat.get(name) ?? { n: 0, latest: 0 };
stat.set(name, { n: cur.n + 1, latest: Math.max(cur.latest, t) });
}
let best: { name: string; n: number; latest: number } | null = null;
for (const [name, v] of stat) {
if (!best || v.n > best.n || (v.n === best.n && v.latest > best.latest)) {
best = { name, ...v };
}
}
return best?.name ?? null;
}
......@@ -3,7 +3,9 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { FactType } from '@pac/types';
import { FeatureRegistry } from './features/feature.registry';
import { visitFactsOf, visitSpanOf } from './features/visit-facts';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import { personaWriteVerdict, type StoredPersonaFeature } from './persona-diff';
import type {
......@@ -58,6 +60,85 @@ export class PersonaService {
* 该查询全租户聚合(大库可达数秒)。**single-flight**:缓存空/过期时,并发调用(批量重算
* concurrency>1)共享同一个在飞查询 —— 否则 N 个重查询同时打 DB 会卡死(370万 facts 实测)。
*/
/**
* 刷新 patients 上的到诊派生字段(上次到诊 / 上次医生 / 偏好医生)。
*
* 口径:
* · lastVisitAt = visitFactsOf(encounter + 实际治疗 + 挂号)里 occurredAt 最大的那次 ——
* 与 rfm / lifecycle_stage / urgency_level **同一口径**(改口径改 visit-facts.ts,一起动)。
* · lastVisitDoctor = **末诊当天**那份病历的 doctor_name;跨天不认 —— 否则
* 「日期是 A 次、医生是 B 次」,客服照着说会露馅,宁可留空。
* (doctor_name 只有 emr_record 带;encounter_record 实测两个宿主都是 0% 有名。)
* · preferredDoctor = 病历里出现频次最高的医生(并列取最近一次,保证结果稳定可复现)。
*
* 值没变就不写 —— 这趟是全量遍历,无谓 UPDATE 会把 patients 的死行堆起来。
*/
private async refreshVisitDerivedFields(
patientId: string,
ctx: FeatureExtractorContext,
): Promise<void> {
const emrs = ctx.factsByType.get(FactType.EMR_RECORD) ?? [];
// ⚠️ 到诊口径 = visitFactsOf **∪ 病历** —— 不能只用 visitFactsOf。
// 本地实测:17,637 个有事实的患者里 8,586 个(48.7%)**只有病历、没有 encounter/治疗/挂号**
// (宿主 appointment.in_time 缺失等原因)。只用 visitFactsOf 会让近一半患者"上次到诊"为空。
// 医生写了病历,患者必然到过场 —— 详情页 lastVisit 早就是这个并集口径,这里与它对齐。
// 注:rfm / lifecycle_stage / urgency_level 仍用窄口径 visitFactsOf,那是它们各自的历史口径,
// 不在本次改动范围(但同一批患者会因此少算就诊次数,值得另开一件事核实)。
const span = visitSpanOf([...visitFactsOf(ctx), ...emrs]);
const lastVisitAt = span.last ? new Date(span.last.toISOString().slice(0, 10)) : null;
let lastVisitDoctor: string | null = null;
if (span.last) {
const day = span.last.toISOString().slice(0, 10);
for (const f of emrs) {
if (!f.occurredAt || f.occurredAt.toISOString().slice(0, 10) !== day) continue;
const n = doctorNameOf(f);
if (n) {
lastVisitDoctor = n;
break;
}
}
}
const stat = new Map<string, { n: number; latest: number }>();
for (const f of emrs) {
const name = doctorNameOf(f);
if (!name) continue;
const cur = stat.get(name) ?? { n: 0, latest: 0 };
stat.set(name, {
n: cur.n + 1,
latest: Math.max(cur.latest, f.occurredAt?.getTime() ?? 0),
});
}
let preferredDoctor: string | null = null;
let best = { n: 0, latest: 0 };
for (const [name, v] of stat) {
if (v.n > best.n || (v.n === best.n && v.latest > best.latest)) {
preferredDoctor = name;
best = v;
}
}
const cur = await this.prisma.patientProfile.findUnique({
where: { patientId },
select: { lastVisitAt: true, lastVisitDoctor: true, preferredDoctor: true },
});
const same =
cur &&
(cur.lastVisitAt?.toISOString().slice(0, 10) ?? null) ===
(lastVisitAt?.toISOString().slice(0, 10) ?? null) &&
cur.lastVisitDoctor === lastVisitDoctor &&
cur.preferredDoctor === preferredDoctor;
if (same) return;
// upsert:壳档患者建档时就建了空 profile 行,但存量/异常路径可能缺,补建不报错
await this.prisma.patientProfile.upsert({
where: { patientId },
update: { lastVisitAt, lastVisitDoctor, preferredDoctor },
create: { patientId, lastVisitAt, lastVisitDoctor, preferredDoctor },
});
}
private async getMonetaryQuantiles(hostId: string, tenantId: string, nowMs: number): Promise<number[]> {
const key = `${hostId}:${tenantId}`;
const hit = this.mQuantileCache.get(key);
......@@ -377,6 +458,21 @@ export class PersonaService {
}
}
// ─── 到诊派生字段(**不是画像**)———————————————————————————————
// 「上次到诊 / 上次医生 / 偏好医生」是事实派生属性,落 patient_profiles 列,不进 persona_features。
// 为什么不进画像:2026-07-29 第一版做成 visit_recency 特征,当天被否 —— 它会混进患者详情的
// 画像标签抽屉、并喂给画像摘要 LLM。「上次到诊 2022-06-19」是原始事实,画像该是
// 「重要价值 / 流失客 / 禁忌」这类判断。
// 为什么写在这里:这趟遍历已经把该患者的 facts 全加载了,顺路算最省;跟画像共用触发时机
// (都由 fact 水位驱动)也正好。**只是借车,不是画像的一部分** —— 它不进 drafts、
// 不参与 personaWriteVerdict、不影响版本号。
// 辅助写入,失败只告警不中断:它不是画像的一部分,不该因为它把整条重算带崩。
await this.refreshVisitDerivedFields(patient.id, ctx).catch((err) =>
this.logger.warn(
`到诊派生字段刷新失败 patient=${patient.id}: ${err instanceof Error ? err.message : err}`,
),
);
// ─── 写入判定(写侧省写)───
// 闸放宽到 fact 水位之后,「算了但内容没变」会变多(reparse 常常只动了不进画像的字段)。
// 这里决定:不写 / 就地刷新 / 升版本。三种都要推水位,否则闸永远开着、下次还白算一遍。
......@@ -594,3 +690,10 @@ export interface RecomputeResult {
status: string;
durationMs: number;
}
/// 取 fact 里的医生姓名(只有 emr_record 带 doctor_name),空串当作没有
function doctorNameOf(f: { content: unknown }): string | null {
const n = (f.content as Record<string, unknown> | null)?.doctor_name;
const s = typeof n === 'string' ? n.trim() : '';
return s || null;
}
......@@ -84,7 +84,7 @@ export class PlanController {
@ApiOperation({
summary: '本 scope 的医生名单(筛选面板的可搜索输入框用;结果缓存)',
description:
'取自 persona_features.visit_recency 的 lastDoctor / preferredDoctor 去重。' +
'取自 patients.last_visit_doctor / preferred_doctor 去重(事实派生列,非画像)。' +
'前端在这个名单上做客户端模糊匹配,**选中后发精确姓名** —— 等值查询才吃得到偏索引。',
})
doctors(@TenantScope() scope: TenantScopeContext) {
......
......@@ -19,6 +19,7 @@ import {
PersonaFeatureKey,
personaTagDimId,
type PersonaTagFilterDim,
visitRecencyRange,
} from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -112,6 +113,8 @@ export class PlanService {
gender: true,
birthDate: true,
medicalRecordNumber: true,
// 到诊派生字段在 profile(派生的归派生,主表不加宽);卡片与筛选读同一处,不会分叉
profile: { select: { lastVisitAt: true, lastVisitDoctor: true } },
},
})
: [];
......@@ -119,7 +122,7 @@ export class PlanService {
// 卡片补充信息(上次到诊 / 那次的医生 / 潜在治疗标签)—— 两条**整页批量**查询,
// 不逐行发。本页最多 pageSize(25)个患者,都走 patient_id 索引。
const cardFeatures = await this.fetchCardFeatures(patientIds);
const potentialByPatient = await this.fetchPotentialTreatments(patientIds);
// 每个 plan 最近一次执行结果(左栏「成功/不成功/保持」角标用)。
// 本页 plan 数少(≤pageSize),执行记录也少;一次拉齐后 JS 取每 plan 最新一条。
......@@ -154,9 +157,9 @@ export class PlanService {
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
medicalRecordNumber: patient?.medicalRecordNumber ?? null,
lastVisitAt: cardFeatures.get(p.patientId)?.lastVisitAt ?? null,
lastVisitDoctor: cardFeatures.get(p.patientId)?.lastVisitDoctor ?? null,
potentialTreatments: cardFeatures.get(p.patientId)?.potentialTreatments ?? [],
lastVisitAt: patient?.profile?.lastVisitAt?.toISOString().slice(0, 10) ?? null,
lastVisitDoctor: patient?.profile?.lastVisitDoctor ?? null,
potentialTreatments: potentialByPatient.get(p.patientId) ?? [],
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
......@@ -169,49 +172,32 @@ export class PlanService {
}
/**
* 列表卡片要的画像字段 —— **一条查询**取回整页患者的两个特征
* 列表卡片的潜在治疗标签 —— 整页患者一条查询
*
* 合并成一条的理由不只是省一次往返:
* · potential_treatment → 潜在治疗标签
* · visit_recency → 上次到诊日期 + 那次的医生
* 后者刻意**读画像而不是现算** —— 现算就会出现"卡片按 A 口径、圈人筛选按 B 口径"的分裂
* (2026-07-29 我第一版就是现算 encounter ∪ emr,与画像侧 visitFactsOf 口径不同,已收敛)。
* 现在卡片显示什么、筛选筛出什么,同源同一份 persona_features 行。
* 只剩这一个画像字段:上次到诊 / 上次医生已改从 patients 列取(它们是事实派生属性,
* 不是画像 —— 见 schema.prisma 上 lastVisitAt 的注释),patients 本来就在 select 里,零额外查询。
*
* 拿不到画像(新患者尚未算过)→ 该行字段留空,前端不占位,不做兜底现算。
* ⭐ 取 types(code)不取 labels(中文):labels 是算画像那刻写死进 JSON 的,改措辞得全量重算
* 才生效;code 稳定,中文由前端查 POTENTIAL_TREATMENT_CARD_LABEL。
*/
private async fetchCardFeatures(patientIds: string[]): Promise<
Map<string, { potentialTreatments: string[]; lastVisitAt: string | null; lastVisitDoctor: string | null }>
> {
const out = new Map<
string,
{ potentialTreatments: string[]; lastVisitAt: string | null; lastVisitDoctor: string | null }
>();
private async fetchPotentialTreatments(patientIds: string[]): Promise<Map<string, string[]>> {
const out = new Map<string, string[]>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.personaFeature.findMany({
where: {
key: { in: [PersonaFeatureKey.POTENTIAL_TREATMENT, PersonaFeatureKey.VISIT_RECENCY] },
key: PersonaFeatureKey.POTENTIAL_TREATMENT,
persona: { patientId: { in: patientIds }, supersededAt: null },
},
select: { key: true, data: true, persona: { select: { patientId: true } } },
select: { data: true, persona: { select: { patientId: true } } },
});
for (const r of rows) {
const pid = r.persona.patientId;
const cur =
out.get(pid) ?? { potentialTreatments: [], lastVisitAt: null, lastVisitDoctor: null };
const data = (r.data ?? {}) as Record<string, unknown>;
if (r.key === PersonaFeatureKey.POTENTIAL_TREATMENT) {
// ⭐ 取 types(code)不取 labels(中文):labels 是算画像那刻写死进 JSON 的,
// 改措辞得全量重算才生效;code 稳定,中文由前端查 POTENTIAL_TREATMENT_CARD_LABEL。
const types = data.types;
const types = (r.data as { types?: unknown } | null)?.types;
if (Array.isArray(types)) {
cur.potentialTreatments = types.filter((t): t is string => typeof t === 'string' && !!t);
}
} else {
cur.lastVisitAt = typeof data.lastVisitAt === 'string' ? data.lastVisitAt : null;
cur.lastVisitDoctor = typeof data.lastDoctor === 'string' ? data.lastDoctor : null;
out.set(
r.persona.patientId,
types.filter((t): t is string => typeof t === 'string' && !!t),
);
}
out.set(pid, cur);
}
return out;
}
......@@ -237,15 +223,11 @@ export class PlanService {
const rows = await this.prisma.$queryRaw<Array<{ name: string }>>`
SELECT DISTINCT name FROM (
SELECT nullif(f.data ->> 'lastDoctor', '') AS name
FROM persona_features f
JOIN personas p ON p.id = f.persona_id AND p.superseded_at IS NULL
WHERE f.key = 'visit_recency' AND p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
SELECT nullif(pr.last_visit_doctor, '') AS name FROM patient_profiles pr JOIN patients p ON p.id = pr.patient_id
WHERE p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
UNION ALL
SELECT nullif(f.data ->> 'preferredDoctor', '')
FROM persona_features f
JOIN personas p ON p.id = f.persona_id AND p.superseded_at IS NULL
WHERE f.key = 'visit_recency' AND p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
SELECT nullif(pr.preferred_doctor, '') FROM patient_profiles pr JOIN patients p ON p.id = pr.patient_id
WHERE p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
) t
WHERE name IS NOT NULL
ORDER BY name
......@@ -353,6 +335,24 @@ export class PlanService {
}
const personaConds: Prisma.PatientWhereInput[] = [];
for (const { dim, values } of byDim.values()) {
// ── 事实派生属性:直接查 patients 列(不穿 personas → persona_features)──
if (dim.source === 'patient') {
const field = dim.patientField!;
if (field === 'lastVisitAt') {
// ⭐ 存的是**日期**不是分档 —— 区间在这里按 now 现算,所以永远准,
// 不会像存死分档那样随时间过期(那需要一整套「到期重算」机械)。
const ranges = values
.map((v) => visitRecencyRange(v, new Date()))
.filter((r): r is { gte?: Date; lt?: Date } => !!r);
if (ranges.length > 0) {
personaConds.push({ profile: { OR: ranges.map((r) => ({ lastVisitAt: r })) } });
}
} else {
// 医生:等值(前端在名单上做模糊,选中后发精确姓名 —— 等值才吃得到索引)
personaConds.push({ profile: { [field]: { in: values } } } as Prisma.PatientWhereInput);
}
continue;
}
const valueConds = values.map((v) =>
dim.isArray
? { data: { path: [dim.dataPath], array_contains: v } }
......
......@@ -89,7 +89,11 @@ describe('spec.display —— 详情页说明', () => {
describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
test('⭐ 可筛维度的 key 必须是在跑的标签(筛一个不产出的标签 = 永远 0 结果)', async () => {
const live = new Set(await registryKeys());
const orphan = PERSONA_TAG_FILTER_DIMS.filter((d) => !live.has(d.key)).map((d) => d.key);
// source='patient' 的维度(上次到诊 / 上次医生 / 偏好医生)查的是 patient_profiles 列,
// 不是画像特征 —— 它们 key 为空,不参与本校验。为什么不做成画像:见 schema.prisma 注释。
const orphan = PERSONA_TAG_FILTER_DIMS.filter(
(d) => d.source !== 'patient' && !live.has(d.key),
).map((d) => d.key);
expect(orphan).toEqual([]);
});
......
......@@ -114,6 +114,11 @@ function makeHarness(o: Opts) {
const prisma = {
patient: { findUnique: jest.fn().mockResolvedValue(PATIENT) },
// 到诊派生字段(上次到诊 / 医生)写在 profile 上 —— 不是画像,只是借这趟遍历顺路刷新
patientProfile: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({}),
},
// readWatermarks(patient_facts / patient_transactions)与 M 分位查询共用 $queryRaw,靠 SQL 文本分流
$queryRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
const sql = strings.join('');
......
......@@ -392,7 +392,6 @@ export const PersonaFeatureKey = {
TREATMENT_SENSITIVITY: 'treatment_sensitivity', // 治疗敏感(看牙恐惧/晕针/晕血/密闭恐惧;病历关键词)
CONTRAINDICATION: 'contraindication', // 禁忌标签(v1 仅种植年龄≤18;余 Layer C)
URGENCY_LEVEL: 'urgency_level', // 急迫等级(紧急/高/中/低;潜在治疗路径×末诊)
VISIT_RECENCY: 'visit_recency', // 上次到诊(时间分档 + 那次的医生 + 主治医生;卡片展示 + 圈人筛选共用)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -55,7 +55,6 @@ export const planScenarioLabel = (key: string): string =>
export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> = {
// ⚠️ 警示
[PersonaFeatureKey.URGENCY_LEVEL]: { label: '急迫等级', tone: 'rose' },
[PersonaFeatureKey.VISIT_RECENCY]: { label: '上次到诊', tone: 'amber' },
[PersonaFeatureKey.SPECIAL_ATTENTION]: { label: '特别关注', tone: 'rose' },
[PersonaFeatureKey.CONTRAINDICATION]: { label: '禁忌标签', tone: 'rose' },
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: { label: '治疗敏感', tone: 'rose' },
......
......@@ -546,37 +546,6 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
version: 1,
},
// ── B.2.x 上次到诊(时间分档 + 那次的医生 + 主治医生)──
visit_recency: {
key: 'visit_recency',
nameZh: '上次到诊',
tier: 'rule',
timeSemantics: 'snapshot',
labelValues: ['0-3 个月', '3-6 个月', '6-12 个月', '12-18 个月', '18 个月以上'],
dataSource: '到诊事实(visitFactsOf:encounter + actual treatment + 挂号,与 rfm/lifecycle/urgency 同口径)+ 病历 doctor_name',
dataFields: ['encounter_record', 'treatment_record', 'visit_registration_record', 'emr_record.doctor_name'],
meaning: '上次到诊多久了、那次是谁接的诊、最常看的是哪位医生。列表卡片展示 + 圈人筛选共用同一份,避免"卡片一个口径、筛选另一个口径"',
algorithm: [
'末诊 = visitSpanOf(visitFactsOf).last;距今天数分档 0-3/3-6/6-12/12-18/18+ 月(边界 90/180/365/547 天)。',
'lastDoctor = **末诊当天**那份病历的 doctor_name;跨天不认(否则日期与医生错配)。取不到 → 空。',
'preferredDoctor = 病历里出现频次最高的医生(并列取最近一次)。这是拿「主治医生」代替业务要的',
'「偏好医生」—— DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入。',
'纯时间流逝会跨桶 → 实现 nextBoundaryAt,否则水位闸不放行、分档永远停在算的那一刻。',
].join('\n'),
display: {
subtitle: '上次来是什么时候、谁接的诊',
rules: [
{ label: '0-3 个月', body: '近三个月来过' },
{ label: '6-12 个月', body: '半年到一年前来的' },
{ label: '18 个月以上', body: '一年半以上没来过' },
],
group: 'action',
order: 21,
},
owner: 'pac-algo',
version: 1,
},
// ── D.2.4 禁忌标签(年龄 + 病历临床信号裁决)──
contraindication: {
key: 'contraindication',
......
......@@ -68,6 +68,19 @@ export interface PersonaTagFilterDim {
* "模糊"体验交给前端:输入框在已加载的候选列表里做客户端匹配,选中后发精确值。
*/
dynamic?: boolean;
/**
* 取值从哪张表来。默认 'persona'(persona_features.data[dataPath])。
*
* 'patient' = 直接查 patient_profiles 的列(经 patients.profile 关系) —— 用于**事实派生属性**(上次到诊 / 上次医生 / 偏好医生)。
* 它们刻意**不做成画像特征**:「上次到诊 2022-06-19」是原始事实,而画像该是
* 「重要价值 / 流失客 / 禁忌」这类判断,混在一起会污染画像标签抽屉和画像摘要 LLM
* (2026-07-29 第一版就是那么做的,当天被否)。
* 附带好处:普通列 + btree,不用穿 patients → personas → persona_features 两层 EXISTS + JSONB;
* 卡片展示与筛选读**同一处**,不会出现「卡片显示 A、筛选按 B 匹配」。
*/
source?: 'persona' | 'patient';
/** source='patient' 时:patient_profiles 上的字段名(Prisma 字段名) */
patientField?: 'lastVisitAt' | 'lastVisitDoctor' | 'preferredDoctor';
options: PersonaTagOption[];
}
......@@ -109,9 +122,12 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
// ══════════════ Tier2 · 行为维度 ══════════════
{
key: 'visit_recency',
id: 'last_visit_bucket',
key: '',
source: 'patient',
patientField: 'lastVisitAt',
nameZh: '上次时间',
dataPath: 'bucket',
dataPath: '',
options: VISIT_RECENCY_BUCKETS.map((b) => ({ value: b.value, zh: b.zh, hint: b.hint })),
},
// 「上次医生 / 偏好医生」是**开集维度**(单宿主几百个医生),取值不做闭集校验,
......@@ -121,17 +137,21 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
// DW fact_client_out.favor_doctor_id 才是真·偏好医生,PAC 尚未摄入,接入后换值即可,前端不用改。
{
id: 'visit_doctor_last',
key: 'visit_recency',
key: '',
source: 'patient',
patientField: 'lastVisitDoctor',
nameZh: '上次医生',
dataPath: 'lastDoctor',
dataPath: '',
dynamic: true,
options: [],
},
{
id: 'visit_doctor_preferred',
key: 'visit_recency',
key: '',
source: 'patient',
patientField: 'preferredDoctor',
nameZh: '偏好医生',
dataPath: 'preferredDoctor',
dataPath: '',
dynamic: true,
options: [],
},
......
......@@ -15,3 +15,31 @@ export const VISIT_RECENCY_BUCKETS = [
{ value: '12_18m', zh: '12-18 个月', hint: '上次到诊在一年到一年半前' },
{ value: '18m_plus', zh: '18 个月以上', hint: '一年半以上没来过' },
] as const;
/**
* 分档 code → `last_visit_at` 的日期区间(半开区间 [gte, lt))。
*
* ⭐ 现算而不是存死分档:存 bucket 会随时间过期(今天 2 个月、下个月就 3 个月),
* 得配一整套「到期重算」机械;存日期 + 查询时现算区间,永远准,零维护。
* 未知 code → null(调用方丢弃该条,不产生"筛了个寂寞"的全集查询)。
*/
export function visitRecencyRange(
bucket: string,
now: Date,
): { gte?: Date; lt?: Date } | null {
const daysAgo = (d: number) => new Date(now.getTime() - d * 86400_000);
switch (bucket) {
case '0_3m':
return { gte: daysAgo(90) };
case '3_6m':
return { gte: daysAgo(180), lt: daysAgo(90) };
case '6_12m':
return { gte: daysAgo(365), lt: daysAgo(180) };
case '12_18m':
return { gte: daysAgo(547), lt: daysAgo(365) }; // 18 个月 ≈ 547 天
case '18m_plus':
return { lt: daysAgo(547) };
default:
return null;
}
}
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