Commit e874e74b by luoqi

feat(persona): 详情页说明收回单一真理源 + 打通证据链 + 分组定序

页面上「对画像的解释」的四个毛病,一次修掉。

## ① 说明写错了 —— 前端自己硬编了一份算法字典

persona-feature-hover.tsx 里的 ALGORITHMS 与后端口径各写各的,已经漂:
  · 权益身份只列「商保客户 / 医保结算」2 类,extractor 实际产 5 类;还承诺
    「显示保司 + 最近日期」—— description/data 里压根没有这两样
  · 转介绍达人写「社交型 = 推外人 ≥3 人」,真实口径是「推荐 ≥3 人**且**带来成交,
    直系亲属关系 <3 才归社交型」
  · 急迫等级缺档位说明;年龄段 labelValues 写 0-3 / 46-55,与自家 algorithm 的
    0-2 / 46-54 自相矛盾
客服是照着这段话跟患者讲的,写错比不写更糟。

改为收进 PERSONA_FEATURE_SPECS[key].display(与 algorithm 口径贴脸放,前端只渲染)。
不新开接口 —— packages/types 本来就是前后端共享的。
新增 tests/persona-spec-drift.spec.ts:registry  注册表  圈人字典三方必须对齐,
每个标签必须有 display —— 光收拢不设闸,过两个月照样漂。

## ② 只给结论不给出处

后端一直存着 evidence.factIds(生产上 rfm + lifecycle 两个特征就占 1 GB),
但 adapt-data 把它写死成 `evidence: []`。现在 /full 透出 evidenceFactIds,
详情页反查同一响应的 facts 渲染「依据 · N 条」(默认 4 行,可展开)。
本地实测证据 id 100% 可解析。factLabel 从 tooth-timeline 抽成共享模块并补齐
结算/预约/就诊/病历等类型 —— 否则证据行会把 `payment_record` 这种表名甩给客服。

## ③ 顺序是随机的

抽屉排序取 `orderBy createdAt`,而同一版 feature 行是一条 createMany 写进去的、
createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key 字母序:
「急迫等级 = 紧急」排最后一个,「性别」「获客渠道」排最前。
现按用途分三组(跟进要点 / 价值与阶段 / 基础属性),组与组内序都定义在标签卡里。
标签云用同一套序。

## ④ `?` 够不着

原来是 16×16 的 <span>,无 tabindex 无 role、纯 hover —— 键盘、读屏、平板触屏都点不到
(可访问性树里一个都读不出来)。改成 24×24 button,focus 即可唤出说明卡,
aria-label 带标签名。

顺带:「更新于」改用 refreshedAt(就地刷新不升版本,computedAt 会停在旧值,
拿它显示会让客服以为数据比实际更旧);extractor 清单从 module/registry 两处收成一处。

## 验证

