Commit 768b1e32 by luoqi

merge: perf/ingest-recompute-hotspots → test

本轮迭代(2026-07-29):
- 品牌:主色换 PANTONE 286 C(#0032A0),teal-* 全站改名 brand-*
- 话术:自报家门改「我是{诊断医生}医生的助理X」,非客服身份(三档 promptVersion 全 bump)
- 详情页:关键画像标签上首屏(姓名下,素色无 label,点开定位到画像详情对应那条)
- 详情页:宿主槽位跳转一律新标签页(原 _top 会顶掉宿主页);隐藏「牙位」「历史联系·详情」
- 召回池:到诊派生字段落 patient_profiles(上次时间/上次医生/偏好医生)+ 筛选索引
- 修复:排序键精度、筛选 chip 显示原始 code、筛选面板 React 重复 key
parents c9796537 a8687e0a
Pipeline #3479 failed in 0 seconds
-- 数据回填 —— 把排序键 priority_score 从「0-100 整数」还原成与展示同精度的「0-100 一位小数」
--
-- 【症状】召回池按优先级降序排,客服看到的却是「3.12 → 3.12 → 3.10 → 3.10 → 3.10 → 3.12 → …」,
-- 像根本没排序。2026-07-29 用户截图报的。
--
-- 【根因】排序键 followup_plans.priority_score 由 priority-scorer 写入,当时是
-- score = Math.round(raw * 10) → 0-100 的**整数**
-- 而列表展示的是 breakdown.priority.raw 的**两位小数**(0-10)。
-- 排序精度比展示精度粗 10 倍 → 同一个整数档里塞着一堆不同的展示分,档内只能按
-- created_at 兜底排,于是看起来乱序。
-- 测试服实测:priority_score=31 一档里有 21 种展示分(3.20~2.59);
-- 全池 126,073 张工单只有 93 个排序档位,却有 849 种展示分。
--
-- 【代码侧已修】priority-scorer 改为 score = round(raw,2) * 10 —— 与展示 1:1。
-- 列本来就是 Float(schema 未变),量纲仍是 0-100(queueStats 的 70/40 分档、
-- 前端进度条 score/100 都不受影响)。
--
-- 【为什么还要这条回填】存量行的 score 仍是旧整数。plan-engine 的 unchanged 分支确实会
-- 就地改 priorityScore(`if (latest.priorityScore !== newPriorityScore) patch...`),
-- 所以下一轮全量重算(每 2 小时)能自愈 —— 但在那之前,页面上所有分都会显得"变整"
-- (如 2.74 → 2.70),看着像精度倒退。这条把它一次性补齐,消除过渡期。
--
-- 【口径】breakdown.priority.raw 就是当初算出来的两位小数原值,乘 10 即得新排序键。
-- 用 numeric 运算(不是 float)避免 3.12*10=31.200000000000003 这种噪声。
-- 没有 raw 的老行(早期 reason 没落 breakdown)保持原值不动,等重算自然覆盖。
--
-- 【幂等】WHERE 带「值确实不同」判断,重复执行为空跑。
-- 【代价】生产 ~206k plan / ~25 万 reason,两条 UPDATE,秒级。
-- 1) reason 级:排序键 = breakdown.priority.raw × 10
UPDATE "plan_reasons" r
SET "priority_score" = round((r."breakdown" -> 'priority' ->> 'raw')::numeric * 10, 1)::double precision
WHERE r."breakdown" -> 'priority' ->> 'raw' IS NOT NULL
AND r."priority_score"
IS DISTINCT FROM round((r."breakdown" -> 'priority' ->> 'raw')::numeric * 10, 1)::double precision;
-- 2) plan 级:= MAX over 它的 reason(与 plan-engine 的 newPriorityScore 同口径)
UPDATE "followup_plans" p
SET "priority_score" = m."mx"
FROM (SELECT "plan_id", MAX("priority_score") AS "mx" FROM "plan_reasons" GROUP BY "plan_id") m
WHERE m."plan_id" = p."id"
AND p."priority_score" IS DISTINCT FROM m."mx";
-- 到诊派生字段落 patient_profiles ——「上次到诊 / 上次医生 / 偏好医生」
--
-- 【为什么要物化】跨全池的筛选没有不物化的:否则「上次到诊在 3-6 个月」这个条件
-- 要对 2,500 万行 patient_facts 做 GROUP BY patient_id HAVING max(occurred_at) …,交互式查询扛不住。
-- 画像筛选也一样物化了 —— 它的"柱子"就叫 persona_features,只是立得早、又用 JSONB 做成了
-- 免 DDL 的扩展点,所以加维度时感觉不到成本。这三个字段不属于画像,没有现成柱子可搭。
--
-- 【为什么落 profiles 而不是 patients】profile 本来就装派生 / 摄入属性(获客渠道、转介绍统计),
-- patients 是主数据。派生的归派生,主表不被越搞越宽。
--
-- 【为什么不是画像特征】2026-07-29 第一版做成 persona 特征 visit_recency,当天被否:
-- 它会混进患者详情的画像标签抽屉、并喂给画像摘要 LLM。
-- 「上次到诊 2022-06-19」是原始事实;画像该是「重要价值 / 流失客 / 禁忌」这类判断。
-- (那版一行都没写进生产就撤了,无需清理。)
--
-- 【⭐ 存日期不存分档 —— 不需要任何定时任务】
-- 存 `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月)→ 得配到期重扫机制;
-- 存日期则**只有患者又来了才会变**,而"又来了"必然先写到诊 fact → 推进事实水位 →
-- 触发该患者画像重算 → 这三列顺路刷新。触发时机与已有机制天然重合,自洽,零额外调度。
-- 0-3 / 3-6 / … 的区间在**查询时**按 now 现算(visitRecencyRange),语义永远对得上今天。
--
-- 【索引】profile 是 1:1 表(PK = patient_id),行数 = 患者数,不需要 host/tenant 前缀
-- (筛选先经 patients 的 host/tenant 圈定,再 join 过来)。
-- · last_visit_at → 区间查询 · last_visit_doctor / preferred_doctor → 等值查询
-- ⭐ 是**等值**不是模糊:前端在 /plans/doctors 的名单上做客户端模糊,选中后发精确姓名。
-- 改成后端 ILIKE '%x%' 索引立刻失效、退回全表扫(见 2026-07-29「圈人 20 秒」事故)。
--
-- 【存量】新列全 NULL,等下一轮画像重算填。在那之前:卡片"上次到诊"留空、三个筛选筛不到人、
-- 医生名单接口返回空数组(前端显示「暂无医生名单」)。刻意**不做**"取不到就现算"的兜底 ——
-- 那会让卡片展示值与筛选口径再次分叉,正是本次返工要消灭的问题。
ALTER TABLE "patient_profiles"
ADD COLUMN IF NOT EXISTS "last_visit_at" date,
ADD COLUMN IF NOT EXISTS "last_visit_doctor" text,
ADD COLUMN IF NOT EXISTS "preferred_doctor" text;
CREATE INDEX IF NOT EXISTS "patient_profiles_last_visit_at_idx" ON "patient_profiles" ("last_visit_at");
CREATE INDEX IF NOT EXISTS "patient_profiles_last_visit_doctor_idx" ON "patient_profiles" ("last_visit_doctor");
CREATE INDEX IF NOT EXISTS "patient_profiles_preferred_doctor_idx" ON "patient_profiles" ("preferred_doctor");
......@@ -293,6 +293,40 @@ model PatientProfile {
referralCount Int? @map("referral_count")
referralAmountCents Int? @map("referral_amount_cents")
// ── 到诊派生字段 ——「上次到诊 / 上次医生 / 偏好医生」────────────────────────────────
// 放这张副表而不是 patients:profile 本来就装**派生 / 摄入属性**(获客渠道、转介绍统计),
// patients 是主数据。派生的归派生,主数据不被越搞越宽。
//
// ⚠️ **不是画像** —— 2026-07-29 曾一度做成 persona 特征 visit_recency,当天被否:
// 它会混进患者详情的画像标签抽屉、并喂给画像摘要 LLM。「上次到诊 2022-06-19」是原始事实,
// 画像该是「重要价值 / 流失客 / 禁忌」这类判断。
//
// **存日期不存分档**,这是不用任何定时任务的关键:
// `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月) 得配到期重扫;
// 存日期则**只有患者又来了才会变**,"又来了"必然先写到诊 fact 推进事实水位
// 触发该患者画像重算 这三个字段顺路刷新。触发时机天然重合,自洽。
// 0-3/3-6/ 的区间在**查询时** now 现算(visitRecencyRange),所以语义永远对得上今天。
//
// 卡片展示与筛选**都读这三列**,同源 —— 否则会出现"卡片显示 A、筛选按 B 匹配"
// 首次画像重算前为 NULL:卡片留空、筛选筛不到。刻意不做"取不到就现算"的兜底,
// 那会让展示值与筛选口径再次分叉。
/// 最近一次到诊日期。口径 = encounter + 实际治疗 + 挂号 + **病历**的并集。
/// ⚠️ 并集里的"病历"不能省:本地实测 48.7% 的患者只有病历、没有 encounter/治疗/挂号记录
/// (宿主 appointment.in_time 缺失等),漏掉就有近一半患者的"上次到诊"是空的。
/// 医生写了病历,患者必然到过场。详情页 lastVisit 早就是这个口径。
lastVisitAt DateTime? @map("last_visit_at") @db.Date
/// **上次到诊当天**那份病历的接诊医生。跨天不认 —— 否则「日期是A次、医生是B次」,客服照着说会露馅。
lastVisitDoctor String? @map("last_visit_doctor")
/// 主治医生 = 病历里出现频次最高的医生。当前用作业务要的「偏好医生」的代替 ——
/// DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;接入后换值即可,消费方不用改。
preferredDoctor String? @map("preferred_doctor")
/// 筛选索引:上次时间走区间、两个医生走等值(前端在名单上做模糊,选中发精确姓名)
@@index([lastVisitAt])
@@index([lastVisitDoctor])
@@index([preferredDoctor])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......
/**
* agent-identity —— 「自报家门」客服身份的**单一源**(稳健 / 标准 / 深度三档共用)。
* agent-identity —— 「自报家门」身份的**单一源**(稳健 / 标准 / 深度三档共用)。
*
* ## 患者侧身份 = 「某医生的助理」,不是「客服」(业务口径 2026-07-29)
* 完整一句是「我是{诊断医生}医生的助理{姓名}」—— 拼装在各档 prompt 里,本文件只管后半截身份。
* 换法的理由是业务定的:回访电话挂医生的名义,患者更愿意接、也更接近事实(话术全程以
* 诊断医生的关怀为主线);"客服"听起来像销售,开场就掉信任。
* ⚠️ 岗位称呼不再区分 staff/leader —— 患者不关心内部层级,统一"助理"。
*
* ## 为什么要占位符(2026-07 线上 bug)
* 原设计把登录客服的姓名直接烤进 LLM 生成的话术正文(v9:"我是X诊所的客服主管晋芳辰")。
......@@ -22,25 +28,34 @@
* · 系统回填:【回访客服】(本文件)—— 对 LLM 的契约完全一致:原样输出、别自己编名字。
*/
/** 自报家门占位符 —— LLM 原样输出,渲染期按当前登录客服回填 */
/**
* 自报家门占位符 —— LLM 原样输出,渲染期按当前登录人回填。
*
* ⚠️ 字面量仍是「回访客服」是**故意不动的**:线上 plan_scripts 里已有的正文都含这串,
* 改字面量 = 老缓存回填不上、患者会看到光秃秃的占位符。它现在只是个 token,
* 回填出来的内容已经是"助理X"(见 ROLE_TITLE_ZH)。
*/
export const AGENT_IDENTITY_PLACEHOLDER = '【回访客服】';
/** 无姓名时的兜底称谓(宿主未下发 users 字典 / 匿名调用)—— 不编名字,只报岗位 */
export const AGENT_IDENTITY_FALLBACK = '客服';
export const AGENT_IDENTITY_FALLBACK = '助理';
/** 自报家门用的客服身份:岗位称呼 + 姓名(姓名可空) */
/** 自报家门用的身份:岗位称呼 + 姓名(姓名可空) */
export interface ScriptAgentIdentity {
/** 宿主 users 字典里的姓名(如"李倩");字典缺失 → null,回填退回通用"客服" */
/** 宿主 users 字典里的姓名(如"李倩");字典缺失 → null,回填退回通用"助理" */
name: string | null;
/** 患者侧岗位称呼(员工/管理员→客服;主管→客服主管) */
/** 患者侧岗位称呼 —— 一律"助理"(见文件头:患者不关心内部层级) */
roleTitle: string;
}
/** JWT role → 患者侧岗位称呼。宿主角色与患者称呼是两件事,别把 admin 报成"管理员"。 */
/**
* JWT role → 患者侧岗位称呼。宿主角色与患者称呼是两件事,别把 admin 报成"管理员"。
* 三种角色现在同值 —— 表保留是因为"将来某类角色要换称呼"仍是这里改,不是散到各档 prompt。
*/
const ROLE_TITLE_ZH: Record<string, string> = {
staff: '客服',
leader: '客服主管',
admin: '客服',
staff: '助理',
leader: '助理',
admin: '助理',
};
/**
......@@ -58,15 +73,15 @@ export function resolveScriptAgent(user: {
};
}
/** 身份 → 落到话术里的那串字(有名字报"客服主管李倩";没名字只报"客服",绝不编) */
/** 身份 → 落到话术里的那串字(有名字报"助理李倩";没名字只报"助理",绝不编) */
export function agentIdentityText(agent: ScriptAgentIdentity | null | undefined): string {
if (!agent?.name) return AGENT_IDENTITY_FALLBACK;
return `${agent.roleTitle}${agent.name}`;
}
/**
* 渲染期回填 —— 话术正文里的 {@link AGENT_IDENTITY_PLACEHOLDER} 换成当前登录客服
* agent 缺省(内部调用 / 无登录态)→ 换成通用"客服",**不留占位符给患者看到**。
* 渲染期回填 —— 话术正文里的 {@link AGENT_IDENTITY_PLACEHOLDER} 换成当前登录
* agent 缺省(内部调用 / 无登录态)→ 换成通用"助理",**不留占位符给患者看到**。
*/
export function renderAgentIdentity<T extends string | null | undefined>(
content: T,
......
......@@ -39,9 +39,11 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null;
const goal = plan.goal?.trim() || null;
// 自报家门:姓名**不进 prompt** —— 给占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan 的、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
const selfIntro = `我是${clinicName}${AGENT_IDENTITY_PLACEHOLDER}`;
// 自报家门:身份是「诊断医生的助理」不是客服(业务口径 2026-07-29,见 agent-identity.ts);
// 姓名**不进 prompt** —— 给占位符,LLM 原样输出,读详情时按当前登录人回填
// (话术缓存 per-plan、召回池共享,烤进人名会让后开的人读到别人的名字)。
// 医生姓取自诊断医生;取不到时 deidentifyDoctor 给"您的主治" → "我是您的主治医生的助理X"。
const selfIntro = `我是${doctorSurname}医生的${AGENT_IDENTITY_PLACEHOLDER}`;
const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见上方"称呼"),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null;
......@@ -84,6 +86,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
## 开场用
- 称呼:${salutation}
- 自报家门:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编)
- 诊所名(**只在需要提到诊所时用这个**,别自己编"XX口腔";自报家门里不用带):${clinicName}
- 最近一次就诊(用于开场"自从…来过";诊断日见病历、可能更早):${lastVisitDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- 诊断医生:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......
......@@ -29,7 +29,7 @@ export interface ScriptContext {
guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
};
/** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */
/** 诊所名 —— 防 LLM 编造"XX口腔"的锚;⚠️ 自报家门里**不用**它(身份是"{诊断医生}医生的助理X") */
clinicName: string;
/** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入。
......
......@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v18'; // v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly promptVersion = 'draft_plan_script@2026-07-29-deep-write-v19'; // v19: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份)。v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
......@@ -12,7 +12,7 @@
# 这三处按原样写,不要改
1. 引导预约句式(严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
3. 自报家门:用给定的“自报家门”整串,其中 `【回访客服】` 原样保留(系统按登录客服回填)——别替换成具体姓名,也别自己编一个
3. 自报家门:用给定的“自报家门”整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成“客服/顾问”
# 开场/结束段
- 开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。
......
......@@ -76,7 +76,7 @@ export function draftOutputToDeep(out: DraftPlanScriptOutput): DeepDraft {
tone: out.tone,
sections: [
{ title: t?.opening ?? '开场白', markdown: out.opening },
{ title: t?.informMissed ?? '告知应治未治', markdown: out.informMissed },
{ title: t?.informMissed ?? '告知潜在治疗', markdown: out.informMissed },
{ title: t?.reviewAdvice ?? '复查建议', markdown: out.reviewAdvice },
{ title: t?.closing ?? '结束回访语', markdown: out.closing },
],
......
......@@ -112,9 +112,10 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE;
const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE;
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
const selfIntro = `我是${clinicName}${AGENT_IDENTITY_PLACEHOLDER}`;
// 自报家门:身份是「诊断医生的助理」不是客服(业务口径 2026-07-29,见 agent-identity.ts);
// 姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录人回填
// (话术缓存 per-plan、召回池共享,烤进人名会让后开的人读到别人的名字)。
const selfIntro = `我是${doctorSurname}医生的${AGENT_IDENTITY_PLACEHOLDER}`;
// 画像精简版(⚠安全 + 定语气):稳健档只灌"护栏"类,不灌切入点(避免诱导加推销),保持填空纪律。
const persona = buildPersonaGuide(input.personaHighlights ?? [], 'essential');
......@@ -125,6 +126,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
## 开场用
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编)
- 诊所名(**只在需要提到诊所时用这个**,别自己编"XX口腔";{自报家门} 里不用带):${clinicName}
- {智能时间显示}(最近一次就诊,用于开场"自从…来过"):${lastVisitDisplay}
- 那次主诉(该问题诊断那次,可能更早):${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......
......@@ -5,10 +5,10 @@
# 占位符约定(两种,别搞混)
- `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}`
- {智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。
- `【xxx】` = **原样保留**:【时间段1】【时间段2】【具体预约时间】(客服手填)、【回访客服】(系统按登录客服回填,别替换成人名、别自己编);结束语分支标签【预约成功】【预约不成功】照常输出。
- `【xxx】` = **原样保留**:【时间段1】【时间段2】【具体预约时间】(客服手填)、【回访客服】(系统按登录人回填成「助理X」,别替换成人名、别自己编);结束语分支标签【预约成功】【预约不成功】照常输出。
# 填空要点
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄) → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄;身份是**医生的助理**,别改写成"客服/顾问") → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
- 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 告知应治未治、复查建议分成短句。
......
......@@ -9,6 +9,7 @@ import { smartDateDisplay } from '../../shared/script-facts';
import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii';
import { SCRIPT_SAFETY_RULES } from '../../shared/safety-rules';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
// 安全规则(禁词/承诺/加粗时间)走单一源 shared/safety-rules.ts,三档共用,不再各档内联。
const safetyRules = SCRIPT_SAFETY_RULES;
......@@ -21,7 +22,8 @@ const safetyRules = SCRIPT_SAFETY_RULES;
* 不加粗具体时间、不承诺、时间一律用【时间段】/【具体预约时间】占位。
*/
export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
// clinicName 不进兜底开场 —— 自报家门是「{诊断医生}医生的助理X」,不带诊所名(同 prompt 口径)
const { patient, plan, clinicalContext } = input;
const salutation = patient.salutation; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好)
// 漏诊项 = 聚焦项 reasons[0](orchestrator 已 priorityScore 降序,单一源)→ 转换层归一
const topReason = (plan.reasons ?? [])[0];
......@@ -42,7 +44,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
return {
tone: 'warm',
opening: `• ${salutation}您好,我是${clinicName}的客服
opening: `• ${salutation}您好,我是${doctor}医生的${AGENT_IDENTITY_PLACEHOLDER}
${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}来过之后,您口腔情况怎么样?`,
informMissed: `• 之前${doctor}医生检查时,注意到您有${disease.label}的情况
......@@ -65,7 +67,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-08-4module-v30'; // v30: 低龄约束(<18 不主推种植,口径同召回 IMPLANT_LAST_AGE)+ 替牙期(≤12)矫治措辞统一说「早期矫治」(同 EARLY_ORTHO_MAX_AGE)。v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
'draft_plan_script@2026-07-29-4module-v31'; // v31: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份;岗位称呼统一"助理",诊所名另给一行防编造)。v30: 低龄约束(<18 不主推种植,口径同召回 IMPLANT_LAST_AGE)+ 替牙期(≤12)矫治措辞统一说「早期矫治」(同 EARLY_ORTHO_MAX_AGE)。v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable()
export class DraftPlanScriptCall
......
......@@ -7,7 +7,7 @@
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。
# 4 段通常覆盖(内容指引,可合并衔接、标题自起)
- 打招呼、确认对方方便、自报家门(用给定的“自报家门”整串,含 `【回访客服】` 照抄不改),以诊断医生名义体现关怀,用“最近一次就诊”问近况。
- 打招呼、确认对方方便、自报家门(用给定的“自报家门”整串,含 `【回访客服】` 照抄不改;身份是**医生的助理**,不要改写成“客服/顾问”),以诊断医生名义体现关怀,用“最近一次就诊”问近况。
- 带出本次这一个问题(有牙位就带上):以“诊断医生上次检查发现”的口吻,结合病历讲清“隐患”+“趁早处理的好处”,温和提醒、非吓唬非推销。
- 邀约来院复查本次问题(关怀口吻、不推销治疗、别写死分钟数)。
- 简短有温度的收尾:约成功就确认时间道别,没约成温和留口子(别写死“下周”这种绝对时间)。
......@@ -17,4 +17,4 @@
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
# 占位
只有这些需原样保留在话术里:`【时间段1】【时间段2】【具体预约时间】`(客服手填)、`【回访客服】`(自报家门,系统按登录客服回填 —— 别替换成具体姓名,也别自己编一个)。其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。
只有这些需原样保留在话术里:`【时间段1】【时间段2】【具体预约时间】`(客服手填)、`【回访客服】`(自报家门,系统按登录人回填成「助理X」 —— 别替换成具体姓名,也别自己编一个)。其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。
......@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
*
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v17'; // v17: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-07-29-standard-v18'; // v18: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份)。v17: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
......@@ -741,7 +741,7 @@ function roleNameZh(role: UserRole): string {
return ({ staff: '员工', leader: '主管', admin: '管理员' } as const)[role] ?? role;
}
/// 模拟登录的演示人名(2 brand × 3 role);话术自报家门会用到("我是X诊所的客服小王")。
/// 模拟登录的演示人名(2 brand × 3 role);话术自报家门会用到("我是韩医生的助理小王")。
const MOCK_NAMES: Record<string, Partial<Record<UserRole, string>>> = {
ruier: { staff: '小王', leader: '李莉', admin: '张敏' },
ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' },
......
......@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { PERSONA_TAG_FILTER_DIMS } from '@pac/types';
import { PERSONA_TAG_FILTER_DIMS, personaTagDimId } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service';
......@@ -20,8 +20,13 @@ function jsonResult(data: unknown) {
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。
*/
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map(
(d) => `${d.key}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) =>
// ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id,
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。
d.dynamic
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>`
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n');
const PERSONA_TAGS_DESC =
......
......@@ -3,7 +3,9 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { FactType } from '@pac/types';
import { FeatureRegistry } from './features/feature.registry';
import { visitFactsOf, visitSpanOf } from './features/visit-facts';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import { personaWriteVerdict, type StoredPersonaFeature } from './persona-diff';
import type {
......@@ -58,6 +60,85 @@ export class PersonaService {
* 该查询全租户聚合(大库可达数秒)。**single-flight**:缓存空/过期时,并发调用(批量重算
* concurrency>1)共享同一个在飞查询 —— 否则 N 个重查询同时打 DB 会卡死(370万 facts 实测)。
*/
/**
* 刷新 patients 上的到诊派生字段(上次到诊 / 上次医生 / 偏好医生)。
*
* 口径:
* · lastVisitAt = visitFactsOf(encounter + 实际治疗 + 挂号)里 occurredAt 最大的那次 ——
* 与 rfm / lifecycle_stage / urgency_level **同一口径**(改口径改 visit-facts.ts,一起动)。
* · lastVisitDoctor = **末诊当天**那份病历的 doctor_name;跨天不认 —— 否则
* 「日期是 A 次、医生是 B 次」,客服照着说会露馅,宁可留空。
* (doctor_name 只有 emr_record 带;encounter_record 实测两个宿主都是 0% 有名。)
* · preferredDoctor = 病历里出现频次最高的医生(并列取最近一次,保证结果稳定可复现)。
*
* 值没变就不写 —— 这趟是全量遍历,无谓 UPDATE 会把 patients 的死行堆起来。
*/
private async refreshVisitDerivedFields(
patientId: string,
ctx: FeatureExtractorContext,
): Promise<void> {
const emrs = ctx.factsByType.get(FactType.EMR_RECORD) ?? [];
// ⚠️ 到诊口径 = visitFactsOf **∪ 病历** —— 不能只用 visitFactsOf。
// 本地实测:17,637 个有事实的患者里 8,586 个(48.7%)**只有病历、没有 encounter/治疗/挂号**
// (宿主 appointment.in_time 缺失等原因)。只用 visitFactsOf 会让近一半患者"上次到诊"为空。
// 医生写了病历,患者必然到过场 —— 详情页 lastVisit 早就是这个并集口径,这里与它对齐。
// 注:rfm / lifecycle_stage / urgency_level 仍用窄口径 visitFactsOf,那是它们各自的历史口径,
// 不在本次改动范围(但同一批患者会因此少算就诊次数,值得另开一件事核实)。
const span = visitSpanOf([...visitFactsOf(ctx), ...emrs]);
const lastVisitAt = span.last ? new Date(span.last.toISOString().slice(0, 10)) : null;
let lastVisitDoctor: string | null = null;
if (span.last) {
const day = span.last.toISOString().slice(0, 10);
for (const f of emrs) {
if (!f.occurredAt || f.occurredAt.toISOString().slice(0, 10) !== day) continue;
const n = doctorNameOf(f);
if (n) {
lastVisitDoctor = n;
break;
}
}
}
const stat = new Map<string, { n: number; latest: number }>();
for (const f of emrs) {
const name = doctorNameOf(f);
if (!name) continue;
const cur = stat.get(name) ?? { n: 0, latest: 0 };
stat.set(name, {
n: cur.n + 1,
latest: Math.max(cur.latest, f.occurredAt?.getTime() ?? 0),
});
}
let preferredDoctor: string | null = null;
let best = { n: 0, latest: 0 };
for (const [name, v] of stat) {
if (v.n > best.n || (v.n === best.n && v.latest > best.latest)) {
preferredDoctor = name;
best = v;
}
}
const cur = await this.prisma.patientProfile.findUnique({
where: { patientId },
select: { lastVisitAt: true, lastVisitDoctor: true, preferredDoctor: true },
});
const same =
cur &&
(cur.lastVisitAt?.toISOString().slice(0, 10) ?? null) ===
(lastVisitAt?.toISOString().slice(0, 10) ?? null) &&
cur.lastVisitDoctor === lastVisitDoctor &&
cur.preferredDoctor === preferredDoctor;
if (same) return;
// upsert:壳档患者建档时就建了空 profile 行,但存量/异常路径可能缺,补建不报错
await this.prisma.patientProfile.upsert({
where: { patientId },
update: { lastVisitAt, lastVisitDoctor, preferredDoctor },
create: { patientId, lastVisitAt, lastVisitDoctor, preferredDoctor },
});
}
private async getMonetaryQuantiles(hostId: string, tenantId: string, nowMs: number): Promise<number[]> {
const key = `${hostId}:${tenantId}`;
const hit = this.mQuantileCache.get(key);
......@@ -377,6 +458,21 @@ export class PersonaService {
}
}
// ─── 到诊派生字段(**不是画像**)———————————————————————————————
// 「上次到诊 / 上次医生 / 偏好医生」是事实派生属性,落 patient_profiles 列,不进 persona_features。
// 为什么不进画像:2026-07-29 第一版做成 visit_recency 特征,当天被否 —— 它会混进患者详情的
// 画像标签抽屉、并喂给画像摘要 LLM。「上次到诊 2022-06-19」是原始事实,画像该是
// 「重要价值 / 流失客 / 禁忌」这类判断。
// 为什么写在这里:这趟遍历已经把该患者的 facts 全加载了,顺路算最省;跟画像共用触发时机
// (都由 fact 水位驱动)也正好。**只是借车,不是画像的一部分** —— 它不进 drafts、
// 不参与 personaWriteVerdict、不影响版本号。
// 辅助写入,失败只告警不中断:它不是画像的一部分,不该因为它把整条重算带崩。
await this.refreshVisitDerivedFields(patient.id, ctx).catch((err) =>
this.logger.warn(
`到诊派生字段刷新失败 patient=${patient.id}: ${err instanceof Error ? err.message : err}`,
),
);
// ─── 写入判定(写侧省写)───
// 闸放宽到 fact 水位之后,「算了但内容没变」会变多(reparse 常常只动了不进画像的字段)。
// 这里决定:不写 / 就地刷新 / 升版本。三种都要推水位,否则闸永远开着、下次还白算一遍。
......@@ -594,3 +690,10 @@ export interface RecomputeResult {
status: string;
durationMs: number;
}
/// 取 fact 里的医生姓名(只有 emr_record 带 doctor_name),空串当作没有
function doctorNameOf(f: { content: unknown }): string | null {
const n = (f.content as Record<string, unknown> | null)?.doctor_name;
const s = typeof n === 'string' ? n.trim() : '';
return s || null;
}
......@@ -604,6 +604,15 @@ function serializeScript(s: {
};
}
/**
* markdown H2 标题 → 段 id 的**解析键**。
*
* ⚠️ 这里必须保持「告知应治未治」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」——
* 它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知应治未治`),
* 以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段,
* 打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。
* 哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。
*/
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = {
开场白: 'opening',
告知应治未治: 'informMissed',
......@@ -615,7 +624,7 @@ const SECTION_META: Record<
{ label: string; durationHint: string }
> = {
opening: { label: '开场白', durationHint: '' },
informMissed: { label: '告知应治未治', durationHint: '' },
informMissed: { label: '告知潜在治疗', durationHint: '' },
reviewAdvice: { label: '复查建议', durationHint: '' },
closing: { label: '结束回访语', durationHint: '' },
};
......
......@@ -167,7 +167,21 @@ export function calcPriority(input: PriorityInput): PriorityResult {
const base = urgency * 0.4 + value * 0.3 + willingness * 0.3;
const raw = base * freshness * confidenceFactor; // 老诊断衰减 × 影像AI/建议 降权
const score = Math.max(0, Math.min(100, Math.round(raw * 10)));
// ⭐ score 与 breakdown.raw **同源同精度**,别再取整。
//
// 原来是 `Math.round(raw * 10)` —— 排序键退化成 0-100 的整数,而列表展示的是
// raw 的两位小数(0-10),**排序精度比展示精度粗 10 倍**。后果:同一个整数档里
// 塞着一堆不同的展示分,档内只能按 created_at 兜底排,客服看到的就是
// 「3.12 → 3.10 → 3.10 → 3.12」这种乱序。
// 2026-07-29 测试服实测:priority_score=31 一档里有 21 种展示分(3.20~2.59);
// 全池 126,073 张工单只有 93 个排序档位、却有 849 种展示分。
//
// 现在:raw 先按展示精度定死两位小数,score = 该值 × 10 → 两者严格 1:1,
// 「排序键 === 展示值 × 10」,档内不再有隐藏差异。列仍是 Float(schema 本来就是),
// 量纲仍是 0-100(queueStats 的 70/40 分档、前端进度条 score/100 全部不受影响)。
const rawDisplay = Math.round(raw * 100) / 100; // 0-10,两位小数 = 列表展示的那个数
const score = Math.max(0, Math.min(100, Math.round(rawDisplay * 1000) / 100));
return {
score,
......@@ -181,7 +195,7 @@ export function calcPriority(input: PriorityInput): PriorityResult {
freshness: Math.round(freshness * 100) / 100,
confidenceFactor,
base: Math.round(base * 100) / 100,
raw: Math.round(raw * 100) / 100,
raw: rawDisplay, // 与 score 同一个数(score = rawDisplay × 10),别各算各的
},
};
}
......@@ -74,6 +74,23 @@ export class PlanController {
return this.plans.counts(scope, user.permissions);
}
/**
* 「上次医生 / 偏好医生」筛选用的候选名单。
*
* ⚠️ 必须放在 `@Get(':id')` **之前** —— 否则 'doctors' 会被当成 planId 吃掉(Nest 按声明顺序匹配)。
*/
@Get('doctors')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: '本 scope 的医生名单(筛选面板的可搜索输入框用;结果缓存)',
description:
'取自 patients.last_visit_doctor / preferred_doctor 去重(事实派生列,非画像)。' +
'前端在这个名单上做客户端模糊匹配,**选中后发精确姓名** —— 等值查询才吃得到偏索引。',
})
doctors(@TenantScope() scope: TenantScopeContext) {
return this.plans.doctorOptions(scope);
}
@Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
......
......@@ -16,9 +16,14 @@ import {
type PlanCountsResponse,
type PlanDetailResponse,
type PlanReasonBrief,
PersonaFeatureKey,
personaTagDimId,
type PersonaTagFilterDim,
visitRecencyRange,
} from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -60,6 +65,7 @@ export class PlanService {
constructor(
private readonly prisma: PrismaService,
private readonly executions: ExecutionService,
private readonly redis: RedisService,
) {}
// ─────────────────────────────────────────────
......@@ -106,11 +112,18 @@ export class PlanService {
phoneVerified: true,
gender: true,
birthDate: true,
medicalRecordNumber: true,
// 到诊派生字段在 profile(派生的归派生,主表不加宽);卡片与筛选读同一处,不会分叉
profile: { select: { lastVisitAt: true, lastVisitDoctor: true } },
},
})
: [];
const patientById = new Map(patients.map((p) => [p.id, p]));
// 卡片补充信息(上次到诊 / 那次的医生 / 潜在治疗标签)—— 两条**整页批量**查询,
// 不逐行发。本页最多 pageSize(25)个患者,都走 patient_id 索引。
const potentialByPatient = await this.fetchPotentialTreatments(patientIds);
// 每个 plan 最近一次执行结果(左栏「成功/不成功/保持」角标用)。
// 本页 plan 数少(≤pageSize),执行记录也少;一次拉齐后 JS 取每 plan 最新一条。
const planIds = rows.map((r) => r.id);
......@@ -143,6 +156,10 @@ export class PlanService {
phoneVerified: patient?.phoneVerified ?? false,
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
medicalRecordNumber: patient?.medicalRecordNumber ?? null,
lastVisitAt: patient?.profile?.lastVisitAt?.toISOString().slice(0, 10) ?? null,
lastVisitDoctor: patient?.profile?.lastVisitDoctor ?? null,
potentialTreatments: potentialByPatient.get(p.patientId) ?? [],
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
......@@ -154,6 +171,72 @@ export class PlanService {
};
}
/**
* 列表卡片的潜在治疗标签 —— 整页患者一条查询。
*
* 只剩这一个画像字段:上次到诊 / 上次医生已改从 patients 列取(它们是事实派生属性,
* 不是画像 —— 见 schema.prisma 上 lastVisitAt 的注释),patients 本来就在 select 里,零额外查询。
*
* ⭐ 取 types(code)不取 labels(中文):labels 是算画像那刻写死进 JSON 的,改措辞得全量重算
* 才生效;code 稳定,中文由前端查 POTENTIAL_TREATMENT_CARD_LABEL。
*/
private async fetchPotentialTreatments(patientIds: string[]): Promise<Map<string, string[]>> {
const out = new Map<string, string[]>();
if (patientIds.length === 0) return out;
const rows = await this.prisma.personaFeature.findMany({
where: {
key: PersonaFeatureKey.POTENTIAL_TREATMENT,
persona: { patientId: { in: patientIds }, supersededAt: null },
},
select: { data: true, persona: { select: { patientId: true } } },
});
for (const r of rows) {
const types = (r.data as { types?: unknown } | null)?.types;
if (Array.isArray(types)) {
out.set(
r.persona.patientId,
types.filter((t): t is string => typeof t === 'string' && !!t),
);
}
}
return out;
}
/**
* 「上次医生 / 偏好医生」筛选的候选名单 —— 本 scope 内出现过的医生姓名(去重、按姓名排序)。
*
* 为什么要这个接口:医生是**开集**(单宿主几百个),chip 平铺的面板放不下。
* 前端把名单一次拉走、在输入框里做客户端模糊匹配,**选中后发精确姓名**;
* 后端仍是等值查询 → 吃得到 visit_recency 的偏索引。
* (做成后端 ILIKE 模糊反而会退化成全分区扫,就是 2026-07-29 那个「圈人 20 秒」。)
*
* 口径:只统计**当前版**画像;不按 clinicIds 再切 —— 医生名单是选择器的候选集,
* 多几个不属于本诊所的名字只是选了筛不出人,而漏名字会让客服以为"这医生没数据"。
* 真正的数据边界由列表查询自己的 scope 保证,这里不是权限闸。
*
* 缓存 6 小时:医生名单变动以月计,而这是个 DISTINCT 聚合,不该每次开筛选面板都跑。
*/
async doctorOptions(scope: TenantScopeContext): Promise<{ doctors: string[] }> {
const cacheKey = `pac:plan:doctors:${scope.hostId}:${scope.tenantId}`;
const cached = await this.redis.get(cacheKey).catch(() => null);
if (cached) return { doctors: JSON.parse(cached) as string[] };
const rows = await this.prisma.$queryRaw<Array<{ name: string }>>`
SELECT DISTINCT name FROM (
SELECT nullif(pr.last_visit_doctor, '') AS name FROM patient_profiles pr JOIN patients p ON p.id = pr.patient_id
WHERE p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
UNION ALL
SELECT nullif(pr.preferred_doctor, '') FROM patient_profiles pr JOIN patients p ON p.id = pr.patient_id
WHERE p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
) t
WHERE name IS NOT NULL
ORDER BY name
`;
const doctors = rows.map((r) => r.name);
await this.redis.setEx(cacheKey, JSON.stringify(doctors), 6 * 3600).catch(() => undefined);
return { doctors };
}
// ─────────────────────────────────────────────
// buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人)
// ─────────────────────────────────────────────
......@@ -237,17 +320,39 @@ export class PlanService {
// 同一维度多选 = OR(该 feature 的 data 值命中任一);跨维度 = AND(每个维度各自 some)。
// 维度/取值/data 路径以 PERSONA_TAG_FILTER_DIMS 为单一真理源(非法 key/value 静默丢弃)。
if (query.personaTags) {
const byKey = new Map<string, string[]>();
for (const { key, value } of parsePersonaTags(query.personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === key);
if (!dim || !dim.options.some((o) => o.value === value)) continue;
const arr = byKey.get(key) ?? [];
arr.push(value);
byKey.set(key, arr);
// ⚠️ 按**维度 id** 分组,不是按 persona 特征 key —— visit_recency 一个特征出三个可筛字段
// (上次时间 / 上次医生 / 偏好医生),按 key 分组会把它们串成一条 OR,筛出错的人。
const byDim = new Map<string, { dim: PersonaTagFilterDim; values: string[] }>();
for (const { key: dimId, value } of parsePersonaTags(query.personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === dimId);
if (!dim) continue;
// 开集维度(医生姓名)不做闭集校验;闭集维度非法取值静默丢弃,行为不变。
// 两者取值都只进参数化 equals / array_contains,无注入面。
if (!dim.dynamic && !dim.options.some((o) => o.value === value)) continue;
const cur = byDim.get(dimId) ?? { dim, values: [] };
cur.values.push(value);
byDim.set(dimId, cur);
}
const personaConds: Prisma.PatientWhereInput[] = [];
for (const [key, values] of byKey) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === key)!;
for (const { dim, values } of byDim.values()) {
// ── 事实派生属性:直接查 patients 列(不穿 personas → persona_features)──
if (dim.source === 'patient') {
const field = dim.patientField!;
if (field === 'lastVisitAt') {
// ⭐ 存的是**日期**不是分档 —— 区间在这里按 now 现算,所以永远准,
// 不会像存死分档那样随时间过期(那需要一整套「到期重算」机械)。
const ranges = values
.map((v) => visitRecencyRange(v, new Date()))
.filter((r): r is { gte?: Date; lt?: Date } => !!r);
if (ranges.length > 0) {
personaConds.push({ profile: { OR: ranges.map((r) => ({ lastVisitAt: r })) } });
}
} else {
// 医生:等值(前端在名单上做模糊,选中后发精确姓名 —— 等值才吃得到索引)
personaConds.push({ profile: { [field]: { in: values } } } as Prisma.PatientWhereInput);
}
continue;
}
const valueConds = values.map((v) =>
dim.isArray
? { data: { path: [dim.dataPath], array_contains: v } }
......@@ -257,7 +362,7 @@ export class PlanService {
personas: {
some: {
supersededAt: null,
features: { some: { key, OR: valueConds } },
features: { some: { key: dim.key, OR: valueConds } },
},
},
});
......
......@@ -13,19 +13,19 @@ import {
} from '../src/modules/ai/calls/draft-plan-script/shared/agent-identity';
describe('resolveScriptAgent | JWT → 自报家门身份', () => {
test('主管 → 客服主管 + 字典姓名', () => {
test('⭐ 患者侧身份一律「助理」—— 不是客服,也不暴露内部层级', () => {
expect(
resolveScriptAgent({
sub: 'u1',
role: 'leader',
dictionary: { users: { u1: '李倩' } },
}),
).toEqual({ name: '李倩', roleTitle: '客服主管' });
).toEqual({ name: '李倩', roleTitle: '助理' });
});
test('员工 / 管理员 → 患者侧一律称"客服"(不把 admin 报成"管理员")', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'staff' }).roleTitle).toBe('客服');
expect(resolveScriptAgent({ sub: 'u1', role: 'admin' }).roleTitle).toBe('客服');
test('员工 / 管理员 → 同样是"助理"(不把 admin 报成"管理员")', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'staff' }).roleTitle).toBe('助理');
expect(resolveScriptAgent({ sub: 'u1', role: 'admin' }).roleTitle).toBe('助理');
});
test('字典缺失 / 空白名 → name=null,不编名字', () => {
......@@ -35,62 +35,63 @@ describe('resolveScriptAgent | JWT → 自报家门身份', () => {
).toBeNull();
});
test('未知角色 → 兜底"客服"', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'wat' }).roleTitle).toBe('客服');
test('未知角色 → 兜底"助理"', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'wat' }).roleTitle).toBe('助理');
});
});
describe('agentIdentityText | 身份 → 落到话术里的字', () => {
test('有名字:岗位 + 姓名', () => {
expect(agentIdentityText({ name: '李倩', roleTitle: '客服主管' })).toBe('客服主管李倩');
expect(agentIdentityText({ name: '李倩', roleTitle: '助理' })).toBe('助理李倩');
});
test('无名字:只报岗位,绝不编名字', () => {
expect(agentIdentityText({ name: null, roleTitle: '客服主管' })).toBe('客服');
expect(agentIdentityText(null)).toBe('客服');
expect(agentIdentityText(undefined)).toBe('客服');
expect(agentIdentityText({ name: null, roleTitle: '助理' })).toBe('助理');
expect(agentIdentityText(null)).toBe('助理');
expect(agentIdentityText(undefined)).toBe('助理');
});
});
describe('renderAgentIdentity | 渲染期回填', () => {
const content = `## 开场白\n您好张先生,我是本诊所的${AGENT_IDENTITY_PLACEHOLDER},耽误您两分钟。`;
// 正文形如「我是{诊断医生}医生的【回访客服】」—— 占位符前后的字由 prompt 拼,这里只管回填
const content = `## 开场白\n您好张先生,我是薛医生的${AGENT_IDENTITY_PLACEHOLDER},耽误您两分钟。`;
test('按当前登录人回填', () => {
expect(renderAgentIdentity(content, { name: '李倩', roleTitle: '客服主管' })).toContain(
'我是本诊所的客服主管李倩',
expect(renderAgentIdentity(content, { name: '李倩', roleTitle: '助理' })).toContain(
'我是薛医生的助理李倩',
);
});
test('⭐ 同一份缓存正文,两个客服各读各的名字(线上 bug 的回归锁)', () => {
const a = renderAgentIdentity(content, { name: '晋芳辰', roleTitle: '客服主管' });
const b = renderAgentIdentity(content, { name: '李倩', roleTitle: '客服主管' });
test('⭐ 同一份缓存正文,两个各读各的名字(线上 bug 的回归锁)', () => {
const a = renderAgentIdentity(content, { name: '晋芳辰', roleTitle: '助理' });
const b = renderAgentIdentity(content, { name: '李倩', roleTitle: '助理' });
expect(a).toContain('晋芳辰');
expect(a).not.toContain('李倩');
expect(b).toContain('李倩');
expect(b).not.toContain('晋芳辰');
});
test('无登录身份 → 退回通用"客服";占位符不能漏给患者看到', () => {
test('无登录身份 → 退回通用"助理";占位符不能漏给患者看到', () => {
const out = renderAgentIdentity(content);
expect(out).toContain('我是本诊所的客服');
expect(out).toContain('我是薛医生的助理');
expect(out).not.toContain(AGENT_IDENTITY_PLACEHOLDER);
});
test('多处出现全部替换', () => {
const twice = `${AGENT_IDENTITY_PLACEHOLDER}${AGENT_IDENTITY_PLACEHOLDER}`;
expect(renderAgentIdentity(twice, { name: '李倩', roleTitle: '客服' })).toBe('客服李倩 … 客服李倩');
expect(renderAgentIdentity(twice, { name: '李倩', roleTitle: '助理' })).toBe('助理李倩 … 助理李倩');
});
test('不含占位符 / null 内容 → 原样返回', () => {
expect(renderAgentIdentity('没有占位符', { name: '李倩', roleTitle: '客服' })).toBe('没有占位符');
expect(renderAgentIdentity('没有占位符', { name: '李倩', roleTitle: '助理' })).toBe('没有占位符');
expect(renderAgentIdentity(null)).toBeNull();
});
test('不碰客服手填的时间占位', () => {
const s = `李医生【时间段1】和【时间段2】有空;我是${AGENT_IDENTITY_PLACEHOLDER}`;
const out = renderAgentIdentity(s, { name: '李倩', roleTitle: '客服主管' });
const s = `李医生【时间段1】和【时间段2】有空;我是李医生的${AGENT_IDENTITY_PLACEHOLDER}`;
const out = renderAgentIdentity(s, { name: '李倩', roleTitle: '助理' });
expect(out).toContain('【时间段1】');
expect(out).toContain('【时间段2】');
expect(out).toContain('我是客服主管李倩');
expect(out).toContain('我是李医生的助理李倩');
});
});
import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_FEATURE_META } from '@pac/types';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_KEY_FEATURE_KEYS,
personaTagDimId, PERSONA_FEATURE_META } from '@pac/types';
import {
FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS,
......@@ -38,6 +39,14 @@ describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => {
const stale = Object.keys(PERSONA_FEATURE_SPECS).filter((k) => !live.has(k));
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 —— 详情页说明', () => {
......@@ -88,14 +97,45 @@ describe('spec.display —— 详情页说明', () => {
describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
test('⭐ 可筛维度的 key 必须是在跑的标签(筛一个不产出的标签 = 永远 0 结果)', async () => {
const live = new Set(await registryKeys());
const orphan = PERSONA_TAG_FILTER_DIMS.filter((d) => !live.has(d.key)).map((d) => d.key);
// source='patient' 的维度(上次到诊 / 上次医生 / 偏好医生)查的是 patient_profiles 列,
// 不是画像特征 —— 它们 key 为空,不参与本校验。为什么不做成画像:见 schema.prisma 注释。
const orphan = PERSONA_TAG_FILTER_DIMS.filter(
(d) => d.source !== 'patient' && !live.has(d.key),
).map((d) => d.key);
expect(orphan).toEqual([]);
});
test('圈人维度的取值不能为空', () => {
test('闭集维度的取值不能为空;开集维度(dynamic)不许留残缺 options', () => {
for (const d of PERSONA_TAG_FILTER_DIMS) {
if (d.dynamic) {
// 开集维度(如医生姓名)取值由接口下发,options 必须**空**——
// 留几个样例值会让前端以为是闭集、把没列出的医生筛没了。
expect(d.options).toHaveLength(0);
continue;
}
expect(d.options.length).toBeGreaterThan(0);
for (const o of d.options) expect(o.value.length).toBeGreaterThan(0);
}
});
test('⭐ 闭集维度的每个取值都要能查回中文 —— 否则已选 chip 显示成原始 code', () => {
// 2026-07-29 线上现象:已选标签显示成「18m_plus ×」而不是「18 个月以上 ×」。
// 根因是消费方按 d.key 查维度,而 source='patient' 的维度 key 为空串、筛选串用的是 id,
// 查不到维度 → 退回显示 value 本身。这里锁住"用 id 一定查得回来"。
for (const d of PERSONA_TAG_FILTER_DIMS) {
if (d.dynamic) continue; // 开集维度取值本身就是中文(医生姓名),无需查表
const dimId = personaTagDimId(d);
for (const o of d.options) {
const found = PERSONA_TAG_FILTER_DIMS.find((x) => personaTagDimId(x) === dimId);
expect(found?.options.find((x) => x.value === o.value)?.zh).toBeTruthy();
}
}
});
test('⭐ 维度标识必须唯一 —— 同一特征出多个可筛字段时靠它区分', () => {
// visit_recency 一个特征出三个字段(bucket / lastDoctor / preferredDoctor),
// 只靠 key 分不开;重复的 id 会让筛选串指向错的 dataPath,筛出错的人。
const ids = PERSONA_TAG_FILTER_DIMS.map(personaTagDimId);
expect(new Set(ids).size).toBe(ids.length);
});
});
......@@ -114,6 +114,11 @@ function makeHarness(o: Opts) {
const prisma = {
patient: { findUnique: jest.fn().mockResolvedValue(PATIENT) },
// 到诊派生字段(上次到诊 / 医生)写在 profile 上 —— 不是画像,只是借这趟遍历顺路刷新
patientProfile: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({}),
},
// readWatermarks(patient_facts / patient_transactions)与 M 分位查询共用 $queryRaw,靠 SQL 文本分流
$queryRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
const sql = strings.join('');
......
/**
* 优先级排序键的精度 —— score 必须与列表展示的 raw 同源同精度
*
* 2026-07-29 线上现象:召回池按优先级降序排,客服看到的却是
* 「3.12 → 3.12 → 3.10 → 3.10 → 3.10 → 3.12 → …」,像没排序。
*
* 根因:排序键 `followup_plans.priority_score` 当时是 `Math.round(raw * 10)` —— 0-100 的**整数**,
* 而列表展示的是 `breakdown.priority.raw` 的**两位小数**(0-10)。排序精度比展示精度粗 10 倍,
* 同一个整数档里塞着一堆不同的展示分,档内只能按 created_at 兜底排。
* 测试服实测:priority_score=31 一档里有 21 种展示分(3.20~2.59);
* 全池 126,073 张工单只有 93 个排序档位,却有 849 种展示分。
*
* 不变量:**score === breakdown.raw × 10**。守住它,「按 score 排序」就等价于
* 「按客服看到的那个数排序」,不会再出现档内乱序。
*/
import { calcPriority, type PriorityInput } from '../src/modules/plan/engine/priority-scorer';
const base: PriorityInput = {
urgencyLevel: 'mid',
daysSince: 200,
windowDays: 180,
primaryCode: 'K08',
toothCount: 1,
rfmSegment: 'general_develop',
consultIntentMatch: false,
lifecycleStage: 'reactivate',
hasTreatmentHistory: false,
isReferralChampion: false,
recentVisitGood: false,
confidence: 1,
};
/** 造一批只在"新鲜度"上有细微差别的输入 —— 正是线上同一档内的那种邻近分 */
const NEIGHBOURS: PriorityInput[] = Array.from({ length: 60 }, (_, i) => ({
...base,
daysSince: 150 + i, // 逐天递增:相邻两档的 raw 只差千分位,正是被旧口径压平的那种
}));
describe('优先级分:排序键与展示值同源', () => {
test('不变量:score === raw × 10(逐例)', () => {
for (const input of NEIGHBOURS) {
const { score, breakdown } = calcPriority(input);
// 浮点乘法有噪声,比到分厘即可(展示只到两位小数)
expect(score).toBeCloseTo(breakdown.raw * 10, 6);
}
});
test('按 score 排序 === 按展示值排序(线上那个乱序的正解)', () => {
const rows = NEIGHBOURS.map((input) => {
const { score, breakdown } = calcPriority(input);
return { score, disp: (score / 10).toFixed(2), raw: breakdown.raw };
});
const byScore = [...rows].sort((a, b) => b.score - a.score).map((r) => r.disp);
const byDisp = [...rows].sort((a, b) => Number(b.disp) - Number(a.disp)).map((r) => r.disp);
expect(byScore).toEqual(byDisp);
// 且展示序列本身单调不增 —— 客服眼里不会再出现「3.10 之后又冒出 3.12」
const nums = byScore.map(Number);
for (let i = 1; i < nums.length; i++) expect(nums[i]!).toBeLessThanOrEqual(nums[i - 1]!);
});
test('回归:被旧口径压平的那些分,现在能分开', () => {
const scored = NEIGHBOURS.map(calcPriority);
// 找一对「旧实现会判定相等、展示上本来就不同」的 —— 线上乱序就是这种对造成的
const pair = scored.flatMap((a, i) =>
scored.slice(i + 1).map((b) => ({ a, b })),
).find(({ a, b }) =>
Math.round(a.score) === Math.round(b.score) && a.breakdown.raw !== b.breakdown.raw,
);
expect(pair).toBeDefined(); // 邻近分里必然存在这种对,否则这个 bug 本来就不会发生
expect(pair!.a.score).not.toBe(pair!.b.score); // 新口径必须分得开(旧实现这里相等)
});
test('量纲不变:仍是 0-100,queueStats 的 70/40 分档口径不受影响', () => {
const top = calcPriority({
...base,
urgencyLevel: 'urgent',
primaryCode: 'K08',
toothCount: 3,
rfmSegment: 'important_value',
consultIntentMatch: true,
lifecycleStage: 'mature',
hasTreatmentHistory: true,
isReferralChampion: true,
recentVisitGood: true,
daysSince: 0,
confidence: 1,
});
expect(top.score).toBeGreaterThan(70);
expect(top.score).toBeLessThanOrEqual(100);
const bottom = calcPriority({ ...base, urgencyLevel: null, daysSince: 3000, confidence: 0.5 });
expect(bottom.score).toBeGreaterThanOrEqual(0);
expect(bottom.score).toBeLessThan(40);
});
});
......@@ -132,7 +132,11 @@ describe('priority-scorer v3 | 确定性因子', () => {
expect(r.breakdown.value).toBe(2);
expect(r.breakdown.willingness).toBe(3.63);
expect(r.breakdown.base).toBe(4.49);
expect(r.score).toBe(45); // round(4.4875 × 10)
// score 与展示值同源:raw 先定到两位小数(4.4875 → 4.49),score = 该值 × 10。
// 2026-07-29 前这里是 round(4.4875 × 10) = 45 —— 排序键被取整,比展示精度粗 10 倍,
// 召回池档内乱序就是这么来的(见 priority-score-precision.spec.ts)。
expect(r.breakdown.raw).toBe(4.49);
expect(r.score).toBe(44.9);
});
test('种植(K08)价值按牙数分档:单颗8 / 多颗9 / 半口+10', () => {
......
......@@ -378,18 +378,22 @@ describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () =>
test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => {
const block = buildRichFactBlock(makeInput());
expect(block).toContain('称呼:张先生');
// ⭐ 自报家门给占位符,不给人名 —— 姓名在渲染期按登录客服回填(见 agent-identity.ts)
expect(block).toContain('我是测试口腔的【回访客服】');
// ⭐ 自报家门 = 「{诊断医生}医生的 + 占位符」,不给人名 —— 姓名在渲染期按登录人回填。
// 身份是**医生的助理**不是客服(业务口径 2026-07-29,见 agent-identity.ts)
expect(block).toContain('我是韩医生的【回访客服】');
// 诊所名仍给,但只作"要提到诊所时用这个"的防编造锚,不再进自报家门
expect(block).toContain('测试口腔');
expect(block).not.toContain('我是测试口腔的');
expect(block).toContain('韩医生'); // 去名留姓
expect(block).not.toContain('韩维'); // 全名不进 prompt
expect(block).not.toContain('张震校');
expect(block).toContain('牙疼来看');
});
test('客服姓名不进 prompt(占位符是唯一形态)', () => {
test('回访人姓名不进 prompt(占位符是唯一形态)', () => {
const block = buildRichFactBlock(makeInput());
// 任何具体人名都不该出现在自报家门位置 —— 缓存 per-plan、召回池共享,烤人名会串号
expect(block).not.toMatch(/我是测试口腔的客服(主管)?[一-龥]/);
expect(block).not.toMatch(/我是韩医生的(助理|客服)(主管)?[一-龥]/);
});
test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => {
......
......@@ -92,7 +92,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
</div>
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/30">
<div className="flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-[12px] text-slate-500 shadow-md ring-1 ring-slate-200">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-teal-500 border-t-transparent" />
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
切换中…
</div>
</div>
......
......@@ -59,7 +59,7 @@ export default function PlansLayout({ children }: { children: React.ReactNode })
<button
type="button"
onClick={() => setRailOpen(true)}
className="fixed left-0 top-1/2 z-50 -translate-y-1/2 rounded-r-lg bg-teal-600 px-1.5 py-3 text-white shadow-lg hover:bg-teal-700 lg:hidden"
className="fixed left-0 top-1/2 z-50 -translate-y-1/2 rounded-r-lg bg-brand-600 px-1.5 py-3 text-white shadow-lg hover:bg-brand-700 lg:hidden"
title="打开患者列表"
>
<span className="flex flex-col items-center gap-1">
......
......@@ -62,7 +62,7 @@ function EntryResolver() {
if (phase === 'resolving') {
return (
<div className="flex h-screen items-center justify-center gap-2 text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-teal-500 border-t-transparent" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
正在进入召回工作台…
</div>
);
......@@ -74,7 +74,7 @@ function EntryResolver() {
{/* 空态 header —— 内容只有 PAC 品牌 + 用户三件套,无功能项;嵌入宿主时整体隐藏 */}
{!isEmbedded() && (
<header className="flex h-12 flex-none items-center gap-2 border-b border-slate-100 bg-white px-4">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-brand-600 text-[12px] font-bold text-white">
PAC
</span>
<h1 className="text-[14px] font-semibold text-slate-900">召回工作台</h1>
......@@ -91,7 +91,7 @@ function EntryResolver() {
</span>
<div className="text-[15px] font-medium text-slate-700">当前没有待跟进的召回任务</div>
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 画像标签)自行查找,
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,
或等召回引擎下次重算后刷新。
</p>
<button
......
......@@ -4,7 +4,7 @@
* PAC Design Tokens(design-system.md §2-§4)
*
* 沿用 shadcn 的 HSL var 模式(方便组件级覆盖),但:
* - primary 改成 teal-600(PAC 品牌)
* - primary 改成 brand-600(PAC 品牌 = PANTONE 286 C / #0032A0)
* - border 加深(default shadcn 91.4% 视觉太弱)
* - 阴影整体放轻(Claude / OpenAI 现代风:border 主导,shadow 仅用于"浮起"场景)
* - 字体优先 PingFang(医疗 SaaS 中文场景)
......@@ -27,7 +27,7 @@
--accent-foreground: 222.2 47.4% 11.2%;
/* === PAC 品牌色 === */
--primary: 173 80% 36%; /* teal-600 */
--primary: 221.25 100% 31.4%; /* brand-600 = #0032A0(PANTONE 286 C) */
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
......@@ -37,13 +37,37 @@
/* === 边框 — 加深(91.4% → 87%,1px 视觉更扎实)=== */
--border: 214 24% 87%;
--input: 214 28% 82%; /* 输入框边再深一档,跟 hover 状态拉开 */
--ring: 173 80% 36%; /* focus ring 与 primary 同色 */
--ring: 221.25 100% 31.4%; /* focus ring 与 primary 同色 */
--radius: 0.5rem;
}
}
@theme inline {
/* ⭐ === PAC 品牌色阶 brand-50…900 ===
*
* 主色 = **PANTONE 286 C / #0032A0**(RGB 0/50/160),坐在 600 档 —— 因为
* 全站 `bg-brand-600` 是主按钮、`--primary` 也指这一档,主色必须落在这里才算"改了主色"。
* 其余档由同色相(H=221.25)调明度/饱和推出:浅档做底纹(brand-50 是最常用的选中/命中底),
* 深档做正文链接(brand-700 用得最多)。
*
* ⚠️ 类名从 `teal-*` 整体改名成 `brand-*`(263 处)—— 不是把 Tailwind 的 teal 调色板
* 偷偷改成蓝的:那样类名写着 teal 渲染出蓝,下一个人读代码会以为自己看错了。
* 品牌色就该叫 brand,换色只改这一段。
*
* 换色只需改下面 10 行;别在组件里写死十六进制(宠物 SVG 是唯一例外,见 pet-body.tsx)。
*/
--color-brand-50: #F0F4FF;
--color-brand-100: #DCE7FE;
--color-brand-200: #BACFFD;
--color-brand-300: #85A9FA;
--color-brand-400: #3B76F7;
--color-brand-500: #0043D6;
--color-brand-600: #0032A0; /* ← 主色 PANTONE 286 C */
--color-brand-700: #002985;
--color-brand-800: #01226A;
--color-brand-900: #031C53;
/* === 颜色 token → 自动生成 bg-{name} text-{name} border-{name} === */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
......
......@@ -121,7 +121,7 @@ export default function PetLabPage() {
{/* 栖息台:离地 ~120px,在可跳高度内(data-pet-perch 机制演示) */}
<div
data-pet-perch
className="fixed bottom-[120px] left-1/2 h-3 w-56 -translate-x-1/2 rounded-full bg-teal-100 shadow-inner"
className="fixed bottom-[120px] left-1/2 h-3 w-56 -translate-x-1/2 rounded-full bg-brand-100 shadow-inner"
title="栖息台(data-pet-perch)"
/>
......
......@@ -46,7 +46,7 @@ const VOICE_INPUT_ENABLED = false;
const EXAMPLES = [
'今日推荐:挑几个该优先跟进的患者,并说明理由',
'查一下患者孙柯的信息、就诊事实和画像',
'现在有哪些应治未治、值得召回的患者?',
'现在有哪些潜在治疗、值得召回的患者?',
];
// ── 工具中文元信息:助手用的是内部英文工具名,界面不展示工具卡,只把"正在做什么"
......@@ -80,13 +80,13 @@ function Markdown({ text }: { text: string }) {
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-slate-900">{children}</strong>,
a: ({ children, href }) => (
<a href={href} className="text-teal-700 underline" target="_blank" rel="noreferrer">
<a href={href} className="text-brand-700 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
hr: () => <hr className="my-3 border-slate-100" />,
blockquote: ({ children }) => (
<blockquote className="my-2 border-l-2 border-teal-300 bg-teal-50/40 py-1 pl-3 text-slate-600">
<blockquote className="my-2 border-l-2 border-brand-300 bg-brand-50/40 py-1 pl-3 text-slate-600">
{children}
</blockquote>
),
......@@ -265,7 +265,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
return (
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
<div className="flex items-center gap-1.5 border-b border-slate-100 bg-slate-50/60 px-3 py-1.5">
<LayoutTemplate className="h-3.5 w-3.5 text-teal-600" />
<LayoutTemplate className="h-3.5 w-3.5 text-brand-600" />
<span className="text-[11.5px] font-medium text-slate-500">{artifact.title || '可视化卡片'}</span>
<button
type="button"
......@@ -288,10 +288,10 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
<div className="absolute inset-0 bg-black/40" onClick={() => setExpanded(false)} />
<div className="relative z-10 flex h-[min(86vh,920px)] w-[min(980px,94vw)] flex-col overflow-hidden rounded-xl border border-slate-100 bg-white shadow-2xl">
<div className="flex flex-none items-center gap-1.5 border-b border-slate-100 bg-slate-50/60 px-3 py-2">
<LayoutTemplate className="h-4 w-4 text-teal-600" />
<LayoutTemplate className="h-4 w-4 text-brand-600" />
<span className="text-[12.5px] font-medium text-slate-600">{artifact.title || '可视化卡片'}</span>
{artifact.streaming && (
<span className="ml-1 inline-flex items-center gap-1 text-[10.5px] text-teal-600">
<span className="ml-1 inline-flex items-center gap-1 text-[10.5px] text-brand-600">
<Loader2 className="h-3 w-3 animate-spin" />
生成中
</span>
......@@ -327,7 +327,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
function GeneratingHint({ label = '生成中' }: { label?: string }) {
return (
<div className="flex items-center gap-1.5 text-[12px] text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-500" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-brand-500" />
<span>{label}</span>
</div>
);
......@@ -351,7 +351,7 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?:
last && last.kind === 'tool' && last.step.status === 'running' ? toolMeta(last.step.tool).running : '生成中';
return (
<div className="flex gap-2.5">
<span className="mt-0.5 inline-flex h-7 w-7 flex-none items-center justify-center rounded-full bg-teal-600 text-white">
<span className="mt-0.5 inline-flex h-7 w-7 flex-none items-center justify-center rounded-full bg-brand-600 text-white">
<Bot className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 space-y-2.5 pt-0.5">
......@@ -407,7 +407,7 @@ function ModelSelect({
}}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] hover:bg-slate-50',
m.value === model ? 'font-medium text-teal-700' : 'text-slate-700',
m.value === model ? 'font-medium text-brand-700' : 'text-slate-700',
)}
>
{m.value === model ? <Check className="h-3.5 w-3.5" /> : <span className="w-3.5" />}
......@@ -577,7 +577,7 @@ export function AssistantChat({
>
<span
className={cn(
'inline-flex flex-none items-center justify-center rounded-lg bg-teal-600 text-white',
'inline-flex flex-none items-center justify-center rounded-lg bg-brand-600 text-white',
isWidget ? 'h-6 w-6' : 'h-7 w-7',
)}
>
......@@ -617,7 +617,7 @@ export function AssistantChat({
<div className="mx-auto max-w-3xl space-y-5 px-4 py-5">
{messages.length === 0 ? (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center', isWidget ? 'py-8' : 'py-20')}>
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-teal-600 text-white">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-600 text-white">
<Bot className="h-6 w-6" />
</span>
<div className="max-w-md text-[13.5px] text-slate-500">
......@@ -629,7 +629,7 @@ export function AssistantChat({
key={ex}
type="button"
onClick={() => fire(ex)}
className="rounded-full border border-slate-100 bg-white px-3 py-1.5 text-[11.5px] text-slate-600 transition-colors hover:border-teal-300 hover:bg-teal-50/50 hover:text-teal-700"
className="rounded-full border border-slate-100 bg-white px-3 py-1.5 text-[11.5px] text-slate-600 transition-colors hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700"
>
{ex}
</button>
......@@ -651,7 +651,7 @@ export function AssistantChat({
{/* Composer */}
<div className="flex-none border-t border-slate-100 bg-white">
<div className="mx-auto max-w-3xl px-4 py-3">
<div className="flex items-end gap-2 rounded-xl border border-slate-100 bg-white px-3 py-2 transition-all focus-within:border-teal-300 focus-within:ring-2 focus-within:ring-teal-200">
<div className="flex items-end gap-2 rounded-xl border border-slate-100 bg-white px-3 py-2 transition-all focus-within:border-brand-300 focus-within:ring-2 focus-within:ring-brand-200">
<textarea
ref={taRef}
rows={1}
......@@ -679,7 +679,7 @@ export function AssistantChat({
'mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg transition-colors',
voice === 'live'
? 'animate-pulse bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-300'
: 'bg-slate-100 text-slate-500 hover:bg-teal-50 hover:text-teal-700',
: 'bg-slate-100 text-slate-500 hover:bg-brand-50 hover:text-brand-700',
)}
>
<Mic className="h-4 w-4" />
......@@ -702,7 +702,7 @@ export function AssistantChat({
title="发送"
className={cn(
'mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg transition-colors',
input.trim() ? 'bg-teal-600 text-white hover:bg-teal-700' : 'bg-slate-100 text-slate-400',
input.trim() ? 'bg-brand-600 text-white hover:bg-brand-700' : 'bg-slate-100 text-slate-400',
)}
>
<Send className="h-4 w-4" />
......
......@@ -23,7 +23,7 @@ export function IdentityCluster() {
{visibleClinics(user).length} 个诊所 · {roleNameZh(user?.role)}
</div>
</div>
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-brand-400 to-brand-600 text-[12px] font-bold text-white">
{name.charAt(0).toUpperCase()}
</span>
<button
......
......@@ -35,7 +35,7 @@ import { cn } from '@/lib/utils';
type BrandSlug = 'ruier' | 'ruitai';
const TENANTS: { slug: BrandSlug; nameZh: string; tone: string }[] = [
{ slug: 'ruier', nameZh: '瑞尔', tone: 'border-teal-200 bg-teal-50/40' },
{ slug: 'ruier', nameZh: '瑞尔', tone: 'border-brand-200 bg-brand-50/40' },
{ slug: 'ruitai', nameZh: '瑞泰', tone: 'border-sky-200 bg-sky-50/40' },
];
......@@ -161,7 +161,7 @@ export function MockLoginDialog({ open }: { open: boolean }) {
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-brand-600 text-[12px] font-bold text-white">
PAC
</span>
<span>快速登录</span>
......@@ -192,7 +192,7 @@ export function MockLoginDialog({ open }: { open: boolean }) {
className={cn(
'flex flex-col items-start rounded-t-md px-3 py-1.5 text-left transition-colors',
hostTab === t.key
? 'border-b-2 border-teal-600 font-semibold text-teal-700'
? 'border-b-2 border-brand-600 font-semibold text-brand-700'
: 'text-slate-500 hover:text-slate-800',
)}
>
......@@ -228,8 +228,8 @@ export function MockLoginDialog({ open }: { open: boolean }) {
className={cn(
'flex flex-col items-start gap-0.5 rounded-md border px-2.5 py-1.5 text-left transition-all',
role === r.key
? 'border-teal-400 bg-teal-50/60 ring-1 ring-teal-200'
: 'border-slate-100 hover:border-teal-300',
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-200'
: 'border-slate-100 hover:border-brand-300',
)}
>
<span className="text-[12.5px] font-semibold text-slate-800">{r.nameZh}</span>
......@@ -270,8 +270,8 @@ export function MockLoginDialog({ open }: { open: boolean }) {
className={cn(
'rounded-full border px-2.5 py-0.5 text-[11px] transition-colors',
tenantFilter === t.k
? 'border-teal-400 bg-teal-50 text-teal-700'
: 'border-slate-100 text-slate-500 hover:border-teal-300',
? 'border-brand-400 bg-brand-50 text-brand-700'
: 'border-slate-100 text-slate-500 hover:border-brand-300',
)}
>
{t.n}
......@@ -281,7 +281,7 @@ export function MockLoginDialog({ open }: { open: boolean }) {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜客服姓名…"
className="ml-auto w-40 rounded-md border border-slate-100 px-2.5 py-1 text-[12px] outline-none focus:border-teal-400"
className="ml-auto w-40 rounded-md border border-slate-100 px-2.5 py-1 text-[12px] outline-none focus:border-brand-400"
/>
</div>
......@@ -300,8 +300,8 @@ export function MockLoginDialog({ open }: { open: boolean }) {
className={cn(
'flex w-full items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-left transition-all',
loading
? 'border-teal-400 ring-2 ring-teal-200'
: 'border-slate-100 hover:border-teal-400 hover:bg-teal-50/40',
? 'border-brand-400 ring-2 ring-brand-200'
: 'border-slate-100 hover:border-brand-400 hover:bg-brand-50/40',
busy && !loading && 'opacity-50',
)}
>
......@@ -347,8 +347,8 @@ export function MockLoginDialog({ open }: { open: boolean }) {
className={cn(
'group flex flex-col items-start gap-1 rounded-md border bg-white px-3 py-2 text-left transition-all',
loading
? 'border-teal-400 ring-2 ring-teal-200'
: 'border-slate-100 hover:border-teal-400 hover:bg-teal-50/40',
? 'border-brand-400 ring-2 ring-brand-200'
: 'border-slate-100 hover:border-brand-400 hover:bg-brand-50/40',
busy && !loading && 'opacity-50',
)}
>
......@@ -429,8 +429,8 @@ function FridayPanel({
className={cn(
'flex flex-col items-start gap-0.5 rounded-md border px-2.5 py-1.5 text-left transition-all',
role === r.key
? 'border-teal-400 bg-teal-50/60 ring-1 ring-teal-200'
: 'border-slate-100 hover:border-teal-300',
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-200'
: 'border-slate-100 hover:border-brand-300',
)}
>
<span className="text-[12.5px] font-semibold text-slate-800">{r.nameZh}</span>
......@@ -465,7 +465,7 @@ function FridayPanel({
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜品牌 / 诊所名…"
className="mb-2 h-7 w-full rounded-md border border-slate-100 bg-slate-50 px-2 text-[12px] outline-none placeholder:text-slate-400 focus:border-teal-300 focus:bg-white"
className="mb-2 h-7 w-full rounded-md border border-slate-100 bg-slate-50 px-2 text-[12px] outline-none placeholder:text-slate-400 focus:border-brand-300 focus:bg-white"
/>
<div className="max-h-[280px] space-y-2 overflow-y-auto">
{filtered.length === 0 && (
......@@ -480,7 +480,7 @@ function FridayPanel({
type="button"
onClick={() => onPick(role, b.brandId)}
disabled={!!busy}
className="flex w-full items-center justify-between gap-2 rounded-t-md bg-slate-50/60 px-2.5 py-1.5 text-left transition-colors hover:bg-teal-50/60 disabled:opacity-50"
className="flex w-full items-center justify-between gap-2 rounded-t-md bg-slate-50/60 px-2.5 py-1.5 text-left transition-colors hover:bg-brand-50/60 disabled:opacity-50"
>
<span className="truncate text-[12.5px] font-semibold text-slate-800">{b.name}</span>
<span className="flex-none text-[10.5px] text-slate-500">
......@@ -495,7 +495,7 @@ function FridayPanel({
type="button"
onClick={() => onPick(role, c.clinicId)}
disabled={!!busy}
className="rounded-full border border-slate-100 px-2 py-0.5 text-[11px] text-slate-600 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700 disabled:opacity-50"
className="rounded-full border border-slate-100 px-2 py-0.5 text-[11px] text-slate-600 transition-colors hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 disabled:opacity-50"
>
{c.name}
</button>
......
......@@ -354,9 +354,9 @@ export function PetBody({
)}
{vetFluoride && (
<>
<line x1="45" y1="51" x2="37" y2="41" stroke="#0d9488" strokeWidth="2" strokeLinecap="round" />
<ellipse className="pac-vet-buzz" cx="35" cy="39" rx="2.4" ry="1.6" fill="#5eead4" stroke="#0d9488" strokeWidth="0.6" />
<g fill="#99f6e4" stroke="#5eead4" strokeWidth="0.4">
<line x1="45" y1="51" x2="37" y2="41" stroke="#0032A0" strokeWidth="2" strokeLinecap="round" />
<ellipse className="pac-vet-buzz" cx="35" cy="39" rx="2.4" ry="1.6" fill="#85A9FA" stroke="#0032A0" strokeWidth="0.6" />
<g fill="#BACFFD" stroke="#85A9FA" strokeWidth="0.4">
<circle className="pac-pet-rinse-b" cx="33" cy="36" r="1.1" />
<circle className="pac-pet-rinse-b" cx="37" cy="35" r="1.4" style={{ animationDelay: '0.5s' }} />
</g>
......@@ -366,7 +366,7 @@ export function PetBody({
)}
{/* 思考:头顶冒点点 */}
{thinking && (
<g fill="#0d9488">
<g fill="#0032A0">
<circle className="pac-pet-dot" cx="46" cy="12" r="2" style={{ animationDelay: '0s' }} />
<circle className="pac-pet-dot" cx="52" cy="8" r="2.6" style={{ animationDelay: '0.2s' }} />
<circle className="pac-pet-dot" cx="58" cy="3.5" r="3.2" style={{ animationDelay: '0.4s' }} />
......@@ -377,7 +377,7 @@ export function PetBody({
<>
<g className="pac-pet-scrub">
{/* 刷柄(品牌青) + 刷毛 */}
<rect x="22" y="50" width="20" height="3.6" rx="1.8" fill="#0d9488" transform="rotate(-8 32 52)" />
<rect x="22" y="50" width="20" height="3.6" rx="1.8" fill="#0032A0" transform="rotate(-8 32 52)" />
<rect x="38" y="45.5" width="7" height="6" rx="1.2" fill="#ffffff" stroke="#e2e8f0" strokeWidth="0.8" transform="rotate(-8 41 48)" />
</g>
<g fill="#a5f3fc" stroke="#67e8f9" strokeWidth="0.5">
......@@ -390,9 +390,9 @@ export function PetBody({
{/* 牙线自理:头顶拉一根牙线锯冠缝 */}
{flossing && (
<g className="pac-pet-floss">
<path d="M8 13 Q32 22 56 13" stroke="#5eead4" strokeWidth="1.6" fill="none" strokeLinecap="round" />
<circle cx="8" cy="13" r="2.2" fill="#0d9488" />
<circle cx="56" cy="13" r="2.2" fill="#0d9488" />
<path d="M8 13 Q32 22 56 13" stroke="#85A9FA" strokeWidth="1.6" fill="none" strokeLinecap="round" />
<circle cx="8" cy="13" r="2.2" fill="#0032A0" />
<circle cx="56" cy="13" r="2.2" fill="#0032A0" />
</g>
)}
{/* 闪亮微笑:牙面 ding 两颗星 */}
......@@ -428,7 +428,7 @@ export function PetBody({
)}
{/* 睡觉:Zzz */}
{sleeping && (
<g fill="#0d9488" fontWeight={700} fontFamily="ui-sans-serif">
<g fill="#0032A0" fontWeight={700} fontFamily="ui-sans-serif">
<text className="pac-pet-z" x="46" y="14" fontSize="9" style={{ animationDelay: '0s' }}>z</text>
<text className="pac-pet-z" x="52" y="8" fontSize="12" style={{ animationDelay: '0.6s' }}>Z</text>
</g>
......
......@@ -16,7 +16,7 @@ const EMPTY_SCRIPT = {
generatedAt: null,
sections: [
{ id: 'opening', label: '开场白', durationHint: '', markdown: '' },
{ id: 'informMissed', label: '告知应治未治', durationHint: '', markdown: '' },
{ id: 'informMissed', label: '告知潜在治疗', durationHint: '', markdown: '' },
{ id: 'reviewAdvice', label: '复查建议', durationHint: '', markdown: '' },
{ id: 'closing', label: '结束回访语', durationHint: '', markdown: '' },
],
......
......@@ -536,7 +536,7 @@ function ChainListItem({
const activeRing: Record<string, string> = {
rose: 'border-rose-400 bg-rose-50 ring-1 ring-rose-200',
amber: 'border-amber-400 bg-amber-50 ring-1 ring-amber-200',
sky: 'border-teal-400 bg-teal-50 ring-1 ring-teal-200',
sky: 'border-brand-400 bg-brand-50 ring-1 ring-brand-200',
emerald: 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200',
slate: 'border-slate-300 bg-slate-50 ring-1 ring-slate-200',
};
......
......@@ -85,14 +85,14 @@ export function CloseOpportunityDialog({
'flex items-center gap-2 rounded-md border px-3 py-2.5 text-left text-[12.5px] transition-colors',
r.key === 'other' && 'col-span-2',
active
? 'border-teal-400 bg-teal-50 font-medium text-teal-800'
: 'border-slate-200 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50/50',
? 'border-brand-400 bg-brand-50 font-medium text-brand-800'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand-300 hover:bg-brand-50/50',
)}
>
<span
className={cn(
'flex h-3.5 w-3.5 flex-none items-center justify-center rounded-[3px] border',
active ? 'border-teal-600 bg-teal-600' : 'border-slate-300',
active ? 'border-brand-600 bg-brand-600' : 'border-slate-300',
)}
>
{active && (
......@@ -124,14 +124,14 @@ export function CloseOpportunityDialog({
onChange={(e) => setNote(e.target.value)}
rows={3}
placeholder="请详细描述关闭机会的具体原因,以便后续分析和改进…"
className="w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-200 focus:border-teal-400"
className="w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-brand-200 focus:border-brand-400"
/>
</div>
)}
{selected.length > 0 && (
<div className="rounded-md border border-teal-100 bg-teal-50/60 px-3 py-2">
<div className="text-[11.5px] font-semibold text-teal-800">
<div className="rounded-md border border-brand-100 bg-brand-50/60 px-3 py-2">
<div className="text-[11.5px] font-semibold text-brand-800">
已选关闭原因 · {selected.length}
</div>
<ul className="mt-0.5 space-y-0.5">
......
......@@ -18,6 +18,7 @@ export function Drawer({
open,
onClose,
kind,
personaFocusKey,
chains,
persona,
summaries,
......@@ -33,6 +34,8 @@ export function Drawer({
open: boolean;
onClose: () => void;
kind: DrawerKind;
/** kind='persona' 时:打开后滚到这个标签并高亮(点身份卡首屏 chip 进来的)。null=看全量 */
personaFocusKey?: string | null;
chains: Chain[];
persona: { computedAt: Date; refreshedAt: Date; features: PersonaFeature[] };
summaries: {
......@@ -117,7 +120,7 @@ export function Drawer({
// 「更新于」用 refreshedAt(内容真实新鲜度):只有天数漂移时后端就地刷新、不升版本,
// computedAt 会停在版本创建那刻,拿它显示会让客服以为数据比实际更旧。
subtitle = `更新于 ${fmtRel(persona.refreshedAt)} · ${persona.features.length} 项画像`;
body = <PersonaDetailList features={persona.features} facts={facts} />;
body = <PersonaDetailList features={persona.features} facts={facts} focusKey={personaFocusKey} />;
}
return (
......@@ -336,14 +339,14 @@ function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
key={i}
className={cn(
'flex items-start gap-2.5 text-[12.5px] py-1 border-b border-slate-50 last:border-0',
latest && 'bg-teal-50/50 -mx-2 px-2 rounded',
latest && 'bg-brand-50/50 -mx-2 px-2 rounded',
)}
>
<span className={cn('flex-none mt-[5px] w-1.5 h-1.5 rounded-full', done ? 'bg-teal-400' : 'bg-slate-300')} />
<span className={cn('flex-none mt-[5px] w-1.5 h-1.5 rounded-full', done ? 'bg-brand-400' : 'bg-slate-300')} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
{latest && (
<span className="flex-none rounded bg-teal-600 px-1 py-px text-[10px] font-medium text-white">
<span className="flex-none rounded bg-brand-600 px-1 py-px text-[10px] font-medium text-white">
最近一次
</span>
)}
......@@ -353,7 +356,7 @@ function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
<span className="font-medium text-slate-700">{v.type ?? '回访'}</span>
{v.status && <span className="text-slate-400">· {v.status}</span>}
{v.taskDate && <span className="text-slate-400 tabular-nums">{v.taskDate}</span>}
{v.treatmentItems && <span className="text-teal-700/70">· {v.treatmentItems}</span>}
{v.treatmentItems && <span className="text-brand-700/70">· {v.treatmentItems}</span>}
</div>
{v.followContent && (
<div className="text-slate-600 leading-snug break-words mt-0.5">{v.followContent}</div>
......
......@@ -324,7 +324,7 @@ function EmrSection({
const tooth = String(tc.tooth_position ?? '');
return (
<li key={tx.id} className="text-[12px] text-slate-700 leading-relaxed">
<span className="px-1.5 py-px mr-1.5 bg-teal-50 text-teal-700 rounded text-[10.5px] font-medium">
<span className="px-1.5 py-px mr-1.5 bg-brand-50 text-brand-700 rounded text-[10.5px] font-medium">
{badgeText}
</span>
<span className="font-medium">{subtype}</span>
......
......@@ -47,7 +47,7 @@ export function factLabel(f: AdaptedFact): FactLabel {
const isPlanned = f.kind === 'planned';
return {
badge: isPlanned ? '计划' : catZh || '治疗',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-teal-50 text-teal-700',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-brand-50 text-brand-700',
text,
};
}
......@@ -95,7 +95,7 @@ export function factLabel(f: AdaptedFact): FactLabel {
// ⚠️ 下面三类的 f.title 都是 `<中文> <externalId>`(接诊 16432552 / 挂号 xxx / 前台接待 xxx)——
// 直接显示等于把宿主内部 ID 甩给客服。一律改取 content 里有语义的字段,取不到就只留中文词。
case 'encounter_record': {
const tone = { badge: '就诊', badgeTone: 'bg-teal-50 text-teal-700' };
const tone = { badge: '就诊', badgeTone: 'bg-brand-50 text-brand-700' };
const cc = String(c.chief_complaint ?? '').trim() || String(f.summary ?? '').trim();
const doc = String(c.doctor_name ?? '').trim();
if (cc) return { ...tone, text: cc, hover: doc ? `${cc}(${doc}医生)` : cc };
......@@ -105,10 +105,10 @@ export function factLabel(f: AdaptedFact): FactLabel {
const doc = String(c.doctor_name ?? '').trim();
const dept = String(c.department ?? c.department_name ?? '').trim();
const text = [dept, doc ? `${doc}医生` : ''].filter(Boolean).join(' · ') || '挂号';
return { badge: '挂号', badgeTone: 'bg-teal-50 text-teal-700', text };
return { badge: '挂号', badgeTone: 'bg-brand-50 text-brand-700', text };
}
case 'visit_reception_record':
return { badge: '接待', badgeTone: 'bg-teal-50 text-teal-700', text: '前台接待' };
return { badge: '接待', badgeTone: 'bg-brand-50 text-brand-700', text: '前台接待' };
case 'emr_record': {
const tone = { badge: '病历', badgeTone: 'bg-slate-100 text-slate-600' };
// ⚠️ **不能用 f.summary** —— emr parser 写进去的是 `关联接诊:<encounter_id>`(内部 ID),
......
......@@ -536,7 +536,7 @@ const TONE_FALLBACK: ToneCls = { bg: 'bg-slate-100', text: 'text-slate-700', bor
const TONE = {
rose: { bg: 'bg-rose-100', text: 'text-rose-700', border: 'border-rose-200' },
teal: { bg: 'bg-teal-100', text: 'text-teal-700', border: 'border-teal-200' },
brand: { bg: 'bg-brand-100', text: 'text-brand-700', border: 'border-brand-200' },
indigo: { bg: 'bg-indigo-100', text: 'text-indigo-700', border: 'border-indigo-200' },
slate: TONE_FALLBACK,
sky: { bg: 'bg-sky-100', text: 'text-sky-700', border: 'border-sky-200' },
......
......@@ -248,7 +248,7 @@ export const mockPersona = {
key: PersonaFeatureKey.LIFECYCLE_STAGE,
label: '生命周期',
value: '成熟客 · 末诊140天 · 就诊20次',
tone: 'teal',
tone: 'brand',
evidence: ['fact_visit_2026_04'],
data: { stage: 'mature', label: '成熟客', recencyDays: 140, totalVisits: 20 },
},
......@@ -349,7 +349,7 @@ export const mockPlan = {
{
id: 'rsn_1',
scenario: PlanScenario.TREATMENT_INITIATION_RECALL,
scenarioLabel: '应治未治',
scenarioLabel: '潜在治疗',
priorityScore: 92,
lifecycle: 'one_shot',
source: 'algorithm',
......@@ -459,7 +459,7 @@ export const mockScript = {
},
{
id: 'informMissed',
label: '告知应治未治',
label: '告知潜在治疗',
durationHint: '1 分钟',
markdown: `• 上次来检查的时候,李医生注意到您左下大牙有缺失牙的情况
• 缺牙的地方如果不处理,旁边的牙齿可能会慢慢歪掉,时间一长吃东西也不太舒服
......
......@@ -311,7 +311,7 @@ export function OutcomeForm({
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="患者反馈、关键点、需告知医生的信息……"
className="flex-1 min-h-[60px] px-2.5 py-1.5 rounded border border-slate-100 bg-white text-[12.5px] resize-none focus:outline-none focus:ring-2 focus:ring-teal-200 focus:border-teal-300"
className="flex-1 min-h-[60px] px-2.5 py-1.5 rounded border border-slate-100 bg-white text-[12.5px] resize-none focus:outline-none focus:ring-2 focus:ring-brand-200 focus:border-brand-300"
/>
</div>
......@@ -329,7 +329,7 @@ export function OutcomeForm({
submitted
? 'bg-emerald-600 text-white'
: canSubmit
? 'bg-teal-600 text-white hover:bg-teal-700'
? 'bg-brand-600 text-white hover:bg-brand-700'
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
)}
>
......
......@@ -51,12 +51,29 @@ function staleAnchorHint(feature: PersonaFeature): string | null {
export function PersonaDetailList({
features,
facts,
focusKey,
}: {
features: PersonaFeature[];
facts: AdaptedFact[];
/**
* 打开时要定位到的标签(身份卡首屏 chip 点进来带的)。
* 抽屉里十几张卡、要找的那张常在折叠线以下 —— 不滚过去等于让人自己翻,
* 那点开标签跟点「详情 →」就没区别了。
*/
focusKey?: string | null;
}) {
const factById = React.useMemo(() => new Map(facts.map((f) => [f.id, f])), [facts]);
// 滚到目标卡。⚠️ 必须等一帧:抽屉是本次渲染才挂上的,同步滚时容器还没有布局,scrollIntoView 是空操作。
const focusRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!focusKey) return;
const id = requestAnimationFrame(() =>
focusRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }),
);
return () => cancelAnimationFrame(id);
}, [focusKey]);
const grouped = React.useMemo(() => {
const sorted = [...features].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key);
......@@ -90,7 +107,13 @@ export function PersonaDetailList({
<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} />
<FeatureCard
key={f.key}
feature={f}
factById={factById}
focused={f.key === focusKey}
cardRef={f.key === focusKey ? focusRef : undefined}
/>
))}
</section>
))}
......@@ -101,9 +124,14 @@ export function PersonaDetailList({
function FeatureCard({
feature: f,
factById,
focused = false,
cardRef,
}: {
feature: PersonaFeature;
factById: Map<string, AdaptedFact>;
/** 从首屏 chip 点进来的那张 —— 描边加重,滚到中间后一眼能认出是哪张 */
focused?: boolean;
cardRef?: React.RefObject<HTMLDivElement | null>;
}) {
const [showAll, setShowAll] = React.useState(false);
const T = tone(f.tone);
......@@ -123,12 +151,18 @@ function FeatureCard({
const shown = showAll ? resolved : resolved.slice(0, MAX_EVIDENCE_ROWS);
return (
<div className="relative rounded-md border border-slate-100 p-3 pr-9">
<div
ref={cardRef}
className={cn(
'relative rounded-md border p-3 pr-9',
focused ? 'border-brand-300 bg-brand-50/40 ring-1 ring-brand-200' : 'border-slate-100',
)}
>
{/* 右上角 ? —— 真 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"
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-brand-500"
aria-label={`${f.label} 怎么算的`}
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
......@@ -177,7 +211,7 @@ function FeatureCard({
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="text-[10.5px] text-teal-700 hover:underline"
className="text-[10.5px] text-brand-700 hover:underline"
>
{showAll ? '收起' : `展开全部`}
</button>
......
......@@ -44,7 +44,7 @@ export function PersonaFeatureHover({
</div>
{/* 本患者实际取值 */}
{value && (
<div className="rounded bg-teal-50 px-2 py-1.5 text-[11px] leading-relaxed text-teal-800">
<div className="rounded bg-brand-50 px-2 py-1.5 text-[11px] leading-relaxed text-brand-800">
{value}
</div>
)}
......
......@@ -150,7 +150,7 @@ function IssueList({
<div className="font-medium text-slate-600">{it.section || `问题 ${i + 1}`}</div>
<div className="break-words text-slate-500">{it.problem}</div>
{it.fix && (
<div className="mt-0.5 break-words text-teal-600">改法:{it.fix}</div>
<div className="mt-0.5 break-words text-brand-600">改法:{it.fix}</div>
)}
</li>
))}
......
......@@ -27,7 +27,7 @@ export function ScriptMarkdown({ sections, streaming = false }: { sections: Scri
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-left hover:bg-slate-50"
>
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded text-[10.5px] flex items-center justify-center font-semibold bg-teal-50 text-teal-700">
<span className="w-5 h-5 rounded text-[10.5px] flex items-center justify-center font-semibold bg-brand-50 text-brand-700">
{idx + 1}
</span>
<span className={cn('text-[13px] font-semibold text-slate-900', streaming && 'shimmer-text')}>
......@@ -66,7 +66,7 @@ export function ScriptStepCards({ sections, streaming = false }: { sections: Scr
<div key={sec.id} className="rounded-md border border-slate-100 bg-white p-3 flex flex-col">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-teal-600 text-white text-[11px] font-semibold flex items-center justify-center">
<span className="w-6 h-6 rounded-full bg-brand-600 text-white text-[11px] font-semibold flex items-center justify-center">
{i + 1}
</span>
<span className={cn('text-[13px] font-semibold text-slate-900', streaming && 'shimmer-text')}>
......@@ -101,9 +101,9 @@ export function ScriptCopilot({ sections, streaming = false }: { sections: Scrip
className={cn(
'flex-1 min-w-0 text-[11px] font-medium px-2 py-1.5 rounded transition-colors flex items-center justify-center gap-1.5',
i === active
? 'bg-teal-600 text-white shadow-sm'
? 'bg-brand-600 text-white shadow-sm'
: i < active
? 'text-teal-700 hover:bg-white'
? 'text-brand-700 hover:bg-white'
: 'text-slate-500 hover:bg-white',
)}
>
......@@ -113,7 +113,7 @@ export function ScriptCopilot({ sections, streaming = false }: { sections: Scrip
i === active
? 'bg-white/20 text-white'
: i < active
? 'bg-teal-100 text-teal-700'
? 'bg-brand-100 text-brand-700'
: 'bg-slate-200 text-slate-500',
)}
>
......@@ -125,18 +125,18 @@ export function ScriptCopilot({ sections, streaming = false }: { sections: Scrip
</div>
{/* 当前段高亮 */}
<div className="rounded-md border-2 border-teal-200 bg-teal-50/30 p-4 ring-1 ring-teal-100">
<div className="rounded-md border-2 border-brand-200 bg-brand-50/30 p-4 ring-1 ring-brand-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal-500 animate-pulse" />
<span className="text-[11px] text-teal-700 font-semibold uppercase tracking-wide">当前段</span>
<span className="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
<span className="text-[11px] text-brand-700 font-semibold uppercase tracking-wide">当前段</span>
<span className={cn('text-[14px] font-semibold text-slate-900', streaming && 'shimmer-text')}>
{cur.label}
</span>
</div>
</div>
<MD text={cur.markdown} />
<div className="mt-3 flex items-center justify-between pt-2 border-t border-teal-100">
<div className="mt-3 flex items-center justify-between pt-2 border-t border-brand-100">
<button
disabled={active === 0}
onClick={() => setActive(Math.max(0, active - 1))}
......@@ -150,7 +150,7 @@ export function ScriptCopilot({ sections, streaming = false }: { sections: Scrip
<button
disabled={active === sections.length - 1}
onClick={() => setActive(Math.min(sections.length - 1, active + 1))}
className="text-[12px] text-teal-700 font-medium hover:text-teal-900 disabled:text-slate-300 inline-flex items-center gap-1"
className="text-[12px] text-brand-700 font-medium hover:text-brand-900 disabled:text-slate-300 inline-flex items-center gap-1"
>
下一段 →
</button>
......
......@@ -10,7 +10,7 @@ export const TONE: Record<
string,
{ bg: string; text: string; ring: string; dot: string }
> = {
teal: { bg: 'bg-teal-50', text: 'text-teal-700', ring: 'ring-teal-200', dot: 'bg-teal-500' },
brand: { bg: 'bg-brand-50', text: 'text-brand-700', ring: 'ring-brand-200', dot: 'bg-brand-500' },
indigo: { bg: 'bg-indigo-50', text: 'text-indigo-700', ring: 'ring-indigo-200', dot: 'bg-indigo-500' },
emerald: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' },
amber: { bg: 'bg-amber-50', text: 'text-amber-700', ring: 'ring-amber-200', dot: 'bg-amber-500' },
......@@ -25,7 +25,7 @@ export const tone = (t?: string) => TONE[t ?? 'slate'] ?? TONE.slate!;
// Chip
// ──────────────────────────────────────────
/**
* 语义色小标签 — tone: teal/slate/amber/rose/indigo/emerald/sky…,icon 可选,size: xs/sm。工作台高频原语。
* 语义色小标签 — tone: brand/slate/amber/rose/indigo/emerald/sky…,icon 可选,size: xs/sm。工作台高频原语。
*/
export function Chip({
children,
......@@ -230,7 +230,7 @@ export function MD({ text, className }: { text: string; className?: string }) {
out.push(
<blockquote
key={key++}
className="my-2 pl-3 border-l-2 border-teal-300 bg-teal-50/40 py-2 pr-2 rounded-r text-[13px] text-slate-800 leading-relaxed"
className="my-2 pl-3 border-l-2 border-brand-300 bg-brand-50/40 py-2 pr-2 rounded-r text-[13px] text-slate-800 leading-relaxed"
>
{block.map((b, k) => (
<p key={k} className="m-0" dangerouslySetInnerHTML={{ __html: inlineMD(b) }} />
......
......@@ -89,7 +89,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
<div className="hidden md:block fixed left-0 top-0 bottom-0 w-3 z-40" onMouseEnter={() => setHover(true)}>
{!open && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1">
<span className="block w-1 h-12 bg-teal-500/40 rounded-r-full hover:bg-teal-600 transition-colors" />
<span className="block w-1 h-12 bg-brand-500/40 rounded-r-full hover:bg-brand-600 transition-colors" />
<span className="text-[9px] text-slate-400 -rotate-90 origin-center mt-4 whitespace-nowrap tracking-widest pointer-events-none">
我的任务
</span>
......@@ -110,7 +110,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
<header className="flex-none px-3 pt-3 pb-2 border-b border-slate-100">
<div className="flex items-center justify-between gap-2 mb-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-teal-50 text-teal-700 ring-1 ring-teal-200 flex-none">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-brand-50 text-brand-700 ring-1 ring-brand-200 flex-none">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
......@@ -125,7 +125,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
title={pinned ? '取消固定' : '固定打开'}
className={cn(
'w-6 h-6 rounded inline-flex items-center justify-center transition-colors flex-none',
pinned ? 'bg-teal-600 text-white' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-700',
pinned ? 'bg-brand-600 text-white' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-700',
)}
>
<svg viewBox="0 0 24 24" className={cn('w-3.5 h-3.5 transition-transform', pinned && 'rotate-45')} fill="none" stroke="currentColor" strokeWidth="2">
......@@ -152,7 +152,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
placeholder="搜索 姓名 / 手机 / ID(已加载项)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-7 pr-2.5 py-1.5 rounded-md border border-slate-100 bg-slate-50 text-[12px] text-slate-800 focus:outline-none focus:ring-2 focus:ring-teal-200 focus:border-teal-300 focus:bg-white transition-all"
className="w-full pl-7 pr-2.5 py-1.5 rounded-md border border-slate-100 bg-slate-50 text-[12px] text-slate-800 focus:outline-none focus:ring-2 focus:ring-brand-200 focus:border-brand-300 focus:bg-white transition-all"
/>
</div>
<div className="grid grid-cols-3 gap-1">
......@@ -165,7 +165,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
onClick={() => setFilter(t.v)}
className={cn(
'px-1 py-1 rounded inline-flex items-center justify-center gap-1 text-[11px] transition-colors',
filter === t.v ? 'bg-teal-600 text-white font-semibold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200',
filter === t.v ? 'bg-brand-600 text-white font-semibold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200',
)}
>
<span>{t.label}</span>
......@@ -255,7 +255,7 @@ function TaskRow({
className={cn(
'w-full text-left rounded-md border px-2.5 py-2 transition-colors',
active
? 'border-teal-400 bg-teal-50/60 ring-1 ring-teal-100'
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-100'
: 'border-slate-100 bg-white hover:border-slate-300 hover:bg-slate-50',
done && 'opacity-70',
)}
......@@ -280,7 +280,7 @@ function TaskRow({
</>
)}
</div>
{active && <div className="mt-1 text-[10px] text-teal-700 font-medium">● 当前查看</div>}
{active && <div className="mt-1 text-[10px] text-brand-700 font-medium">● 当前查看</div>}
</button>
</li>
);
......
......@@ -146,9 +146,9 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
<ToothFactRow key={`${k}-${f.id}`} fact={f} />
))}
{!isWhole && bridgeNote.has(k) && (
<div className="flex items-baseline gap-2 px-2.5 py-1.5 text-[12px] bg-teal-50/40">
<div className="flex items-baseline gap-2 px-2.5 py-1.5 text-[12px] bg-brand-50/40">
<span className="w-[74px] flex-none" />
<span className="flex-none px-1.5 py-px rounded text-[10px] font-medium bg-teal-50 text-teal-700">
<span className="flex-none px-1.5 py-px rounded text-[10px] font-medium bg-brand-50 text-brand-700">
桥体
</span>
<span className="flex-1 min-w-0 text-slate-600 leading-snug">
......
......@@ -71,7 +71,7 @@ export interface UseScriptStream {
// 2026-06 重构:4 模块 — opening / informMissed / reviewAdvice / closing
const LABEL_FALLBACK: Record<FixedSectionId, string> = {
opening: '开场白',
informMissed: '告知应治未治',
informMissed: '告知潜在治疗',
reviewAdvice: '复查建议',
closing: '结束回访语',
};
......
......@@ -31,6 +31,9 @@ export const plansApi = {
},
}),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
/** KPI / Tab badge 聚合计数 — 一次拉齐(替代之前 5 个并发 list 请求)*/
counts: () => api.get<PlanCountsResponse>('/pac/v1/plans/counts'),
......
......@@ -52,7 +52,7 @@ export function AudioVisualizer({
ref={(el) => {
barsRef.current[i] = el;
}}
className="w-[3px] rounded-full bg-teal-500/90 transition-[height] duration-75 ease-out"
className="w-[3px] rounded-full bg-brand-500/90 transition-[height] duration-75 ease-out"
style={{ height: `${MIN_H}px` }}
/>
))}
......
......@@ -13,7 +13,7 @@ export function RealtimeCoachButton({ active, onClick }: { active: boolean; onCl
'cursor-pointer active:scale-95',
active
? 'bg-rose-500 hover:bg-rose-600 animate-coachPulse'
: 'bg-teal-600 hover:bg-teal-700',
: 'bg-brand-600 hover:bg-brand-700',
)}
>
{active ? (
......
......@@ -37,7 +37,7 @@ export interface ButtonProps
}
/**
* 主操作按钮 — variant: default/secondary/outline/ghost/destructive/link,size: sm/default/lg/icon;teal 主色
* 主操作按钮 — variant: default/secondary/outline/ghost/destructive/link,size: sm/default/lg/icon;PAC 品牌色(brand-600 / #0032A0)
* @category actions
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
......
......@@ -19,7 +19,7 @@ const zhFormatters = {
};
/// shadcn 官方 Calendar(react-day-picker v10)—— 全 Tailwind classNames,不引 rdp 默认样式表。
/// 主题走项目 CSS 变量(--primary=teal-600 / --accent / --ring),故选中/今天/焦点自动是 teal
/// 主题走项目 CSS 变量(--primary=brand-600 / --accent / --ring),故选中/今天/焦点自动是品牌色
/// 支持 captionLayout="dropdown"(月/年下拉)与默认 label(左右翻页箭头)两种 caption。
/**
* 日历(react-day-picker 封装)— 单选/范围由 mode 决定。
......
......@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
* Input — shadcn-new-york 标准 input,统一全站输入框样式。
*
* 风格:无 shadow(扁平),border-input(82% L,1px 视觉扎实),
* hover 加深 border,focus 切 ring(跟 primary 同色 = teal)。
* hover 加深 border,focus 切 ring(跟 primary 同色 = 品牌色)。
*
* 变体通过 className 覆盖:
* <Input className="font-mono text-xs" placeholder="https://..." />
......
......@@ -37,3 +37,19 @@ export function resolveActionUrl(
const tpl = actionTemplate(key);
return tpl ? fillActionUrl(tpl, ctx) : undefined;
}
/**
* ⭐ 宿主槽位跳转一律**新开标签页** —— 所有槽位统一,别再各写各的 target。
*
* 2026-07-29 业务约定:客户档案 / 病历 / 预约等宿主页面「打开新页,不在当前页面做跳转」。
* 原来用的是 `_top`:PAC 嵌在宿主 iframe 里时会把**整个宿主页顶掉**,客服看完档案回不到
* 刚才的工单(召回上下文、已填的通话结果全丢)。新标签页则两边都在。
*
* 用 a 标签的地方用 HOST_LINK_PROPS,用 JS 派发的地方用 openHostUrl(),两条路同一口径。
* `noopener`:新页拿不到 window.opener,防宿主页被反向导航(tabnabbing)。
*/
export const HOST_LINK_PROPS = { target: '_blank', rel: 'noopener noreferrer' } as const;
export function openHostUrl(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer');
}
......@@ -2,7 +2,7 @@
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store';
import { fillActionUrl } from '@/lib/action-url';
import { fillActionUrl, openHostUrl } from '@/lib/action-url';
/**
* 宿主动作派发(潜在治疗 / 回访)—— 模式由 actionUrls[key] 的值形态决定:
......@@ -63,7 +63,7 @@ export function openHostAction(
return false;
}
if (mode === 'url') {
window.open(fillActionUrl(raw, ctx), '_top');
openHostUrl(fillActionUrl(raw, ctx));
return true;
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
......
......@@ -5,4 +5,5 @@ export * from './labels';
export * from './canonical-codes';
export * from './persona-feature-specs';
export * from './clinical-signals';
export * from './visit-recency';
export * from './persona-tag-filters';
......@@ -6,11 +6,11 @@
import { PersonaFeatureKey, PlanScenario } from './enums';
// ─────────────────────────────────────────────────────────
// Tone — UI 色板键(对应前端 Tailwind 色板 teal/indigo/...)
// Tone — UI 色板键(brand=PAC 主色,其余对应 Tailwind 色板 indigo/amber/...)
// ─────────────────────────────────────────────────────────
export type Tone =
| 'teal'
| 'brand'
| 'indigo'
| 'emerald'
| 'amber'
......@@ -25,7 +25,7 @@ export type Tone =
export const PLAN_SCENARIO_LABEL: Record<string, string> = {
[PlanScenario.TREATMENT_AFTERCARE_RECALL]: '疗效保障',
[PlanScenario.TREATMENT_INITIATION_RECALL]: '应治未治',
[PlanScenario.TREATMENT_INITIATION_RECALL]: '潜在治疗',
treatment_chain_disruption_recall: '链中断', // 待业务拍板,暂未入 enum
missed_diagnosis_recall: '漏诊召回', // W6+ 影像 AI
};
......@@ -49,7 +49,7 @@ export const planScenarioLabel = (key: string): string =>
* rose ⚠️ 警示 —— 看到要小心/要避让(禁忌、爽约、敏感、急迫)
* amber ⏳ 机会 —— 有事可做(潜在治疗、治疗史这类可切入点)
* indigo 💰 价值 —— 钱与权益
* teal 📈 阶段 —— 客户所处位置
* brand 📈 阶段 —— 客户所处位置
* sky/slate 基础属性 —— 不需要判断,只是背景信息
*/
export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> = {
......@@ -66,8 +66,8 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.ENTITLEMENT_STATUS]: { label: '权益身份', tone: 'indigo' },
[PersonaFeatureKey.DISCOUNT_ANCHOR]: { label: '折扣锚点', tone: 'indigo' },
// 📈 阶段与关系(转介绍达人是正面信息,同理不该标红)
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' },
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'teal' },
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'brand' },
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'brand' },
// 基础属性
[PersonaFeatureKey.TIME_PREFERENCE]: { label: '时间偏好', tone: 'sky' },
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' },
......@@ -78,3 +78,30 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
export const personaFeatureMeta = (key: string): { label: string; tone: Tone } =>
PERSONA_FEATURE_META[key] ?? { label: key, tone: 'slate' };
/**
* 潜在治疗 —— **卡片展示用**中文(与画像内部存的 `潜在种植` 等不同)。
*
* 为什么单独一份字典、而不是直接用 persona data 里的 labels:
* labels 是**算画像那一刻写死进 JSON 的**,改措辞要全量重算画像才生效(百万级,几小时)。
* 卡片只拿 code(implant/perio/…)、中文在这里查表 → 改词即时生效、且全站一处。
*
* 措辞对齐业务口径(2026-07-29):统一「XX治疗」,filling 用「充填治疗」不用「补牙治疗」,
* early_ortho 用「早期矫治」(「早矫治疗」不通)。
* code 来源:potential-treatment.feature 的 classifyGapToLabel,改那边要同步这里。
*/
export const POTENTIAL_TREATMENT_CARD_LABEL: Record<string, string> = {
implant: '种植治疗',
ortho: '正畸治疗',
early_ortho: '早期矫治',
endo: '根管治疗',
perio: '牙周治疗',
filling: '充填治疗',
restoration: '修复治疗',
extraction: '拔牙治疗',
};
/// code → 卡片中文;未知 code 原样返回(宁可露出生码,也不静默吞掉一个标签)
export function potentialTreatmentCardLabel(code: string): string {
return POTENTIAL_TREATMENT_CARD_LABEL[code] ?? code;
}
......@@ -31,6 +31,50 @@ export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> =
export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic'];
/**
* ⭐ 身份卡「关键标签」—— 患者姓名下面直接露出的那几个,不是全部 16 个。
*
* 业务口径(2026-07-29):客服扫一眼就能判断「这通电话能不能打、该聊什么」的才算关键。
* 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 ——
* 这里取舍的是**首屏**,不是信息。
*
* ⭐ **数组顺序 = 首屏展示顺序**(业务定序,2026-07-29),不走 personaFeatureSortKey ——
* 抽屉那套序是按"跟进要点 / 价值与阶段 / 基础属性"分组的,首屏没有分组标题,
* 照那个序排出来客服看不出章法。这里是业务自己排的一条线:
* 做过什么 → 还差什么 → 这人什么档位 / 走到哪一步 → 能怎么付 / 谈价底线 →
* 怎么来的 → 最后两颗是风险(禁忌 / 特别关注)。
*
* 急迫等级不进首屏:顶栏「优先级」条已经表达同一件事(急迫是它的最大权重项),
* 同屏两处讲一件事,客服会以为是两个指标。
*
* 没进来的:时间偏好 / 治疗敏感(通话**中**才用得上,不是扫一眼要的);
* 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人
* (有价值但不改变这通电话怎么开场)。
*
* ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?加了要顺手排进上面那条线里。
*/
export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
'treatment_history',
'potential_treatment',
'rfm',
'lifecycle_stage',
'entitlement_status',
'discount_anchor',
'acquisition_channel',
'contraindication',
'special_attention',
];
/** 该标签是否进身份卡首屏(未登记的一律不进 —— 新标签得显式选进来) */
export const isKeyPersonaFeature = (key: string): boolean =>
PERSONA_KEY_FEATURE_KEYS.includes(key);
/** 首屏排序键 = 白名单下标;不在首屏的排最后(理论上不会被问到) */
export const personaKeyFeatureOrder = (key: string): number => {
const i = PERSONA_KEY_FEATURE_KEYS.indexOf(key);
return i < 0 ? PERSONA_KEY_FEATURE_KEYS.length : i;
};
/**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
*
* 为什么放在标签卡里而不是前端:原先前端 persona-feature-hover.tsx 自己硬编了一份
......
......@@ -163,6 +163,20 @@ export const PlanPatientBriefSchema = z.object({
phoneVerified: z.boolean().optional(),
gender: z.string().nullable(),
age: z.number().int().nullable(),
/// 病历号(客服口头核对身份用的档案号)。宿主可能不给 —— jvs-dw 覆盖 91%、friday 63%。
medicalRecordNumber: z.string().nullable(),
/// 最近一次到诊日期(YYYY-MM-DD)。口径同详情页 lastVisit:encounter_record ∪ emr_record
/// 取 occurred_at 最大的那次(部分宿主 appointment.in_time 缺失、只有 EMR,故必须并集)。
lastVisitAt: z.string().nullable(),
/// **那一次**的接诊医生姓名(不是"历史最常见医生")—— 日期与医生同源于同一次就诊,
/// 否则会出现"日期是A次、医生是B次"的错配。doctor_name 只有 emr_record 带
/// (encounter_record 实测 0% 有名),故同日并列时优先取 emr。取不到 → null,前端不显示。
lastVisitDoctor: z.string().nullable(),
/// 潜在治疗 **code**(画像 potential_treatment.types,如 implant / perio)。
/// 刻意给 code 不给中文:中文在前端查 POTENTIAL_TREATMENT_CARD_LABEL,改措辞即时生效;
/// 若透画像里存的 labels,改词就得全量重算画像才看得到。
/// 无画像 / 无该特征 → 空数组。
potentialTreatments: z.array(z.string()),
});
export type PlanPatientBrief = z.infer<typeof PlanPatientBriefSchema>;
......
/**
* 上次到诊的时间分档 —— 圈人筛选的「上次时间」维度取值。
*
* 业务口径(2026-07-29):0-3 / 3-6 / 6-12 / 12-18 / 18 个月以上。
* 边界天数在 visit-recency.feature.ts 的 BUCKET_EDGES,两处要一起改。
*
* ⚠️ 与「生命周期」(潜客/新客/…/流失客)信息高度重叠 —— 后者本质也是 recency 分档,
* 只是掺了首诊时间与就诊次数。业务这次要下线生命周期、改用这个更直白的口径。
* 两者并存时别让客服以为是两件事。
*/
export const VISIT_RECENCY_BUCKETS = [
{ value: '0_3m', zh: '0-3 个月', hint: '近三个月来过' },
{ value: '3_6m', zh: '3-6 个月', hint: '上次到诊在 3 到 6 个月前' },
{ value: '6_12m', zh: '6-12 个月', hint: '上次到诊在半年到一年前' },
{ value: '12_18m', zh: '12-18 个月', hint: '上次到诊在一年到一年半前' },
{ value: '18m_plus', zh: '18 个月以上', hint: '一年半以上没来过' },
] as const;
/**
* 分档 code → `last_visit_at` 的日期区间(半开区间 [gte, lt))。
*
* ⭐ 现算而不是存死分档:存 bucket 会随时间过期(今天 2 个月、下个月就 3 个月),
* 得配一整套「到期重算」机械;存日期 + 查询时现算区间,永远准,零维护。
* 未知 code → null(调用方丢弃该条,不产生"筛了个寂寞"的全集查询)。
*/
export function visitRecencyRange(
bucket: string,
now: Date,
): { gte?: Date; lt?: Date } | null {
const daysAgo = (d: number) => new Date(now.getTime() - d * 86400_000);
switch (bucket) {
case '0_3m':
return { gte: daysAgo(90) };
case '3_6m':
return { gte: daysAgo(180), lt: daysAgo(90) };
case '6_12m':
return { gte: daysAgo(365), lt: daysAgo(180) };
case '12_18m':
return { gte: daysAgo(547), lt: daysAgo(365) }; // 18 个月 ≈ 547 天
case '18m_plus':
return { lt: daysAgo(547) };
default:
return null;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment