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>
);
}
...@@ -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 }> {
......
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