Commit a21d74c1 by luoqi

feat(web): 关键画像标签直接展示在患者姓名下

业务(2026-07-29):关键标签在完整客户名字下直接显示,可快速查看,要细看再进详情。

标签云组件(PersonaTagCloud)一直在,只是自从画像卡改成 LLM 一句话摘要后
被降级成"摘要生成失败"的兜底 —— 客服想看标签得点开抽屉。现在把**关键那几个**
提到身份卡姓名下方,复用同一套 chip(hover 仍能看「怎么算的」),不另起渲染。

关键 = PERSONA_KEY_FEATURE_KEYS(types 里定,附选取理由):
  禁忌标签 / 特别关注 / 急迫等级 / 潜在治疗 / 价值分群 / 生命周期
按「能不能治 → 能不能打 → 多急 → 聊什么 → 给多大力度」排。
时间偏好、折扣锚点、治疗敏感是通话**中**才用的,基础属性姓名行已有,都不进首屏。
16 个标签一个不少,仍在画像详情抽屉全量可查 —— 取舍的是首屏不是信息。

排序沿用 personaFeatureSortKey(与抽屉同一套序),白名单只管"哪些进"。
加防漂移测试:白名单里的 key 必须还活着 —— 改 key 名不会报错,
只会静悄悄少一个 chip,而少的若是禁忌/免打扰就是事故。