432 tests green · pac-web build 通过 · 本地真实数据人工验:
分组与依据渲染正确、hover 内容来自 spec、`?` 可聚焦且 focus 能唤出对应说明卡。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 889dd6f0
...@@ -26,6 +26,30 @@ import { UrgencyLevelFeatureExtractor } from './urgency-level.feature'; ...@@ -26,6 +26,30 @@ import { UrgencyLevelFeatureExtractor } from './urgency-level.feature';
* 合规闸召回读 patient_profiles 原始列(不依赖本特征);已故/投诉信号后续补(投诉数据缺)。 * 合规闸召回读 patient_profiles 原始列(不依赖本特征);已故/投诉信号后续补(投诉数据缺)。
* 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial。 * 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial。
*/ */
/**
* 全部 extractor 的 DI provider 清单 —— PersonaModule 与测试共用这一份。
* (原先 module.providers 与本文件构造参数各写一遍,加特征时容易只改一边。)
* extractor 都是无依赖纯函数类,所以测试里 `providers: [FeatureRegistry, ...此清单]` 即可跑起来。
*/
export const FEATURE_EXTRACTOR_PROVIDERS = [
RfmFeatureExtractor,
AgeBracketFeatureExtractor,
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
ReferralChampionFeatureExtractor,
LifecycleStageFeatureExtractor,
TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DiscountAnchorFeatureExtractor,
SpecialAttentionFeatureExtractor,
TreatmentSensitivityFeatureExtractor,
ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
EntitlementStatusFeatureExtractor,
];
@Injectable() @Injectable()
export class FeatureRegistry { export class FeatureRegistry {
readonly extractors: FeatureExtractor[]; readonly extractors: FeatureExtractor[];
......
...@@ -2,23 +2,7 @@ import { Module } from '@nestjs/common'; ...@@ -2,23 +2,7 @@ import { Module } from '@nestjs/common';
import { PersonaController } from './persona.controller'; import { PersonaController } from './persona.controller';
import { PersonaService } from './persona.service'; import { PersonaService } from './persona.service';
import { PersonaCoverageMonitorService } from './persona-coverage-monitor.service'; import { PersonaCoverageMonitorService } from './persona-coverage-monitor.service';
import { FeatureRegistry } from './features/feature.registry'; import { FeatureRegistry, FEATURE_EXTRACTOR_PROVIDERS } from './features/feature.registry';
import { EntitlementStatusFeatureExtractor } from './features/entitlement-status.feature';
import { RfmFeatureExtractor } from './features/rfm.feature';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
import { GenderFeatureExtractor } from './features/gender.feature';
import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './features/family-structure.feature';
import { ReferralChampionFeatureExtractor } from './features/referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './features/time-preference.feature';
import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.feature';
import { SpecialAttentionFeatureExtractor } from './features/special-attention.feature';
import { TreatmentSensitivityFeatureExtractor } from './features/treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './features/contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './features/potential-treatment.feature';
import { UrgencyLevelFeatureExtractor } from './features/urgency-level.feature';
import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module'; import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
@Module({ @Module({
...@@ -28,23 +12,8 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module'; ...@@ -28,23 +12,8 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
PersonaService, PersonaService,
PersonaCoverageMonitorService, PersonaCoverageMonitorService,
FeatureRegistry, FeatureRegistry,
// W7:rfm 统一了旧 value/recall_risk/treatment_chain_status,三个旧 extractor 已摘除。 // extractor 清单收在 feature.registry.ts(与 FeatureRegistry 构造参数同一份,免两边漏改)
RfmFeatureExtractor, ...FEATURE_EXTRACTOR_PROVIDERS,
AgeBracketFeatureExtractor,
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
ReferralChampionFeatureExtractor,
LifecycleStageFeatureExtractor,
TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DiscountAnchorFeatureExtractor,
SpecialAttentionFeatureExtractor,
TreatmentSensitivityFeatureExtractor,
ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
EntitlementStatusFeatureExtractor,
], ],
exports: [PersonaService], exports: [PersonaService],
}) })
......
...@@ -535,12 +535,22 @@ function serializePersona(persona: { ...@@ -535,12 +535,22 @@ function serializePersona(persona: {
id: string; id: string;
version: number; version: number;
computedAt: Date; computedAt: Date;
features: Array<{ id: string; key: string; description: string; score: number | null; data: unknown }>; refreshedAt: Date | null;
features: Array<{
id: string;
key: string;
description: string;
score: number | null;
data: unknown;
evidence: unknown;
}>;
}) { }) {
return { return {
id: persona.id, id: persona.id,
version: persona.version, version: persona.version,
computedAt: persona.computedAt.toISOString(), computedAt: persona.computedAt.toISOString(),
/// 内容真实新鲜度:就地刷新过就报刷新时刻(computedAt 是版本创建时刻,覆盖率日报靠它反推快照)
refreshedAt: (persona.refreshedAt ?? persona.computedAt).toISOString(),
features: persona.features.map((f) => ({ features: persona.features.map((f) => ({
id: f.id, id: f.id,
key: f.key, key: f.key,
...@@ -548,10 +558,21 @@ function serializePersona(persona: { ...@@ -548,10 +558,21 @@ function serializePersona(persona: {
score: f.score, score: f.score,
/// 结构化 payload(如 entitlement_status 的 {commercialInsured, commercialInsurers, medicalInsured...}) /// 结构化 payload(如 entitlement_status 的 {commercialInsured, commercialInsurers, medicalInsured...})
data: f.data ?? null, data: f.data ?? null,
/// 证据 fact id —— 详情页「依据」区反查同一响应里的 facts 渲染。
/// 之前这里没透出,前端 evidence 恒为空数组:库里存着的证据链(rfm/lifecycle 两个
/// 特征就占 1 GB)一条都没被用上,客服只能看到结论看不到出处。
evidenceFactIds: extractFactIds(f.evidence),
})), })),
}; };
} }
/// evidence 形状由各 extractor 自管({factIds, agentInvocationIds?}),这里只取 factIds 且防脏
function extractFactIds(evidence: unknown): string[] {
if (!evidence || typeof evidence !== 'object') return [];
const ids = (evidence as { factIds?: unknown }).factIds;
return Array.isArray(ids) ? ids.filter((x): x is string => typeof x === 'string') : [];
}
/** /**
* W4 新加:PlanScript serializer + markdown → sections 反 parse。 * W4 新加:PlanScript serializer + markdown → sections 反 parse。
* *
......
import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_FEATURE_META } from '@pac/types';
import {
FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS,
} from '../src/modules/persona/features/feature.registry';
/**
* 标签注册表防漂移。
*
* 由来:同一个「这个标签怎么算的」历史上有四份 —— PERSONA_FEATURE_SPECS.algorithm、
* 前端 persona-feature-hover.tsx 的 ALGORITHMS、extractor 顶部 JSDoc、extractor 代码。
* 前端那份靠人肉同步,已经漂了:
* · 权益身份 hover 只列 2 类,extractor 实际产 5 类,还承诺了数据里没有的「保司 + 日期」
* · 转介绍达人 hover 写「社交型 = 推外人 ≥3 人」,真实门槛是「推荐 ≥3 人且带来成交」
* 客服照着 hover 跟患者讲,说明写错比不写更糟。
*
* 现在说明收进标签卡(spec.display),前端只渲染不编内容。本文件守住"注册表 ↔ 实现"的一致:
* 光收拢不设闸,过两个月照样会漂。
*/
/** extractor 都是无依赖纯函数类 → 不用起 PersonaModule(那会拽进 Prisma / ClinicalGap) */
async function registryKeys(): Promise<string[]> {
const mod = await Test.createTestingModule({
providers: [FeatureRegistry, ...FEATURE_EXTRACTOR_PROVIDERS],
}).compile();
return mod.get(FeatureRegistry).extractors.map((e) => e.key as string);
}
describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => {
test('⭐ 每个在跑的 extractor 都必须在注册表里登记(新特征上线不许漏登记)', async () => {
const missing = (await registryKeys()).filter((k) => !PERSONA_FEATURE_SPECS[k]);
expect(missing).toEqual([]);
});
test('⭐ 注册表里不留已摘除的标签(摘 extractor 时要一并清掉,否则覆盖率日报会永远报 0%)', async () => {
const live = new Set(await registryKeys());
const stale = Object.keys(PERSONA_FEATURE_SPECS).filter((k) => !live.has(k));
expect(stale).toEqual([]);
});
});
describe('spec.display —— 详情页说明', () => {
test('⭐ 每个标签都要有客服版说明,且至少一条取值条目', () => {
for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
expect(spec.display?.subtitle?.length ?? 0).toBeGreaterThan(0);
expect(spec.display?.rules?.length ?? 0).toBeGreaterThan(0);
for (const r of spec.display.rules) expect(r.body.length).toBeGreaterThan(0);
expect(`${key}:${spec.display.subtitle}`).not.toContain('undefined');
}
});
test('每个标签都有中文展示名(前端 hover 标题取它)', async () => {
for (const key of await registryKeys()) expect(PERSONA_FEATURE_META[key]).toBeDefined();
});
});
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);
expect(orphan).toEqual([]);
});
test('圈人维度的取值不能为空', () => {
for (const d of PERSONA_TAG_FILTER_DIMS) {
expect(d.options.length).toBeGreaterThan(0);
for (const o of d.options) expect(o.value.length).toBeGreaterThan(0);
}
});
});
...@@ -109,6 +109,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -109,6 +109,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
id: real.persona?.id ?? '—', id: real.persona?.id ?? '—',
version: real.persona?.version ?? 0, version: real.persona?.version ?? 0,
computedAt: real.persona ? new Date(real.persona.computedAt) : now, computedAt: real.persona ? new Date(real.persona.computedAt) : now,
/// 内容真实新鲜度:只有天数漂移时后端就地刷新、不升版本,computedAt 会停在版本创建那刻。
/// 老响应没有这个字段 → 退回 computedAt。
refreshedAt: real.persona ? new Date(real.persona.refreshedAt ?? real.persona.computedAt) : now,
features: (real.persona?.features ?? []).map((f) => { features: (real.persona?.features ?? []).map((f) => {
const meta = personaFeatureMeta(f.key); const meta = personaFeatureMeta(f.key);
return { return {
...@@ -116,7 +119,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -116,7 +119,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
label: meta.label, label: meta.label,
value: f.description, value: f.description,
tone: meta.tone, tone: meta.tone,
evidence: [], /// 证据 fact id —— 详情抽屉反查 facts 渲染「依据」。
/// 原来这里写死 [],库里存着的证据链一条都没被用上(客服只看得到结论看不到出处)。
evidence: f.evidenceFactIds ?? [],
data: f.data ?? null, data: f.data ?? null,
} as PersonaFeature; } as PersonaFeature;
}), }),
......
...@@ -2,13 +2,12 @@ ...@@ -2,13 +2,12 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AIStamp, Chip, MD, tone } from './shared'; import { Chip } from './shared';
import { ChainDetailView } from './chain-viz'; import { ChainDetailView } from './chain-viz';
import { EmrSoapView } from './emr-soap-view'; import { EmrSoapView } from './emr-soap-view';
import { FactsTimeline } from './facts-timeline'; import { FactsTimeline } from './facts-timeline';
import { ToothTimeline } from './tooth-timeline'; import { ToothTimeline } from './tooth-timeline';
import { cleanPersonaValue } from './persona-display'; import { PersonaDetailList } from './persona-detail-list';
import { PersonaFeatureHover } from './persona-feature-hover';
import type { Chain, PersonaFeature, PlanReason } from './mock-data'; import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import type { ReturnVisitItem } from './plan-detail-app'; import type { ReturnVisitItem } from './plan-detail-app';
...@@ -35,7 +34,7 @@ export function Drawer({ ...@@ -35,7 +34,7 @@ export function Drawer({
onClose: () => void; onClose: () => void;
kind: DrawerKind; kind: DrawerKind;
chains: Chain[]; chains: Chain[];
persona: { computedAt: Date; features: PersonaFeature[] }; persona: { computedAt: Date; refreshedAt: Date; features: PersonaFeature[] };
summaries: { summaries: {
medical_record: { content: string; generatedAt: Date }; medical_record: { content: string; generatedAt: Date };
treatment_chain: { content: string; generatedAt: Date }; treatment_chain: { content: string; generatedAt: Date };
...@@ -115,64 +114,10 @@ export function Drawer({ ...@@ -115,64 +114,10 @@ export function Drawer({
width = 'w-[560px]'; width = 'w-[560px]';
} else if (kind === 'persona') { } else if (kind === 'persona') {
title = '画像标签'; title = '画像标签';
subtitle = `更新于 ${fmtRel(persona.computedAt)} · ${persona.features.length} 项画像`; // 「更新于」用 refreshedAt(内容真实新鲜度):只有天数漂移时后端就地刷新、不升版本,
body = ( // computedAt 会停在版本创建那刻,拿它显示会让客服以为数据比实际更旧。
<div className="space-y-3"> subtitle = `更新于 ${fmtRel(persona.refreshedAt)} · ${persona.features.length} 项画像`;
<AIStamp relative={fmtRel(persona.computedAt)} label="画像重算" /> body = <PersonaDetailList features={persona.features} facts={facts} />;
{persona.features.map((f) => {
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征回退到 cleanPersonaValue 的主描述文本。
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string')
? (labels as string[])
: null;
return (
<div key={f.key} className="relative rounded-md border border-slate-100 p-3 pr-8">
{/* 右上角 ? hover 看规则说明(算法口径) */}
<PersonaFeatureHover featureKey={f.key} value={f.value}>
<span
className="absolute top-2 right-2 inline-flex items-center justify-center w-4 h-4 rounded-full text-slate-400 cursor-help hover:text-slate-700 hover:bg-slate-100"
aria-label="查看规则说明"
>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</PersonaFeatureHover>
{/* 属性名 */}
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}>
<span className={cn('w-1.5 h-1.5 rounded-full', T.dot)} />
{f.label}
{tag && (
<span className="ml-1 px-1 py-0 rounded bg-slate-100 text-slate-600 text-[10.5px] font-normal">
{tag}
</span>
)}
</div>
{/* 取值条目 — 多值展开为 chip,单值显示文本 */}
{valueLabels && valueLabels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{valueLabels.map((l, i) => (
<span
key={i}
className="inline-flex items-center rounded bg-slate-50 px-1.5 py-0.5 text-[12px] font-medium text-slate-800 ring-1 ring-slate-100"
>
{l}
</span>
))}
</div>
) : (
text && <div className="text-[13px] font-medium text-slate-900 mt-1">{text}</div>
)}
</div>
);
})}
</div>
);
} }
return ( return (
......
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import type { AdaptedFact } from './adapt-data';
/**
* 一条 fact 的紧凑展示形态:徽标 + 一行文本。
*
* 原本只长在 tooth-timeline.tsx 里、只覆盖临床四类(诊断/治疗/建议/影像);
* 画像「依据」区要展示任意类型的证据 fact(结算、预约、就诊、病历都会出现),
* 落到 default 分支会直接把 `payment_record` 这种表名甩给客服。故抽出来共用并补齐常见类型。
*/
export interface FactLabel {
badge: string;
badgeTone: string;
text: string;
}
const MODALITY_ZH: Record<string, string> = {
pa: '根尖片',
bw: '咬翼片',
pano: '曲面断层',
cbct: 'CBCT',
intraoral_photo: '口内照片',
};
const yuan = (cents: unknown): string => {
const n = Number(cents ?? 0);
return Number.isFinite(n) ? ${Math.round(n / 100).toLocaleString('zh-CN')}` : '';
};
export function factLabel(f: AdaptedFact): FactLabel {
const c = (f.content ?? {}) as Record<string, unknown>;
switch (f.type) {
case 'diagnosis_record': {
const code = String(c.code ?? '');
const name = String(c.name_zh ?? c.name ?? '');
const std = code ? diagnosisCodeNameZh(code) : '';
const text = std && name && std !== name ? `${std} · ${name}` : std || name || '诊断';
return { badge: '诊断', badgeTone: 'bg-rose-50 text-rose-700', text };
}
case 'treatment_record': {
const cat = String(c.category ?? '');
const sub = String(c.subtype ?? '');
const catZh = cat ? treatmentCategoryNameZh(cat) : '';
const text = sub || catZh || '治疗';
const isPlanned = f.kind === 'planned';
return {
badge: isPlanned ? '计划' : catZh || '治疗',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-teal-50 text-teal-700',
text,
};
}
case 'recommendation_record': {
const name = String(c.name ?? '');
const code = String(c.code ?? '');
return {
badge: '建议',
badgeTone: 'bg-amber-50 text-amber-700',
text: name || (code ? diagnosisCodeNameZh(code) : '') || '医生建议',
};
}
case 'image_record': {
const mod = String(c.modality ?? '');
return {
badge: '影像',
badgeTone: 'bg-slate-100 text-slate-500',
text: MODALITY_ZH[mod] ?? mod ?? '影像',
};
}
// ── 以下为画像证据链补齐(临床时间轴用不到,但画像标签大量引用)──
case 'payment_record': {
// 折扣锚点 / 权益身份的证据:金额 + 卡券或项目名才说明得了问题
const project = String(c.settlement_project ?? '').trim();
const card = String(c.card_name ?? c.card_type_name ?? c.insurance_name ?? '').trim();
const parts = [yuan(c.amount_cents), card, project].filter(Boolean);
return { badge: '结算', badgeTone: 'bg-indigo-50 text-indigo-700', text: parts.join(' · ') || '结算' };
}
case 'recharge_record':
return { badge: '充值', badgeTone: 'bg-indigo-50 text-indigo-700', text: yuan(c.amount_cents) || '充值' };
case 'refund_record':
return { badge: '退费', badgeTone: 'bg-rose-50 text-rose-700', text: yuan(c.amount_cents) || '退费' };
case 'appointment_record': {
// 特别关注(爽约/迟到)的证据:履约状态才是重点
const st = String(c.status ?? f.status ?? '');
const ST_ZH: Record<string, string> = {
no_show: '爽约',
cancelled: '已取消',
completed: '已到诊',
arrived: '已到诊',
booked: '已预约',
};
return { badge: '预约', badgeTone: 'bg-sky-50 text-sky-700', text: ST_ZH[st] ?? st ?? '预约' };
}
case 'encounter_record':
return { badge: '就诊', badgeTone: 'bg-teal-50 text-teal-700', text: f.title ?? '到诊' };
case 'visit_registration_record':
return { badge: '挂号', badgeTone: 'bg-teal-50 text-teal-700', text: f.title ?? '挂号' };
case 'emr_record':
// 治疗敏感 / 不可等候的证据来自病历自由文本 —— summary 是命中的那段
return { badge: '病历', badgeTone: 'bg-slate-100 text-slate-600', text: f.summary ?? f.title ?? '病历' };
case 'consultation_record':
return { badge: '咨询', badgeTone: 'bg-amber-50 text-amber-700', text: f.title ?? '咨询' };
default:
return { badge: '事实', badgeTone: 'bg-slate-100 text-slate-500', text: f.title ?? f.type };
}
}
...@@ -214,6 +214,7 @@ export const mockPersona = { ...@@ -214,6 +214,7 @@ export const mockPersona = {
id: 'prsn_v7', id: 'prsn_v7',
version: 7, version: 7,
computedAt: daysAgo(2), computedAt: daysAgo(2),
refreshedAt: daysAgo(2),
features: [ features: [
{ {
key: PersonaFeatureKey.VALUE, key: PersonaFeatureKey.VALUE,
......
'use client';
import * as React from 'react';
import {
PERSONA_FEATURE_GROUP_LABEL,
PERSONA_FEATURE_GROUP_ORDER,
personaFeatureGroup,
personaFeatureSortKey,
type PersonaFeatureGroup,
} from '@pac/types';
import { cn } from '@/lib/utils';
import { tone } from './shared';
import { cleanPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover';
import { factLabel } from './fact-label';
import type { PersonaFeature } from './mock-data';
import type { AdaptedFact } from './adapt-data';
/**
* 画像标签详情列表(抽屉体)。
*
* 相对旧版改了三件事:
* ① **分组 + 定序**。原来 16 张卡等权重平铺,顺序来自 `orderBy createdAt`;而同一版的 feature
* 行是一条 createMany 写进去的、createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key
* 字母序:「急迫等级 = 紧急」排在最后一个,「性别」「获客渠道」排在最前。现按用途分三组。
* ② **给出依据**。后端一直存着 evidence.factIds(rfm + lifecycle 两个特征在生产就占 1 GB),
* 但前端 adapt-data 把它写死成空数组 —— 客服只看得到结论、看不到出处。现在反查同一份 facts
* 渲染最近几条,并给出总条数。
* ③ **`?` 改成真按钮**。原来是 16×16 的 `<span>`,无 tabindex 无 role、纯 hover —— 键盘、读屏、
* 平板触屏都够不着。现在是 button(focus 可触发 hover 卡),热区放大到 24×24。
*/
const MAX_EVIDENCE_ROWS = 4;
export function PersonaDetailList({
features,
facts,
}: {
features: PersonaFeature[];
facts: AdaptedFact[];
}) {
const factById = React.useMemo(() => new Map(facts.map((f) => [f.id, f])), [facts]);
const grouped = React.useMemo(() => {
const sorted = [...features].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key);
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
});
const buckets = new Map<PersonaFeatureGroup | 'other', PersonaFeature[]>();
for (const f of sorted) {
const g = personaFeatureGroup(f.key) ?? 'other';
buckets.set(g, [...(buckets.get(g) ?? []), f]);
}
return buckets;
}, [features]);
const sections: Array<[string, PersonaFeature[]]> = [
...PERSONA_FEATURE_GROUP_ORDER.map(
(g) => [PERSONA_FEATURE_GROUP_LABEL[g], grouped.get(g) ?? []] as [string, PersonaFeature[]],
),
// 注册表里没登记的标签不吞掉,兜到最后一组(比"标签凭空消失"好排查)
['其他', grouped.get('other') ?? []],
];
return (
<div className="space-y-4">
{sections
.filter(([, items]) => items.length > 0)
.map(([label, items]) => (
<section key={label} className="space-y-2">
<h3 className="text-[10.5px] font-semibold tracking-wide text-slate-400">
{label}
<span className="ml-1.5 font-normal text-slate-300">{items.length}</span>
</h3>
{items.map((f) => (
<FeatureCard key={f.key} feature={f} factById={factById} />
))}
</section>
))}
</div>
);
}
function FeatureCard({
feature: f,
factById,
}: {
feature: PersonaFeature;
factById: Map<string, AdaptedFact>;
}) {
const [showAll, setShowAll] = React.useState(false);
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征回退到 cleanPersonaValue 的主描述文本。
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string') ? (labels as string[]) : null;
// 证据:只渲染能在本次响应的 facts 里找到的(跨版本的旧 fact 可能已被 supersede,查不到就不显)
const resolved = f.evidence
.map((id) => factById.get(id))
.filter((x): x is AdaptedFact => !!x)
.sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? ''));
const shown = showAll ? resolved : resolved.slice(0, MAX_EVIDENCE_ROWS);
return (
<div className="relative rounded-md border border-slate-100 p-3 pr-9">
{/* 右上角 ? —— 真 button:可 Tab 聚焦,focus 也能唤出说明卡(触屏/键盘可达) */}
<PersonaFeatureHover featureKey={f.key} value={f.value}>
<button
type="button"
className="absolute top-1.5 right-1.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500"
aria-label={`${f.label} 怎么算的`}
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</PersonaFeatureHover>
{/* 属性名 */}
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}>
<span className={cn('h-1.5 w-1.5 rounded-full', T.dot)} />
{f.label}
{tag && (
<span className="ml-1 rounded bg-slate-100 px-1 py-0 text-[10.5px] font-normal text-slate-600">
{tag}
</span>
)}
</div>
{/* 取值条目 — 多值展开为 chip,单值显示文本 */}
{valueLabels && valueLabels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{valueLabels.map((l, i) => (
<span
key={i}
className="inline-flex items-center rounded bg-slate-50 px-1.5 py-0.5 text-[12px] font-medium text-slate-800 ring-1 ring-slate-100"
>
{l}
</span>
))}
</div>
) : (
text && <div className="mt-1 text-[13px] font-medium text-slate-900">{text}</div>
)}
{/* 依据 —— 这个标签是被哪些事实推出来的 */}
{resolved.length > 0 && (
<div className="mt-2 border-t border-slate-100 pt-2">
<div className="mb-1 flex items-baseline gap-2">
<span className="text-[10.5px] text-slate-400">依据 · {resolved.length}</span>
{resolved.length > MAX_EVIDENCE_ROWS && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="text-[10.5px] text-teal-700 hover:underline"
>
{showAll ? '收起' : `展开全部`}
</button>
)}
</div>
<ul className="space-y-0.5">
{shown.map((fact) => {
const { badge, badgeTone, text: label } = factLabel(fact);
return (
<li key={fact.id} className="flex items-baseline gap-1.5 text-[11px] leading-relaxed">
<span className="w-[68px] flex-none tabular-nums text-slate-400">
{(fact.occurredAt ?? fact.plannedFor ?? '').slice(0, 10) || '—'}
</span>
<span className={cn('flex-none rounded px-1 text-[10px]', badgeTone)}>{badge}</span>
<span className="min-w-0 flex-1 truncate text-slate-700" title={label}>
{label}
</span>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { PersonaFeatureKey } from '@pac/types'; import { personaFeatureDisplay, personaFeatureMeta } from '@pac/types';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
/** /**
* PersonaFeatureHover — 患者画像 4 个 feature 的算法说明 hover card * PersonaFeatureHover — 画像标签的「怎么算的」说明卡
* *
* 数据来源:apps/pac-service/src/modules/persona/features/*.feature.ts 顶部 JSDoc。 * 内容来自 `@pac/types` 的 PERSONA_FEATURE_SPECS[key].display —— 与后端 extractor 的口径
* 算法 doc 改动时同步这里(后续可考虑后端返回 description,前端零硬编码)。 * (同一份标签卡里的 algorithm 字段)贴脸放。**前端不再自己维护一份算法字典**:
* 原来这里硬编的 ALGORITHMS 已经和后端漂了 —— 权益身份只列 2 类(实际 5 类)、还承诺
* "显示保司 + 最近日期"(数据里根本没有);转介绍达人把门槛写成"推外人≥3人"(真实口径是
* 推荐≥3人**且**带来成交)。客服是照着这段话跟患者讲的,说明写错比不写更糟。
* *
* 触发器(children)在 feature 卡片右上角放一个 ? 图标 * 触发器(children)由调用方给 —— 抽屉里是卡片右上角的 ? 按钮,标签云里是 chip 本身
*/ */
export function PersonaFeatureHover({ export function PersonaFeatureHover({
featureKey, featureKey,
...@@ -18,12 +21,14 @@ export function PersonaFeatureHover({ ...@@ -18,12 +21,14 @@ export function PersonaFeatureHover({
children, children,
}: { }: {
featureKey: string; featureKey: string;
/// 该患者的实际取值(原生 title 内容并入 hovercard 顶部) /// 该患者的实际取值(展示在说明卡顶部,先说"你是什么"再说"怎么算的")
value?: string; value?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const meta = ALGORITHMS[featureKey]; const display = personaFeatureDisplay(featureKey);
if (!meta && !value) return <>{children}</>; if (!display && !value) return <>{children}</>;
const title = personaFeatureMeta(featureKey).label;
return ( return (
<HoverCard openDelay={150} closeDelay={80}> <HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身的位置(避免多包一层 0 尺寸 span)*/} {/* 直接把 children 作为 trigger,radix 锚定 children 本身的位置(避免多包一层 0 尺寸 span)*/}
...@@ -31,37 +36,34 @@ export function PersonaFeatureHover({ ...@@ -31,37 +36,34 @@ export function PersonaFeatureHover({
{/* z-[70]:抽屉面板是 z-[60],默认 hovercard z-50 会被抽屉盖住(详情抽屉里 ? hover 不显的根因) */} {/* z-[70]:抽屉面板是 z-[60],默认 hovercard z-50 会被抽屉盖住(详情抽屉里 ? hover 不显的根因) */}
<HoverCardContent align="end" sideOffset={6} className="z-[70] w-80 p-3 text-[11.5px]"> <HoverCardContent align="end" sideOffset={6} className="z-[70] w-80 p-3 text-[11.5px]">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-baseline justify-between border-b border-slate-100 pb-1.5"> <div className="flex items-baseline justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">{meta?.title ?? featureKey}</span> <span className="text-[13px] font-semibold text-slate-900">{title}</span>
{meta?.subtitle && <span className="text-[10.5px] text-slate-500">{meta.subtitle}</span>} {display && (
<span className="text-right text-[10.5px] text-slate-500">{display.subtitle}</span>
)}
</div> </div>
{/* 本患者实际取值(原 title 内容)*/} {/* 本患者实际取值 */}
{value && ( {value && (
<div className="rounded bg-teal-50 px-2 py-1.5 text-[11px] text-teal-800 leading-relaxed"> <div className="rounded bg-teal-50 px-2 py-1.5 text-[11px] leading-relaxed text-teal-800">
{value} {value}
</div> </div>
)} )}
{meta?.formula && ( {display && (
<div className="rounded bg-slate-50 px-2 py-1.5 font-mono text-[10.5px] text-slate-700 leading-relaxed"> <ul className="space-y-1 leading-relaxed text-slate-600">
{meta.formula} {display.rules.map((r, i) => (
</div>
)}
{meta && (
<ul className="space-y-1 text-slate-600 leading-relaxed">
{meta.rules.map((r, i) => (
<li key={i} className="flex gap-2"> <li key={i} className="flex gap-2">
<span className="text-slate-400 flex-none">·</span> <span className="flex-none text-slate-400">·</span>
<span> <span>
{r.label && <strong className="text-slate-800 mr-1">{r.label}</strong>} {r.label && <strong className="mr-1 text-slate-800">{r.label}</strong>}
{r.body} {r.body}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{meta?.note && ( {display?.note && (
<p className="border-t border-slate-100 pt-1.5 text-[10.5px] text-slate-500 leading-relaxed"> <p className="border-t border-slate-100 pt-1.5 text-[10.5px] leading-relaxed text-slate-500">
{meta.note} {display.note}
</p> </p>
)} )}
</div> </div>
...@@ -69,151 +71,3 @@ export function PersonaFeatureHover({ ...@@ -69,151 +71,3 @@ export function PersonaFeatureHover({
</HoverCard> </HoverCard>
); );
} }
interface AlgoMeta {
title: string;
subtitle: string;
formula?: string;
rules: { label?: string; body: string }[];
note?: string;
}
const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.RFM]: {
title: '价值分群 (RFM)',
subtitle: '按最近就诊 · 频次 · 累计消费',
rules: [
{ label: '重要价值', body: '近期来过、高频、消费高' },
{ label: '重要保持/发展/挽留', body: '消费高,但最近度或频次偏弱' },
{ label: '一般价值/保持/发展', body: '消费偏低的对应象限' },
{ label: '低活跃', body: '很久没来,或各项都偏低' },
],
},
[PersonaFeatureKey.AGE_BRACKET]: {
title: '年龄段',
subtitle: '按生日当下计算',
rules: [
{ body: '婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17' },
{ body: '青年 18-25 / 中青年 26-30 / 中年 31-45 / 中老年 46-54 / 老年 ≥55' },
],
},
[PersonaFeatureKey.GENDER]: {
title: '性别',
subtitle: '影响话术与项目推荐',
rules: [{ label: '男性 / 女性 / 未知', body: '按建档性别' }],
},
[PersonaFeatureKey.ACQUISITION_CHANNEL]: {
title: '获客渠道',
subtitle: '客户首次到诊的来源',
rules: [{ body: '按客户首次到诊的来源判定,确定后不再变' }],
},
[PersonaFeatureKey.FAMILY_STRUCTURE]: {
title: '家庭构成',
subtitle: '按直系亲属关系推断',
rules: [
{ label: '多代之家', body: '有长辈(父母 / 祖辈)' },
{ label: '多口之家', body: '有子女' },
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '仅有同辈 / 朋友等非直系' },
],
},
[PersonaFeatureKey.REFERRAL_CHAMPION]: {
title: '转介绍达人',
subtitle: '推荐过他人且带来成交',
rules: [
{ label: '家庭型', body: '带家人(直系 ≥3 人)' },
{ label: '社交型', body: '推外人(≥3 人)' },
],
},
[PersonaFeatureKey.LIFECYCLE_STAGE]: {
title: '生命周期',
subtitle: '按就诊时间 + 消费',
rules: [
{ label: '潜客', body: '尚未到诊,只约过 / 咨询过' },
{ label: '新客', body: '首诊半年内、就诊 ≤3 次' },
{ label: '成长客', body: '近一年消费明显上升' },
{ label: '成熟客', body: '到诊稳定、消费平稳' },
{ label: '待激活 / 沉睡 / 流失', body: '末次就诊越久,越靠后' },
],
},
[PersonaFeatureKey.TREATMENT_HISTORY]: {
title: '治疗史',
subtitle: '做过的核心治疗类型',
rules: [
{ label: '种植史', body: '种过牙' },
{ label: '正畸史', body: '做过矫正' },
{ label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
],
note: '基础治疗(拔牙 / 补牙 / 根管)不计入',
},
[PersonaFeatureKey.TIME_PREFERENCE]: {
title: '时间偏好',
subtitle: '习惯的就诊时段',
rules: [
{ label: '工作日 / 周末', body: '按近两年预约时间占比' },
{ label: '上午 / 下午 / 晚间', body: '同上,占比过半即标' },
],
},
[PersonaFeatureKey.DISCOUNT_ANCHOR]: {
title: '折扣锚点',
subtitle: '历史最深的一次折扣',
rules: [
{ body: '取真实治疗(原价 ≥¥500)上谈到的最低折扣 + 日期 / 项目' },
{ body: '免费洁牙 / 检查等促销不计入' },
],
note: '作谈优惠的价格底线参考',
},
[PersonaFeatureKey.SPECIAL_ATTENTION]: {
title: '特别关注',
subtitle: '排班 / 触达需注意',
rules: [
{ label: '屡次爽约', body: '近一年履约率偏低' },
{ label: '经常迟到', body: '多次迟到超 15 分钟' },
{ label: '免打扰', body: '已标记不打扰' },
{ label: '不可等候', body: '病历 / 备注提到赶时间、不能等' },
],
},
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: {
title: '治疗敏感',
subtitle: '沟通需安抚的点',
rules: [
{ label: '看牙恐惧', body: '牙科恐惧 / 害怕看牙' },
{ label: '晕针 / 晕血', body: '晕针·怕打针 / 晕血·见血不适' },
{ label: '密闭恐惧', body: '幽闭 / 长时间张口不适' },
],
},
[PersonaFeatureKey.CONTRAINDICATION]: {
title: '禁忌标签',
subtitle: '治疗安全预警',
rules: [{ label: '种植禁忌', body: '未满 18 岁(骨骼未发育完全)' }],
note: '满 19 岁后自动解除',
},
[PersonaFeatureKey.URGENCY_LEVEL]: {
title: '急迫等级',
subtitle: '按末次就诊距今',
rules: [
{ label: '紧急', body: '有待治项且 90 天以上没来' },
{ label: '高', body: '30-90 天没来' },
{ label: '中', body: '近期来过或新发现' },
],
},
[PersonaFeatureKey.POTENTIAL_TREATMENT]: {
title: '潜在治疗',
subtitle: '诊断 / 建议了但还没做',
rules: [
{ label: '种植 / 修复 / 拔牙', body: '缺牙、需冠桥、残根需拔' },
{ label: '正畸 / 早矫', body: '按年龄分成人正畸 / 儿童早矫' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
],
},
[PersonaFeatureKey.ENTITLEMENT_STATUS]: {
title: '权益身份',
subtitle: '保险结算历史',
rules: [
{ label: '商保客户', body: '用过商业保险结算(显示保司 + 最近日期)' },
{ label: '医保结算', body: '用过社保医保结算' },
],
note: '只表示曾用保险结算,不代表当前在保',
},
};
...@@ -44,6 +44,7 @@ import { ...@@ -44,6 +44,7 @@ import {
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
RECALL_FEEDBACK_OPTIONS, RECALL_FEEDBACK_OPTIONS,
ABANDON_REASON_META, ABANDON_REASON_META,
personaFeatureSortKey,
type AbandonReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
...@@ -2155,9 +2156,16 @@ function PersonaTagCloud({ features }: { features: typeof mockPersona.features } ...@@ -2155,9 +2156,16 @@ function PersonaTagCloud({ features }: { features: typeof mockPersona.features }
if (!features.length) { if (!features.length) {
return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>; return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>;
} }
// 与详情抽屉同一套序(跟进要点 → 价值与阶段 → 基础属性)。
// 后端返回顺序不保证(同版 feature 行 createdAt 相同),不排就是随机的。
const ordered = [...features].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key);
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
});
return ( return (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{features.map((f) => { {ordered.map((f) => {
const T = tone(f.tone); const T = tone(f.tone);
const { text: short, more } = compactPersonaValue(f.value, f.data); const { text: short, more } = compactPersonaValue(f.value, f.data);
return ( return (
......
...@@ -130,13 +130,19 @@ export type PlanDetailData = { ...@@ -130,13 +130,19 @@ export type PlanDetailData = {
persona: { persona: {
id: string; id: string;
version: number; version: number;
/// 本版本创建时刻(升版本才动)
computedAt: string; computedAt: string;
/// 内容真实新鲜度:只有天数漂移时后端就地刷新、不升版本 → computedAt 会停在旧值。
/// UI 的「更新于」用这个。老响应可能没有 → 调用方回退 computedAt。
refreshedAt?: string;
features: Array<{ features: Array<{
id: string; id: string;
key: string; key: string;
description: string; description: string;
score: number | null; score: number | null;
data: unknown; data: unknown;
/// 证据 fact id(反查同一响应的 facts 渲染「依据」);老响应没有 → 空
evidenceFactIds?: string[];
}>; }>;
} | null; } | null;
chains: Array<{ chains: Array<{
......
'use client'; 'use client';
import { import { lookupDxTreatment, NO_RESTORATION_GAP_EXAM_PATTERNS } from '@pac/types';
diagnosisCodeNameZh,
treatmentCategoryNameZh,
lookupDxTreatment,
NO_RESTORATION_GAP_EXAM_PATTERNS,
} 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 { factLabel } from './fact-label';
/** /**
* ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴 * ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴
...@@ -204,54 +200,6 @@ function ToothFactRow({ fact }: { fact: AdaptedFact }) { ...@@ -204,54 +200,6 @@ function ToothFactRow({ fact }: { fact: AdaptedFact }) {
); );
} }
function factLabel(f: AdaptedFact): { badge: string; badgeTone: string; text: string } {
const c = (f.content ?? {}) as Record<string, unknown>;
switch (f.type) {
case 'diagnosis_record': {
const code = String(c.code ?? '');
const name = String(c.name_zh ?? c.name ?? '');
const std = code ? diagnosisCodeNameZh(code) : '';
const text = std && name && std !== name ? `${std} · ${name}` : std || name || '诊断';
return { badge: '诊断', badgeTone: 'bg-rose-50 text-rose-700', text };
}
case 'treatment_record': {
const cat = String(c.category ?? '');
const sub = String(c.subtype ?? '');
const catZh = cat ? treatmentCategoryNameZh(cat) : '';
const text = sub || catZh || '治疗';
const isPlanned = f.kind === 'planned';
return {
badge: isPlanned ? '计划' : catZh || '治疗',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-teal-50 text-teal-700',
text: catZh && sub ? `${text}` : text,
};
}
case 'recommendation_record': {
const name = String(c.name ?? '');
const code = String(c.code ?? '');
return {
badge: '建议',
badgeTone: 'bg-amber-50 text-amber-700',
text: name || (code ? diagnosisCodeNameZh(code) : '') || '医生建议',
};
}
case 'image_record': {
const mod = String(c.modality ?? '');
const modZh = MODALITY_ZH[mod] ?? mod ?? '影像';
return { badge: '影像', badgeTone: 'bg-slate-100 text-slate-500', text: modZh };
}
default:
return { badge: '事实', badgeTone: 'bg-slate-100 text-slate-500', text: f.title ?? f.type };
}
}
const MODALITY_ZH: Record<string, string> = {
pa: '根尖片',
bw: '咬翼片',
pano: '曲面断层',
cbct: 'CBCT',
intraoral_photo: '口内照片',
};
/// 牙位归一(剥面/方位后缀,保留牙位 base);跟 facts-timeline 同义 /// 牙位归一(剥面/方位后缀,保留牙位 base);跟 facts-timeline 同义
function parseJsonArrayLoose(raw: unknown): Array<{ toothPosition?: string; message?: string }> { function parseJsonArrayLoose(raw: unknown): Array<{ toothPosition?: string; message?: string }> {
......
...@@ -12,6 +12,63 @@ ...@@ -12,6 +12,63 @@
export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm'; export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm';
export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed'; export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed';
/**
* 详情页分组 —— 按「客服打这通电话时的用途」分,不按数据血缘分。
*
* 原来 16 个标签在抽屉里等权重平铺,且顺序取自 `orderBy createdAt`,而同一版的 feature 行是
* 一条 createMany 写进去的、createdAt 完全相同 —— 排序实际未定义,生产上呈现为英文 key 字母序:
* 「急迫等级=紧急」排在最后一个,「性别」「获客渠道」排在最前。
*/
export type PersonaFeatureGroup = 'action' | 'value' | 'basic';
export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> = {
action: '跟进要点',
value: '价值与阶段',
basic: '基础属性',
};
/** 分组自身的展示顺序 */
export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic'];
/**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
*
* 为什么放在标签卡里而不是前端:原先前端 persona-feature-hover.tsx 自己硬编了一份
* `ALGORITHMS` 字典,和后端口径各写各的,已经漂了 ——
* · 权益身份 hover 只列「商保客户 / 医保结算」2 类,extractor 实际产 5 类;
* 还写了「显示保司 + 最近日期」,而 description/data 里压根没有保司名和日期;
* · 转介绍达人 hover 写「社交型 = 推外人 ≥3 人」,真实口径是「推荐 ≥3 人**且**带来成交,
* 直系亲属关系 <3 归社交型」。
* 说明写错比不写更糟(客服照着 hover 跟患者讲)。收到这里 = 跟 algorithm 口径贴脸放,
* 改一处就会看到另一处;CI 侧由 tests/persona-spec-drift.spec.ts 兜底。
*
* 与 `algorithm` 的分工:algorithm 是口径原文(算法/数据同学核对用,含阈值与边界),
* display 是同一件事的客服版(不出现分位、天数阈值以外的技术词)。
*/
export interface PersonaFeatureDisplay {
/** 一句话副标题:这个标签在看什么 */
subtitle: string;
/** 取值条目:label = 标签值,body = 什么情况下会是它 */
rules: Array<{ label?: string; body: string }>;
/** 使用提醒 —— 只写会影响客服判断的(口径边界、已知局限),不写实现细节 */
note?: string;
/** 详情页分组 */
group: PersonaFeatureGroup;
/** 组内排序(小的在前;留 10 的间隔便于插新标签) */
order: number;
}
/** 展示排序键:先按组、再按组内序;未登记的标签排最后(仍可见,不吞) */
export function personaFeatureSortKey(key: string): [number, number] {
const d = PERSONA_FEATURE_SPECS[key]?.display;
if (!d) return [PERSONA_FEATURE_GROUP_ORDER.length, 0];
return [PERSONA_FEATURE_GROUP_ORDER.indexOf(d.group), d.order];
}
/** 该标签属于哪个组(未登记 → null,调用方自行归入"其他") */
export const personaFeatureGroup = (key: string): PersonaFeatureGroup | null =>
PERSONA_FEATURE_SPECS[key]?.display?.group ?? null;
export interface PersonaFeatureSpec { export interface PersonaFeatureSpec {
key: string; // PersonaFeatureKey key: string; // PersonaFeatureKey
nameZh: string; // 标签名 nameZh: string; // 标签名
...@@ -22,6 +79,7 @@ export interface PersonaFeatureSpec { ...@@ -22,6 +79,7 @@ export interface PersonaFeatureSpec {
dataFields: string[]; // 数据字段 dataFields: string[]; // 数据字段
meaning: string; // 标签释义 meaning: string; // 标签释义
algorithm: string; // 计算算法(口径,人读;真理实现在 extractor) algorithm: string; // 计算算法(口径,人读;真理实现在 extractor)
display: PersonaFeatureDisplay; // 详情页 ? 悬浮卡内容(客服版)
owner: string; owner: string;
version: number; version: number;
} }
...@@ -56,6 +114,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -56,6 +114,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
' R≥4 F≥3 M≥4→重要价值 / R=3 F≥3 M≥4→重要保持 / R≥4 F=2 M≥4→重要发展 / R≤2 F≥3 M≥4→重要挽留', ' R≥4 F≥3 M≥4→重要价值 / R=3 F≥3 M≥4→重要保持 / R≥4 F=2 M≥4→重要发展 / R≤2 F≥3 M≥4→重要挽留',
' R≥4 F≥3 M<4→一般价值 / R=3 F≥3 M<4→一般保持 / R≥4 F=2 M<4→一般发展 / 其余(含 R≤2 任意)→低活跃', ' R≥4 F≥3 M<4→一般价值 / R=3 F≥3 M<4→一般保持 / R≥4 F=2 M<4→一般发展 / 其余(含 R≤2 任意)→低活跃',
].join('\n'), ].join('\n'),
display: {
subtitle: '按最近就诊 · 就诊频次 · 累计消费三个维度分层',
rules: [
{ label: '重要价值', body: '近期来过、来得勤、花得多' },
{ label: '重要保持 / 发展 / 挽留', body: '消费高,但最近度或频次偏弱' },
{ label: '一般价值 / 保持 / 发展', body: '同样的象限,消费偏低' },
{ label: '低活跃', body: '很久没来,或三项都偏低' },
],
note: '消费额取本院实付合计(已扣退费);高低是跟**同租户其他患者**比出来的分位,不是绝对金额档',
group: 'value',
order: 10,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -66,15 +136,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -66,15 +136,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
nameZh: '年龄段', nameZh: '年龄段',
tier: 'rule', tier: 'rule',
timeSemantics: 'snapshot', // 当下年龄(随时间增长,历史读版本流) timeSemantics: 'snapshot', // 当下年龄(随时间增长,历史读版本流)
// 区间与下方 algorithm / extractor BRACKETS 对齐(下界含、上界含)。
// 原写 婴幼儿(0-3) / 中老年(46-55) 与 algorithm 的 0-2 / 46-54 自相矛盾,已订正。
labelValues: [ labelValues: [
'婴幼儿(0-3)', '婴幼儿(0-2)',
'学龄前(3-6)', '学龄前(3-6)',
'替牙期(7-11)', '替牙期(7-11)',
'青少年(12-17)', '青少年(12-17)',
'青年(18-25)', '青年(18-25)',
'中青年(26-30)', '中青年(26-30)',
'中年(31-45)', '中年(31-45)',
'中老年(46-55)', '中老年(46-54)',
'老年(55+)', '老年(55+)',
], ],
dataSource: '现:PAC patient.birthDate 自算;未来:宿主 CDP「客户综合分析报表」client_age 直接取', dataSource: '现:PAC patient.birthDate 自算;未来:宿主 CDP「客户综合分析报表」client_age 直接取',
...@@ -85,6 +157,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -85,6 +157,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'0-2→婴幼儿 / 3-6→学龄前 / 7-11→替牙期 / 12-17→青少年 / 18-25→青年 /', '0-2→婴幼儿 / 3-6→学龄前 / 7-11→替牙期 / 12-17→青少年 / 18-25→青年 /',
'26-30→中青年 / 31-45→中年 / 46-54→中老年 / ≥55→老年', '26-30→中青年 / 31-45→中年 / 46-54→中老年 / ≥55→老年',
].join('\n'), ].join('\n'),
display: {
subtitle: '按建档生日算的当下周岁',
rules: [
{ body: '婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17' },
{ body: '青年 18-25 / 中青年 26-30 / 中年 31-45 / 中老年 46-54 / 老年 ≥55' },
],
note: '没填生日的不打此标签',
group: 'basic',
order: 10,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -100,6 +182,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -100,6 +182,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
dataFields: ['client_gender'], dataFields: ['client_gender'],
meaning: '客户生理性别,影响沟通话术与项目推荐', meaning: '客户生理性别,影响沟通话术与项目推荐',
algorithm: 'client_gender 直接映射:"M"/"男"→男性;"F"/"女"→女性;其他→未知', algorithm: 'client_gender 直接映射:"M"/"男"→男性;"F"/"女"→女性;其他→未知',
display: {
subtitle: '影响话术称呼与项目推荐',
rules: [
{ label: '男性 / 女性', body: '按建档性别' },
{ label: '未知', body: '建档没填或填了无法识别的值' },
],
group: 'basic',
order: 20,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -115,6 +206,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -115,6 +206,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
dataFields: ['primary_category', 'sub_category'], dataFields: ['primary_category', 'sub_category'],
meaning: '客户首次到诊的获客来源,用于渠道价值分析。初诊来源一经判定不做二次改归类', meaning: '客户首次到诊的获客来源,用于渠道价值分析。初诊来源一经判定不做二次改归类',
algorithm: 'host primary_category 经 assembler enum_mapping 归一到 PAC 立柱标准(走入→walk_in 等);二级 sub_category 原值透传。冲突解决(数仓侧):推荐人 > 活动码/渠道码 > 客户类型', algorithm: 'host primary_category 经 assembler enum_mapping 归一到 PAC 立柱标准(走入→walk_in 等);二级 sub_category 原值透传。冲突解决(数仓侧):推荐人 > 活动码/渠道码 > 客户类型',
display: {
subtitle: '客户第一次到诊是怎么来的',
rules: [
{ body: '走入 / 口碑客户 / 集团销售渠道 / 集团营销渠道 / 地区营销渠道' },
{ body: '电商平台 / 自媒体网络 / 内部员工 / 其他' },
],
note: '初诊来源一经判定不再改归类 —— 后来又被谁推荐过不会覆盖它',
group: 'basic',
order: 40,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -134,6 +235,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -134,6 +235,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'有长辈→多代之家;否则有子女→多口之家;否则有配偶→两口之家;否则有非直系边→单身家庭;无边→不打标签。', '有长辈→多代之家;否则有子女→多口之家;否则有配偶→两口之家;否则有非直系边→单身家庭;无边→不打标签。',
'⚠️ 依赖关系边覆盖,样本稀疏 + 非全量时覆盖偏低。', '⚠️ 依赖关系边覆盖,样本稀疏 + 非全量时覆盖偏低。',
].join('\n'), ].join('\n'),
display: {
subtitle: '按已建档的直系亲属关系反推',
rules: [
{ label: '多代之家', body: '有长辈(父母 / 祖辈)' },
{ label: '多口之家', body: '有子女' },
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '只有同辈 / 朋友这类非直系关系' },
],
note: '只认已在院建档并关联上的亲属 —— 家人没在本院看过牙就推不出来,覆盖率偏低属正常',
group: 'basic',
order: 30,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -153,6 +266,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -153,6 +266,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'家庭型:直系家庭关系 ≥3(带家人);否则 社交型(推外人)。不满足→不打标签。', '家庭型:直系家庭关系 ≥3(带家人);否则 社交型(推外人)。不满足→不打标签。',
'⚠️ v1 用 DW 预聚合(逐个被推荐人"均有效转化"需其结算,跨患者+需被推荐人已摄入,留 v2)。', '⚠️ v1 用 DW 预聚合(逐个被推荐人"均有效转化"需其结算,跨患者+需被推荐人已摄入,留 v2)。',
].join('\n'), ].join('\n'),
display: {
subtitle: '推荐过他人**且**对方真的成交了',
rules: [
{ label: '门槛', body: '推荐 ≥3 人,且这些人带来过实际成交金额' },
{ label: '家庭型', body: '满足门槛,且院内直系亲属关系 ≥3(带的是家里人)' },
{ label: '社交型', body: '满足门槛,但直系亲属关系不足 3(带的是家人以外的人)' },
],
note: '推荐人数与成交额取自数仓预聚合,不逐个核对被推荐人;成交额可能很小,谈奖励前先看具体数字',
group: 'value',
order: 50,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -172,6 +296,19 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -172,6 +296,19 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'高端保险直付 Bupa/中间带/保险直付/商保;银行私行 招行私行/私人银行/白金卡/贵宾卡;', '高端保险直付 Bupa/中间带/保险直付/商保;银行私行 招行私行/私人银行/白金卡/贵宾卡;',
'储值会员 储值/预存(或有充值);儿牙会员 儿牙会员/乐牙卡/涂氟年卡;医保 医保。未命中→不打标签。', '储值会员 储值/预存(或有充值);儿牙会员 儿牙会员/乐牙卡/涂氟年卡;医保 医保。未命中→不打标签。',
].join('\n'), ].join('\n'),
display: {
subtitle: '历史结算里用过哪些卡券 / 保险',
rules: [
{ label: '高端保险直付', body: '用过 Bupa、中间带、万欣和、招商信诺等商业保险直付' },
{ label: '银行私行权益', body: '用过招行私行、私人银行、白金卡、贵宾卡等银行权益' },
{ label: '储值会员', body: '有储值 / 预存记录' },
{ label: '儿牙会员', body: '买过儿牙会员、乐牙卡、涂氟年卡' },
{ label: '医保客户', body: '用过医保结算' },
],
note: '可以同时命中多个。只说明**曾经**这样结算过,不代表当前仍在保 / 卡内还有余额',
group: 'value',
order: 40,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 2, version: 2,
}, },
...@@ -192,6 +329,20 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -192,6 +329,20 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'末诊180-540&就诊≥2→待激活;末诊>730→流失客;末诊540-730&就诊≥2→沉睡客;否则→新客兜底。', '末诊180-540&就诊≥2→待激活;末诊>730→流失客;末诊540-730&就诊≥2→沉睡客;否则→新客兜底。',
'⚠️ 修正 spec 顺序 bug:原"沉睡>540"在"流失>730"前 → 流失永不触发;此处流失提前。', '⚠️ 修正 spec 顺序 bug:原"沉睡>540"在"流失>730"前 → 流失永不触发;此处流失提前。',
].join('\n'), ].join('\n'),
display: {
subtitle: '客户走到了哪个阶段(看就诊时间 + 消费走势)',
rules: [
{ label: '潜客', body: '还没到过诊,只约过或咨询过' },
{ label: '新客', body: '首诊半年内,且就诊不超过 3 次' },
{ label: '成长客', body: '还在来,且近一年消费明显高于以往年均' },
{ label: '成熟客', body: '还在来,消费平稳' },
{ label: '待激活', body: '半年到一年半没来' },
{ label: '沉睡客', body: '一年半到两年没来' },
{ label: '流失客', body: '两年以上没来' },
],
group: 'value',
order: 20,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -210,6 +361,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -210,6 +361,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'implant→种植史;orthodontic→正畸史;prosthodontic/cosmetic→修复史(冠桥/贴面/嵌体);periodontic→牙周治疗史。', 'implant→种植史;orthodontic→正畸史;prosthodontic/cosmetic→修复史(冠桥/贴面/嵌体);periodontic→牙周治疗史。',
'不标记 surgical(拔牙)/restorative(充填)/endodontic(根管)等基础治疗。仅 actual;全不命中→不打标签。', '不标记 surgical(拔牙)/restorative(充填)/endodontic(根管)等基础治疗。仅 actual;全不命中→不打标签。',
].join('\n'), ].join('\n'),
display: {
subtitle: '做过哪些核心治疗',
rules: [
{ label: '种植史', body: '种过牙' },
{ label: '正畸史', body: '做过矫正' },
{ label: '修复史', body: '做过冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '做过洁牙 / 刮治 / 牙周序列治疗' },
],
note: '只统计已实际执行的治疗;拔牙 / 补牙 / 根管等基础项目不计入',
group: 'value',
order: 30,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -229,6 +392,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -229,6 +392,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'上午8-12≥50%→上午;下午12-18≥50%→下午;晚间18-21≥50%→晚间。无命中/记录<2→不打标签。', '上午8-12≥50%→上午;下午12-18≥50%→下午;晚间18-21≥50%→晚间。无命中/记录<2→不打标签。',
'⚠️ TZ 硬编北京(+8),多宿主应读 host 时区(follow-up)。', '⚠️ TZ 硬编北京(+8),多宿主应读 host 时区(follow-up)。',
].join('\n'), ].join('\n'),
display: {
subtitle: '习惯在什么时候来',
rules: [
{ label: '工作日 / 周末', body: '近两年预约里该类占比过半' },
{ label: '上午 / 下午 / 晚间', body: '同上,按预约时段统计' },
],
note: '看的是**预约时刻**而非实际到诊时刻;近两年预约少于 2 次不打标签',
group: 'action',
order: 60,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -247,6 +420,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -247,6 +420,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'遍历有折扣的结算:折扣率 = 1 − discount_cents/amount_cents(DW 无原价字段;discount_*_rate 实为折扣金额)。', '遍历有折扣的结算:折扣率 = 1 − discount_cents/amount_cents(DW 无原价字段;discount_*_rate 实为折扣金额)。',
'取最小折扣率(力度最大)+ 保留日期/项目。无折扣→不打标签(业务:无锚点换推增值权益,不直接降价)。', '取最小折扣率(力度最大)+ 保留日期/项目。无折扣→不打标签(业务:无锚点换推增值权益,不直接降价)。',
].join('\n'), ].join('\n'),
display: {
subtitle: '历史上给过的最深一次折扣',
rules: [
{ body: '在原价 ≥¥500 的真实治疗里,取谈成过的最低折扣,并记下日期与项目' },
],
note: '免费洁牙 / 检查这类促销和全免单不计入,否则锚点会失去参考意义。锚点可能很久远,报价前先看日期',
group: 'action',
order: 70,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -266,6 +448,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -266,6 +448,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'经常迟到 到店>预约+15min 占比≥50%且≥3次;免打扰 doNotContact;', '经常迟到 到店>预约+15min 占比≥50%且≥3次;免打扰 doNotContact;',
'不可等候 notes/tags/病历含 不可等候/时间敏感/赶时间/不能等。全不命中→不打标签。', '不可等候 notes/tags/病历含 不可等候/时间敏感/赶时间/不能等。全不命中→不打标签。',
].join('\n'), ].join('\n'),
display: {
subtitle: '排班与触达要特别注意的事',
rules: [
{ label: '屡次爽约', body: '近一年至少 3 次已定预约,履约率不足一半' },
{ label: '经常迟到', body: '近一年至少 3 次到店晚于预约 15 分钟以上' },
{ label: '免打扰', body: '已被标记不接受主动联系' },
{ label: '不可等候', body: '病历或备注里提过赶时间 / 不能等' },
],
note: '命中「免打扰」时不要外呼',
group: 'action',
order: 30,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -285,6 +479,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -285,6 +479,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'晕针 晕针/害怕打针;晕血 晕血/见血不适;密闭恐惧 幽闭/密闭/长时间张口不适。全不命中→不打标签。', '晕针 晕针/害怕打针;晕血 晕血/见血不适;密闭恐惧 幽闭/密闭/长时间张口不适。全不命中→不打标签。',
'⚠️ 关键词按真实数据精炼排假阳:裸"紧张"=颏肌紧张(正畸)、裸"见血"=可见血凝块(临床)、裸"张口受限"=物理开口受限,均剔除。', '⚠️ 关键词按真实数据精炼排假阳:裸"紧张"=颏肌紧张(正畸)、裸"见血"=可见血凝块(临床)、裸"张口受限"=物理开口受限,均剔除。',
].join('\n'), ].join('\n'),
display: {
subtitle: '沟通时需要提前安抚的点',
rules: [
{ label: '看牙恐惧', body: '病历 / 备注提过牙科恐惧、害怕看牙' },
{ label: '晕针 / 晕血', body: '提过晕针、怕打针 / 晕血、见血不适' },
{ label: '密闭恐惧', body: '提过幽闭、长时间张口不适' },
],
note: '从病历自由文本里识别,只在明确写到时才打标 —— 没打标不等于患者不敏感',
group: 'action',
order: 50,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -304,6 +509,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -304,6 +509,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'K 码→8 标签:种植←K08(>18)/补牙←K02/根管←K04/牙周←K05,K06/正畸←K07(>12≤40)/早矫←K07(3-12)/', 'K 码→8 标签:种植←K08(>18)/补牙←K02/根管←K04/牙周←K05,K06/正畸←K07(>12≤40)/早矫←K07(3-12)/',
'修复←K03(默认)/拔牙←K01+K03(残根残冠)。置信度=诊断1.0/建议0.8。⚠️非已丢单(sales_chance)PAC未摄入,省略。', '修复←K03(默认)/拔牙←K01+K03(残根残冠)。置信度=诊断1.0/建议0.8。⚠️非已丢单(sales_chance)PAC未摄入,省略。',
].join('\n'), ].join('\n'),
display: {
subtitle: '诊断或建议过、但还没做的项目',
rules: [
{ label: '种植 / 修复 / 拔牙', body: '缺牙待种、需要冠桥、残根残冠需拔' },
{ label: '正畸 / 早矫', body: '按年龄分成人正畸(13-40)与儿童早矫(3-12)' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
],
note: '口径与召回完全一致(就是召回在挖的机会),只是不受冷静期等时间限制。已在别处做掉的看不到',
group: 'action',
order: 10,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -323,6 +539,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -323,6 +539,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'末诊>90天→紧急 / 30-90→高 / <30 或新发现→中 / 美学预防→低(8 标签不含,暂不触发)。', '末诊>90天→紧急 / 30-90→高 / <30 或新发现→中 / 美学预防→低(8 标签不含,暂不触发)。',
'无潜在治疗→不打标签。Step2 取最大(多路径 MAX,现仅 1 路径)。', '无潜在治疗→不打标签。Step2 取最大(多路径 MAX,现仅 1 路径)。',
].join('\n'), ].join('\n'),
display: {
subtitle: '有待处理项时,多久没来了',
rules: [
{ label: '紧急', body: '有潜在待治项,且超过 90 天没来' },
{ label: '高', body: '有潜在待治项,30 到 90 天没来' },
{ label: '中', body: '有潜在待治项,30 天内来过或刚发现' },
],
note: '没有潜在待治项就不打此标签。目前只覆盖「诊断了没做」这条路径,复查提醒类尚未纳入',
group: 'action',
order: 20,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
...@@ -342,7 +569,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -342,7 +569,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'⚠️ 其余禁忌(糖尿病控制不佳/过敏/抗凝/妊娠/放疗…)规则做不了:① 既往史否定泛滥(含"过敏"97%是"否认过敏",naive 匹配=医疗事故);', '⚠️ 其余禁忌(糖尿病控制不佳/过敏/抗凝/妊娠/放疗…)规则做不了:① 既往史否定泛滥(含"过敏"97%是"否认过敏",naive 匹配=医疗事故);',
'② 量化条件无数据(HbA1c 全量仅10条/血压自由文本);③ 需控制状态/急性期判断+时效窗口 → 留 Layer C(LLM 抽取)。', '② 量化条件无数据(HbA1c 全量仅10条/血压自由文本);③ 需控制状态/急性期判断+时效窗口 → 留 Layer C(LLM 抽取)。',
].join('\n'), ].join('\n'),
display: {
subtitle: '治疗安全预警',
rules: [
{ label: '种植禁忌', body: '未满 19 岁,颌骨尚未发育完全' },
],
note: '目前只做了年龄这一项,满 19 岁自动解除。全身疾病 / 用药 / 过敏类禁忌尚未覆盖,**不能**据此认为患者无禁忌',
group: 'action',
order: 40,
},
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
}; };
/**
* 取某标签的客服版说明(详情页 `?` 悬浮卡)。未登记的 key 返回 null —— 前端据此不渲染悬浮卡,
* 而不是编一段说明出来。新特征上线忘了写 display 会在页面上"少个 ?",比显示错的说明安全。
*/
export const personaFeatureDisplay = (key: string): PersonaFeatureDisplay | null =>
PERSONA_FEATURE_SPECS[key]?.display ?? 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