Commit c241da85 by luoqi

feat(persona): 新增 visit_recency 特征 —— 上次到诊分档/医生;筛选项改隐藏而非删除

按 2026-07-29 四点反馈做:

【1】下线的 5 个筛选项改成**隐藏**而非删除。
  PersonaTagFilterDim 加 `hidden?: boolean`,面板 `.filter(d => !d.hidden)`。
  这样 API / MCP 仍接受这些维度 —— 已保存的筛选链接、机器人调用不会突然失效;
  persona_features 照常写、偏索引照常在,复活只需删一行。
  (上一版直接从字典摘掉,连 API 一起禁了,比"前端隐藏"狠。)

【2】【4】新增画像特征 visit_recency —— 一个特征喂三处:
  · 列表卡片的「2022-06-19 · 张敏」
  · 圈人筛选的「上次时间」(0-3/3-6/6-12/12-18/18+ 月),已接面板
  · 「上次医生 / 偏好医生」数据已就位,面板未接(医生是高基数,静态 options 的 chip
    面板放不下,需可搜索下拉 —— 已在 persona-tag-filters 注释说明)

   偏好医生用**主治医生**代替(病历里出现频次最高的医生)——
    DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;换成真值时消费方不用改。

   到诊口径用 visitFactsOf(encounter + actual treatment + 挂号),与 rfm / lifecycle /
    urgency **同一口径**。我上一版卡片是现算 encounter ∪ emr —— 两套口径会算出两个"末诊",
    卡片与筛选打架。本次把卡片改成读同一份画像行,fetchLastVisits 删除,
    两条查询合并成 fetchCardFeatures 一条(顺带少一次往返)。

   lastDoctor 只认**末诊当天**那份病历的医生,跨天不认 —— 否则"日期是A次、医生是B次",
    客服照着说就露馅,宁可留空。本地实测填充率:主治医生 1133/1133,上次医生 392/1133(34.6%)
    —— 因为末诊常落在没有当天病历的到诊上(挂号/治疗)。这是刻意的准确性取舍。

   实现 nextBoundaryAt:纯时间流逝就会跨桶,不报的话水位闸永远不放行、分档停在算的那一刻。

【3】筛选字段加索引:迁移 20260729080000,visit_recency.bucket 的偏索引
  (btree(表达式, persona_id),第二列让内层 EXISTS 走 index-only scan)。
  这是 persona-tag-filters 顶部那条纪律的兑现 —— 加维度必须同时加索引,
  漏了就是 2026-07-29 那个「圈人 20 秒」。新特征建索引时表里还没数据,故无需 CONCURRENTLY。

登记:PersonaFeatureKey / FeatureRegistry / PERSONA_FEATURE_SPECS / PERSONA_FEATURE_META
  四处齐全(测试 persona-spec-drift 会卡漏登记,本次就被它卡住两次)。