657 tests / 42 suites 通过;本地实测张红渲染出 潜在治疗·急迫等级·价值分群·生命周期 四颗。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 951fb26d
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_KEY_FEATURE_KEYS,
personaTagDimId, PERSONA_FEATURE_META } from '@pac/types'; personaTagDimId, PERSONA_FEATURE_META } from '@pac/types';
import { import {
FeatureRegistry, FeatureRegistry,
...@@ -39,6 +39,14 @@ describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => { ...@@ -39,6 +39,14 @@ describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => {
const stale = Object.keys(PERSONA_FEATURE_SPECS).filter((k) => !live.has(k)); const stale = Object.keys(PERSONA_FEATURE_SPECS).filter((k) => !live.has(k));
expect(stale).toEqual([]); expect(stale).toEqual([]);
}); });
// 身份卡「关键标签」是按 key 白名单挑的。改 key 名(或摘掉 extractor)时若忘了同步这份名单,
// 页面上不会报错,只会**静悄悄少一个 chip** —— 客服看不到禁忌/免打扰这种红线标签才是事故。
test('⭐ 关键标签白名单里的 key 都还活着', async () => {
const live = new Set(await registryKeys());
const dead = PERSONA_KEY_FEATURE_KEYS.filter((k) => !live.has(k));
expect(dead).toEqual([]);
});
}); });
describe('spec.display —— 详情页说明', () => { describe('spec.display —— 详情页说明', () => {
......
...@@ -45,6 +45,7 @@ import { ...@@ -45,6 +45,7 @@ import {
RECALL_FEEDBACK_OPTIONS, RECALL_FEEDBACK_OPTIONS,
ABANDON_REASON_META, ABANDON_REASON_META,
personaFeatureSortKey, personaFeatureSortKey,
isKeyPersonaFeature,
type AbandonReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
...@@ -526,6 +527,7 @@ export function PlanDetailApp({ ...@@ -526,6 +527,7 @@ export function PlanDetailApp({
<aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full"> <aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full">
<IdentityCard <IdentityCard
patient={patient} patient={patient}
features={persona.features}
onOpenImage={() => onOpenImage={() =>
showToast('slate', '影像调阅', '跳转宿主页面') showToast('slate', '影像调阅', '跳转宿主页面')
} }
...@@ -1284,13 +1286,29 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) { ...@@ -1284,13 +1286,29 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
// ────────────────────────────────────────── // ──────────────────────────────────────────
function IdentityCard({ function IdentityCard({
patient, patient,
features,
onOpenImage, onOpenImage,
onOpenProfile, onOpenProfile,
}: { }: {
patient: typeof mockPatient; patient: typeof mockPatient;
/** 全量画像标签;卡内只挑「关键标签」露出(PERSONA_KEY_FEATURE_KEYS),其余在画像详情抽屉 */
features: typeof mockPersona.features;
onOpenImage: () => void; onOpenImage: () => void;
onOpenProfile: () => void; onOpenProfile: () => void;
}) { }) {
// 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。
// 选哪些是业务口径(PERSONA_KEY_FEATURE_KEYS),排序沿用抽屉那套(跟进要点 → 价值与阶段)。
const keyFeatures = useMemo(
() =>
features
.filter((f) => isKeyPersonaFeature(f.key))
.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);
}),
[features],
);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// patient.phone 当前 backend 已返回明文(plan-aggregate.service.ts:131)—— 详情页直接显示明文, // patient.phone 当前 backend 已返回明文(plan-aggregate.service.ts:131)—— 详情页直接显示明文,
// 不脱敏、不做 eye toggle;若未来 backend 只发 phoneMasked,复制时降级调 phone-reveal 端点。 // 不脱敏、不做 eye toggle;若未来 backend 只发 phoneMasked,复制时降级调 phone-reveal 端点。
...@@ -1347,6 +1365,13 @@ function IdentityCard({ ...@@ -1347,6 +1365,13 @@ function IdentityCard({
) : null} ) : null}
</div> </div>
</div> </div>
{/* ⭐ 关键标签 —— 紧贴姓名下方(业务 2026-07-29:可快速查看,要细看再进画像详情)。
chip 与画像标签卡/抽屉同一套组件,hover 仍能看「怎么算的」,不另起一套渲染。 */}
{keyFeatures.length > 0 && (
<div className="mt-1.5">
<PersonaTagCloud features={keyFeatures} />
</div>
)}
{patient.tags.length > 0 && ( {patient.tags.length > 0 && (
<div className="mt-1 flex items-center gap-1 flex-wrap"> <div className="mt-1 flex items-center gap-1 flex-wrap">
{patient.tags.map((tag) => ( {patient.tags.map((tag) => (
......
...@@ -31,6 +31,41 @@ export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> = ...@@ -31,6 +31,41 @@ export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> =
export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic']; export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic'];
/** /**
* ⭐ 身份卡「关键标签」—— 患者姓名下面直接露出的那几个,不是全部 16 个。
*
* 业务口径(2026-07-29):客服扫一眼就能判断「这通电话能不能打、该聊什么」的才算关键。
* 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 ——
* 这里取舍的是**首屏**,不是信息。
*
* 选这六个的理由,按客服打这通电话的顺序:
* contraindication 能不能治 —— 安全红线,漏看会出事
* special_attention 能不能打 —— 免打扰 / 屡次爽约,决定要不要拨
* urgency_level 多急
* potential_treatment 聊什么
* rfm / lifecycle_stage 这人值多少、走到哪一步 —— 决定给什么力度
*
* 没进来的:时间偏好 / 折扣锚点 / 治疗敏感(通话**中**才用得上,不是扫一眼要的);
* 年龄段 / 性别 / 家庭构成 / 获客渠道(基础属性,姓名行已经有性别年龄);
* 权益身份 / 转介绍达人 / 治疗史(重要,但不改变这通电话怎么开场)。
*
* ⚠️ 展示顺序不在这里定 —— 渲染按 personaFeatureSortKey(与抽屉同一套序),
* 本常量只管「哪些进首屏」。想加 key 先自问:客服扫一眼真会拿它做决定吗?
* 六个是软上限:再多就不是"关键"了,身份卡也会被挤成标签墙。
*/
export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
'contraindication',
'special_attention',
'urgency_level',
'potential_treatment',
'rfm',
'lifecycle_stage',
];
/** 该标签是否进身份卡首屏(未登记的一律不进 —— 新标签得显式选进来) */
export const isKeyPersonaFeature = (key: string): boolean =>
PERSONA_KEY_FEATURE_KEYS.includes(key);
/**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。 * 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
* *
* 为什么放在标签卡里而不是前端:原先前端 persona-feature-hover.tsx 自己硬编了一份 * 为什么放在标签卡里而不是前端:原先前端 persona-feature-hover.tsx 自己硬编了一份
......
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