654 tests / 42 suites 全过;types / service / web typecheck 通过;
本地重算实测特征写入正常。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 487041a9
-- CreateIndex —— 新筛选维度「上次时间」(visit_recency.bucket)的偏索引
--
-- 与 20260728020000 那 14 条同一套路数、同一理由,不再重复长篇:
-- Prisma 把画像筛选编译成 `key = 'visit_recency' AND (data #> '{bucket}') = '"0_3m"'`,
-- 没有这条索引,planner 只能按 key 捞出**全部** visit_recency 行再回堆过滤 jsonb。
-- 2026-07-29 生产实测过后果:禁忌维度 190,210 行里只中 2,156 行,读 1.3GB 堆、单次筛人 20+ 秒。
--
-- ⚠️ **加筛选维度必须同时加索引** —— 这是 persona-tag-filters.ts 顶部写死的纪律,
-- 本条就是「上次时间」维度的那一条。漏了它,新维度一上线就是 20 秒起步。
--
-- 形态:标量维度(equals)→ btree (表达式, persona_id)。
-- 第二列 persona_id 不是排序用,是让内层 EXISTS 走 **index-only scan**:
-- 它只 SELECT persona_id,索引自带就不用回堆(详见 20260728020000 的实测数据)。
--
-- 【为什么不是 CONCURRENTLY】本文件只有 1 条语句,本可以 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';
......@@ -15,6 +15,7 @@ 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';
/**
......@@ -48,6 +49,7 @@ export const FEATURE_EXTRACTOR_PROVIDERS = [
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
EntitlementStatusFeatureExtractor,
VisitRecencyFeatureExtractor,
];
@Injectable()
......@@ -71,7 +73,8 @@ 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];
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, urgencyLevel, entitlement, visitRecency];
}
}
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;
}
......@@ -16,6 +16,7 @@ import {
type PlanCountsResponse,
type PlanDetailResponse,
type PlanReasonBrief,
PersonaFeatureKey,
} from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -114,10 +115,7 @@ export class PlanService {
// 卡片补充信息(上次到诊 / 那次的医生 / 潜在治疗标签)—— 两条**整页批量**查询,
// 不逐行发。本页最多 pageSize(25)个患者,都走 patient_id 索引。
const [lastVisitByPatient, potentialByPatient] = await Promise.all([
this.fetchLastVisits(scope, patientIds),
this.fetchPotentialTreatments(patientIds),
]);
const cardFeatures = await this.fetchCardFeatures(patientIds);
// 每个 plan 最近一次执行结果(左栏「成功/不成功/保持」角标用)。
// 本页 plan 数少(≤pageSize),执行记录也少;一次拉齐后 JS 取每 plan 最新一条。
......@@ -152,9 +150,9 @@ export class PlanService {
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
medicalRecordNumber: patient?.medicalRecordNumber ?? null,
lastVisitAt: lastVisitByPatient.get(p.patientId)?.at ?? null,
lastVisitDoctor: lastVisitByPatient.get(p.patientId)?.doctor ?? null,
potentialTreatments: potentialByPatient.get(p.patientId) ?? [],
lastVisitAt: cardFeatures.get(p.patientId)?.lastVisitAt ?? null,
lastVisitDoctor: cardFeatures.get(p.patientId)?.lastVisitDoctor ?? null,
potentialTreatments: cardFeatures.get(p.patientId)?.potentialTreatments ?? [],
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
......@@ -167,75 +165,49 @@ export class PlanService {
}
/**
* 整页患者的「上次到诊 + 那次的医生」—— 一条 DISTINCT ON 取齐
* 列表卡片要的画像字段 —— **一条查询**取回整页患者的两个特征
*
* 口径与详情页 lastVisit 一致:**encounter_record ∪ emr_record** 取 occurred_at 最大的那次。
* 并集是必须的 —— 部分宿主 appointment.in_time 缺失,只有 EMR 能证明患者到过场
* (jvs-dw 实测有患者 0 encounter / 39 emr)。
* 合并成一条的理由不只是省一次往返:
* · potential_treatment → 潜在治疗标签
* · visit_recency → 上次到诊日期 + 那次的医生
* 后者刻意**读画像而不是现算** —— 现算就会出现"卡片按 A 口径、圈人筛选按 B 口径"的分裂
* (2026-07-29 我第一版就是现算 encounter ∪ emr,与画像侧 visitFactsOf 口径不同,已收敛)。
* 现在卡片显示什么、筛选筛出什么,同源同一份 persona_features 行。
*
* ⭐ 医生取的是**同一次就诊**的医生,不是"历史最常见医生" —— 否则会出现
* 「日期是 A 次、医生是 B 次」的错配,客服照着说就露馅。
* doctor_name 只有 emr_record 带(encounter_record 实测两个宿主都是 0% 有名),
* 所以同一时刻并列时优先取 emr;取不到名就返 null,前端不显示医生,不退而求其次拿 id 顶替
* (医生 id 对客服无意义,显示出来只会让人以为是工号)。
* 测试服实测覆盖率:池内 2,990 个有就诊记录的患者里 2,966 拿得到医生名(99.2%)。
* 拿不到画像(新患者尚未算过)→ 该行字段留空,前端不占位,不做兜底现算。
*/
private async fetchLastVisits(
scope: TenantScopeContext,
patientIds: string[],
): Promise<Map<string, { at: string; doctor: string | null }>> {
const out = new Map<string, { at: string; doctor: string | null }>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.$queryRaw<
Array<{ patient_id: string; occurred_at: Date; doctor_name: string | null }>
>`
SELECT DISTINCT ON (patient_id)
patient_id,
occurred_at,
nullif(content->>'doctor_name', '') AS doctor_name
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${patientIds}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC, (type = 'emr_record') DESC
`;
for (const r of rows) {
out.set(r.patient_id, {
at: r.occurred_at.toISOString().slice(0, 10),
doctor: r.doctor_name,
});
}
return out;
}
/**
* 整页患者的潜在治疗标签 —— 取**当前版**画像的 potential_treatment.labels。
*
* 注意它跟卡片上的「召回理由」高度重叠但不等价:画像覆盖该患者**全部**潜在治疗
* (诊断了没做),召回理由只是其中"这次值得召回"的子集。业务要的是前者(出自画像)。
*/
private async fetchPotentialTreatments(patientIds: string[]): Promise<Map<string, string[]>> {
const out = new Map<string, string[]>();
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 }
>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.personaFeature.findMany({
where: {
key: 'potential_treatment',
key: { in: [PersonaFeatureKey.POTENTIAL_TREATMENT, PersonaFeatureKey.VISIT_RECENCY] },
persona: { patientId: { in: patientIds }, supersededAt: null },
},
select: { data: true, persona: { select: { patientId: true } } },
select: { key: true, data: true, persona: { select: { patientId: true } } },
});
for (const r of rows) {
// ⭐ 取 types(code)不取 labels(中文):labels 是算画像那刻写死进 JSON 的,
// 改措辞得全量重算才生效;code 稳定,中文由前端查 POTENTIAL_TREATMENT_CARD_LABEL。
const types = (r.data as { types?: unknown } | null)?.types;
if (!Array.isArray(types)) continue;
out.set(
r.persona.patientId,
types.filter((t): t is string => typeof t === 'string' && t.length > 0),
);
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;
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(pid, cur);
}
return out;
}
......
......@@ -384,7 +384,9 @@ function PersonaTagFilter({
光看名字猜不出边界(重要保持 vs 重要挽留差在哪?待激活 vs 沉睡客几个月分界?),
客服只能凭感觉勾。说明文案在 persona-tag-filters.ts 的 hint 字段。 */}
<div className="max-h-80 space-y-2 overflow-y-auto">
{PERSONA_TAG_FILTER_DIMS.map((dim) => (
{/* hidden 维度不进面板(业务下线的那几个);它们在 API / MCP 侧仍然有效,
已保存的筛选链接不会失效 —— 见 persona-tag-filters.ts 的 hidden 注释。 */}
{PERSONA_TAG_FILTER_DIMS.filter((d) => !d.hidden).map((dim) => (
<div key={dim.key}>
<div className="px-1 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
{dim.nameZh}
......
......@@ -392,6 +392,7 @@ 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', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -5,4 +5,5 @@ export * from './labels';
export * from './canonical-codes';
export * from './persona-feature-specs';
export * from './clinical-signals';
export * from './visit-recency';
export * from './persona-tag-filters';
......@@ -55,6 +55,7 @@ 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,6 +546,37 @@ 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',
......
import { VISIT_RECENCY_BUCKETS } from './visit-recency';
/**
* 画像标签筛选字典 — 列表/选人侧"按画像圈人"的可筛维度(单一真理源)。
*
......@@ -38,6 +40,15 @@ export interface PersonaTagFilterDim {
dataPath: string;
/** true = data[dataPath] 是 string[](用 array_contains);false = 标量 equals */
isArray?: boolean;
/**
* true = **前端筛选面板不展示**,但维度本身仍然有效。
*
* 刻意做成"隐藏"而不是"删除":
* · API / MCP 仍接受该维度 —— 已保存的筛选链接、机器人调用不会突然失效;
* · persona_features 照常写这些特征、偏索引(迁移 20260728020000)照常在,复活只需删这一行;
* · 业务口径反复过不止一次,删了重写的成本远高于留一个 flag。
*/
hidden?: boolean;
options: PersonaTagOption[];
}
......@@ -72,6 +83,20 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
// ══════════════ Tier2 · 行为维度 ══════════════
{
key: 'visit_recency',
nameZh: '上次时间',
dataPath: 'bucket',
options: VISIT_RECENCY_BUCKETS.map((b) => ({ value: b.value, zh: b.zh, hint: b.hint })),
},
// 「上次医生 / 偏好医生」数据已就位(同一个 visit_recency 特征的 lastDoctor / preferredDoctor),
// 但**没做成筛选维度**:医生是高基数(单宿主几百个),PersonaTagFilterDim 的 options 是静态闭集,
// 平铺 chip 的面板放不下 —— 需要换成可搜索下拉,是另一个 UI 组件的活。
// 数据侧已经能筛(dataPath 直接指 lastDoctor / preferredDoctor),接 UI 时补两条 dim 即可。
// 注:preferredDoctor 现在是拿「主治医生」(病历里出现频次最高的医生)代替 ——
// DW fact_client_out.favor_doctor_id 才是真·偏好医生,PAC 尚未摄入。
// ══════════════ Tier3 · 属性维度 —— 定语气与话术分寸,不决定要不要打 ══════════════
{
key: 'rfm',
......@@ -157,24 +182,90 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
// ══════════════ Tier2 · 行为维度(偏好医生 / 上次医生 / 上次时间)—— 留位待接入 ══════════════
// 业务 2026-07-29 提的三个维度,数据基础还不到位,先留位不上:
// · 偏好医生:DW fact_client_out.favor_doctor_id 有,但 **PAC 尚未摄入**,且没有医生字典表;
// · 上次医生:目前只存在于 patient_facts.content.doctor_name(JSONB)。直接拿它筛 = 全表 JSONB 扫,
// 正是 2026-07-29 刚修完那个「圈人筛选 20 秒」的翻版,必须先落成可索引字段;
// · 上次时间区间(0-3 / 3-6 / 6-12 / 12-18 / 18+ 月):同上,建议做成画像特征(recency 分档),
// 那样与其余维度共用同一套偏索引,零额外查询代价。
// 注:生命周期(潜客/新客/…/流失客)本质就是 recency 分档。业务要下线它、同时新增「上次时间」,
// 两者信息高度重叠,只是后者对客服更直白 —— 接入时一起想清楚,别做成两套。
// ══════════════ 已隐藏(hidden: true)—— 面板不展示,维度仍有效 ══════════════
{
key: 'lifecycle_stage',
hidden: true, // 业务 2026-07-29 下线:与召回决策关系不大,面板不展示(API/MCP 仍可用)
nameZh: '生命周期',
dataPath: 'stage',
options: [
{ value: 'prospect', zh: '潜客', hint: '还没到过诊,只约过或咨询过' },
{ value: 'new', zh: '新客', hint: '首诊半年内,就诊不超过 3 次' },
{ value: 'growth', zh: '成长客', hint: '还在来,近一年消费明显上涨' },
{ value: 'mature', zh: '成熟客', hint: '还在来,消费平稳' },
{ value: 'reactivate', zh: '待激活', hint: '半年到一年半没来' },
{ value: 'dormant', zh: '沉睡客', hint: '一年半到两年没来' },
{ value: 'churned', zh: '流失客', hint: '两年以上没来' },
],
},
{
key: 'treatment_sensitivity',
hidden: true, // 业务 2026-07-29 下线:与召回决策关系不大,面板不展示(API/MCP 仍可用)
nameZh: '治疗敏感',
dataPath: 'types',
isArray: true,
options: [
{ value: 'dental_fear', zh: '看牙恐惧', hint: '病历里提过害怕看牙' },
{ value: 'needle_faint', zh: '晕针', hint: '病历里提过晕针 / 怕打针' },
{ value: 'blood_faint', zh: '晕血', hint: '病历里提过晕血' },
{ value: 'claustrophobia', zh: '密闭恐惧', hint: '病历里提过幽闭 / 长时间张口不适' },
],
},
{
key: 'special_attention',
hidden: true, // 业务 2026-07-29 下线:与召回决策关系不大,面板不展示(API/MCP 仍可用)
nameZh: '特殊关注',
dataPath: 'types',
isArray: true,
options: [
{ value: 'frequent_no_show', zh: '屡次爽约', hint: '近一年至少 3 次预约,履约不到一半' },
{ value: 'often_late', zh: '经常迟到', hint: '近一年多次晚到 15 分钟以上' },
{ value: 'do_not_disturb', zh: '免打扰', hint: '已标记不接受主动联系,不要外呼' },
{ value: 'cannot_wait', zh: '不可等候', hint: '病历或备注提过赶时间 / 不能等' },
],
},
{
key: 'time_preference',
hidden: true, // 业务 2026-07-29 下线:与召回决策关系不大,面板不展示(API/MCP 仍可用)
nameZh: '时间偏好',
dataPath: 'types',
isArray: true,
options: [
{ value: 'weekday', zh: '工作日', hint: '近两年预约多在工作日' },
{ value: 'weekend', zh: '周末', hint: '近两年预约多在周末' },
{ value: 'morning', zh: '上午', hint: '多约在 8-12 点' },
{ value: 'afternoon', zh: '下午', hint: '多约在 12-18 点' },
{ value: 'evening', zh: '晚间', hint: '多约在 18-21 点' },
],
},
{
key: 'contraindication',
hidden: true, // 业务 2026-07-29 下线:与召回决策关系不大,面板不展示(API/MCP 仍可用)
nameZh: '禁忌',
/**
* 按【治疗域】筛,不按病因 —— data.domains 而非 data.types。
*
* 客服圈人时想的是「哪些人不能做种植」,不是「哪些人在吃抗骨吸收药」;
* 病因(抗凝药/放疗史/双膦酸盐…)是算法口径,列成筛选项等于把内部实现摊给业务看。
* 病因照常在关键事实卡和画像详情里露出(带医生原话),要查"所有放疗史患者"是另一个
* 场景(风险排查),需要时再加一个独立维度,别混进这里。
*
* 不列「拍片禁忌」:唯一来源是妊娠,而妊娠同时禁种植和手术 —— 勾那两项就已经覆盖,
* 单列只会让面板变长。data.domains 里仍有 xray,详情页照常显示。
*
* 核查项(糖尿病/高血压/骨质疏松/吸烟)不在这里 —— 它们落 data.checks 而非 data.domains,
* 按定义就不是禁忌,放进来会让客服把「问一句」当成「不能治」。
*/
dataPath: 'domains',
isArray: true,
options: [
{ value: 'surgery', zh: '手术禁忌', hint: '拔牙 / 翻瓣 / 植骨 —— 抗凝药、凝血障碍、抗骨吸收药、放疗史、妊娠期' },
{ value: 'implant', zh: '种植禁忌', hint: '未满 19 岁,或放疗史 / 抗骨吸收药 / 抗凝药 / 心血管手术史 / 妊娠哺乳' },
{ value: 'anesthesia', zh: '麻醉禁忌', hint: '局麻药物过敏;哮喘为相对禁忌' },
{ value: 'orthodontic', zh: '正畸禁忌', hint: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
],
},
// ══════════════ 已下线的维度(业务 2026-07-29 判定与召回决策关系不大)══════════════
// 刻意**注释保留而非删除**:业务口径反复过不止一次,留着比重写便宜;
// 且 persona_features 仍在写这些特征、偏索引也还在(见迁移 20260728020000),随时可复活。
// time_preference 时间偏好 / lifecycle_stage 生命周期 /
// treatment_sensitivity 治疗敏感 / special_attention 特殊关注 / contraindication 禁忌
//
// ⚠️ 禁忌下线的**只是「圈人筛选」这一处**:它仍在患者详情的画像卡上展示、仍参与 persona 计算。
// 安全信号不因为不能拿来圈人就消失。
];
/** "key:value" 串(query 上线格式)解析;非法项丢弃。 */
......
/**
* 上次到诊的时间分档 —— 圈人筛选的「上次时间」维度取值。
*
* 业务口径(2026-07-29):0-3 / 3-6 / 6-12 / 12-18 / 18 个月以上。
* 边界天数在 visit-recency.feature.ts 的 BUCKET_EDGES,两处要一起改。
*
* ⚠️ 与「生命周期」(潜客/新客/…/流失客)信息高度重叠 —— 后者本质也是 recency 分档,
* 只是掺了首诊时间与就诊次数。业务这次要下线生命周期、改用这个更直白的口径。
* 两者并存时别让客服以为是两件事。
*/
export const VISIT_RECENCY_BUCKETS = [
{ value: '0_3m', zh: '0-3 个月', hint: '近三个月来过' },
{ value: '3_6m', zh: '3-6 个月', hint: '上次到诊在 3 到 6 个月前' },
{ value: '6_12m', zh: '6-12 个月', hint: '上次到诊在半年到一年前' },
{ value: '12_18m', zh: '12-18 个月', hint: '上次到诊在一年到一年半前' },
{ value: '18m_plus', zh: '18 个月以上', hint: '一年半以上没来过' },
] as const;
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