Commit f634f274 by luoqi

feat(recall): 历史联系摘要突出「最近一次」+ 抽屉标注最近一次/未到期

业务可用性测试 Issue 2「历史联系记录缺具体内容」(患者余同学 BJ0L028292):
  [被测者]「说了常规的提醒没有特殊的」
  [主持人]「那就是暂时没有办法详细的看到具体一次沟通什么内容」

## 先查源:PAC 没有藏数据,源侧确实只有标签

DW 直查 fact_returnvisit_out(customer_id=1683102 brand=瑞尔),该患者 5 条:

  2026-05-20 常规回访 未回访/未完成 内容「复查洁牙」  结果 null
  2026-02-25 常规回访 未回访/未完成 内容「方案跟进」  结果 null
  2026-02-23 常规回访 已回访/已预约 内容 null        结果「复查」
  2025-11-16 常规回访 未回访/未完成 内容「半年复查」  结果 null
  2025-08-17 常规回访 已回访/已完成 内容 null        结果「复查」

PAC 库里 5 条一条不差,7 个业务字段全部已在抽屉展示 —— **没有少展示任何东西**。

️ 排查中的一个坑,顺手确认了不是 bug:DW 直查会返回 15 条,多出的 10 条 brand=瑞泰。
   patient_id 跨品牌复用(374 万 id 中 196 万重号),1683102 在瑞尔=余继来(9岁,
   BJ0L028292)、在瑞泰=宋可超(33岁,QD0D020738),是两个人。摄入按 (patient_id, brand)
   复合键切分,各归各家,正确。

全库口径(生产 165.2 万条回访):
  follow_content 填充 51.0%(均 12.4 字)· return_visit_result 填充 67.4%(均 9.1 字)
  取值 TOP:「种植潜客」8.6万「提醒洗牙」3.9万「无不适」8.2万「已约」3.5万「未接」1.2万

即业务想要的三样东西,源里的实际情况是:
  · 沟通结论 / 患者态度 → **只有粗粒度标签**(无不适/已约/未接/挂断/拒接),没有原话
  · 下次建议联系时间   → **完全没有**(个别混在 result 自由文本里,如「7月份再联系」)
  · 逐字沟通内容       → **没有**;dw_group 全库只有 fact_returnvisit_out 一张联系表

→ 结论:摘要说「常规的提醒」是**如实转述**,不是 PAC 概括丢了信息。PAC 不造数据。

## 再改:业务关心「最近一次」这条建议成立,采纳

列表按日期倒序时头部常是**还没到日子的排程**,客服最想知道的"上次聊到哪儿了"
反而被埋在中间 —— 这是真问题,跟"源里没内容"是两回事。

摘要侧(promptVersion → @2026-07-26-f):
  · orchestrator 程序判定 isLatestPast(第一条有日期且已到期的),同 isFuture 的纪律
    —— 哪条是"最近一次"是排序问题,不交给 LLM 比日期
  · prompt 渲染【最近一次·已发生】标记 + 要求摘要开头交代它(时间+事由+结果)
  · 新增「数据边界」硬约束段:明写源里只有标签、没有原话和下次时间,
    没记的一个字都不许补,禁止输出"患者表示…"这类原话式表述
  · 最近一次那条正文预算 40 → 160 字(源里偶有几百字的方案沟通记录,
    一刀切 40 字会把唯一一条有信息量的记录砍没)
  · 它没记结果时显式渲染「结果:未记录」—— 不写模型会拿别条的结果脑补这条

抽屉侧:最近一次加 teal 角标 + 底色,未到期的加「未到期」灰标;
最近一次没结果时明写「结果:宿主未记录」(空着会被当成 PAC 没展示,正是这次的误会来源)。

顺带两个同源 bug:
  · fallback()(LLM 失败时给客服看的兜底句)还在拿 items[0] 当"最近一次",
    等于把 2026-07 那次未来排程事故在兜底路径上原样复刻 → 改走 isLatestPast
  · returnVisits 的 orderBy taskDate desc 在 Postgres 默认 NULLS FIRST,
    无日期的记录会顶到列表最前冒充"最近一次",还吃掉 take 名额 → nulls:'last'
    (摘要 take:12 与聚合 take:100 两处都改,口径一致)

## 本地验证
- 469 单测通过(本文件 13 → 24,新增「最近一次」13 例,含余同学生产原始形态回放)
- service / web tsc 干净
- 吕学文(BJ0U017795,4 条历史 + 1 条 2026-10-09 未来排程)清缓存连跑 3 次稳定:
    「最近一次4月4日治疗后回访已完成;此前种植咨询未回访,已排10月9日复查洁牙邀约。」
  李石明(BJ0U017487,最近一次无结果)同样 3 次稳定:
    「最近一次4月种植咨询回访未执行、未留结果;此前3次常规回访均已约到诊复查…」
  —— 开头讲最近一次、没记结果如实说、未来排程仍说"已排"不说"漏做",三个方向都对
- 抽屉截图确认:2026-10-09 带「未到期」灰标,2026-04-04 带「最近一次」角标+底色

## 待定(本次不做,需业务决定)
- DW 有两列我们没摄:task_director(回访负责人姓名,「上次是谁跟的」)、
  appointment_id(该次回访是否真开出预约 —— 比 result 文本硬的结果证据)。
  加这两列要 migration + 165 万行 reparse,先记着。
- fact_complain_out(8.15 万条投诉)整张表未摄入,是目前唯一没用的患者态度硬信号。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent eb4d01c6
......@@ -22,13 +22,22 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftRecallSummaryOutput>> = [
},
];
/** LLM 失败 / safety 拒收时:用记录拼一句最朴素的概述。 */
/**
* LLM 失败 / safety 拒收时:用记录拼一句最朴素的概述。
*
* ⭐「最近一次」必须取 isLatestPast 那条,**不能**取 items[0] —— items 按日期倒序,
* 头部很可能是尚未到期的未来排程,拿它当"最近一次"就是 2026-07 那次生产事故的原样复刻
* (兜底文案同样会展示给客服)。全是未来排程时不提"最近一次"。
*/
function fallback(input: DraftRecallSummaryInput): DraftRecallSummaryOutput {
const n = input.items.length;
if (n === 0) return { summary: '暂无历史联系记录。' };
const top = input.items[0]!;
const top2 = [top.type, top.status].filter(Boolean).join(' · ') || '回访';
return { summary: `共 ${n} 条历史联系,最近一次:${top.taskDate ?? '—'} ${top2}` };
const latest = input.items.find((it) => it.isLatestPast);
if (!latest) return { summary: `共 ${n} 条历史联系,均为尚未到期的排程。` };
const what = [latest.type, latest.status].filter(Boolean).join(' · ') || '回访';
const outcome = latest.result?.trim() || latest.followContent?.trim();
const tail = outcome ? `,${outcome.slice(0, 20)}` : ',未留结果';
return { summary: `共 ${n} 条历史联系,最近一次:${latest.taskDate ?? '—'} ${what}${tail}` };
}
@Injectable()
......
......@@ -27,6 +27,16 @@ export interface DraftRecallSummaryInput {
* 934 条已生成摘要里 196 条最新回访是未来排程、其中 158 条带"未执行/补做"措辞。
*/
isFuture: boolean;
/**
* ⭐ 该条是否为**最近一次真正发生过的联系**(有日期、且已到期的第一条)。
* 全列表里**至多一条**为 true;全是未来排程 / 全无日期时一条都没有。
* 同 isFuture 一样由 orchestrator 程序判定 —— 「哪条最新」是排序问题不是语言问题。
*
* 为什么要单独标:业务用户看历史联系时第一诉求就是"上次联系聊了什么"
* (2026-07 可用性测试原话:"暂时没有办法详细的看到具体一次沟通什么内容"),
* 摘要必须先回答这一条,再讲整体趋势。
*/
isLatestPast: boolean;
treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果
......@@ -34,6 +44,6 @@ export interface DraftRecallSummaryInput {
}
export interface DraftRecallSummaryOutput {
/** 一句话历史联系摘要(中文,≤60 字) */
/** 一句话历史联系摘要(中文,≤70 字;先说最近一次,再说整体) */
summary: string;
}
......@@ -5,40 +5,54 @@ import type { DraftRecallSummaryInput } from './input.types';
* 2026-06-16-d 初版(仅 taskDate/type/status/内容/结果)
* 2026-07-24-e ⭐ 加今日锚点 + 程序判定的「未来排程」标记 + taskStatus。
* 修生产事故:未来排程被写成"最新一次未执行,需优先补做"。
* 2026-07-26-f ⭐ 摘要先说「最近一次」(程序标注),再讲整体;并明写"源系统只记事由/
* 结果标签、没有逐字沟通内容",没记就说没记,禁止补写沟通细节。
* 来源:业务可用性测试「暂时没有办法详细的看到具体一次沟通什么内容」。
*/
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-24-e';
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-26-f';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
# 关键要求
1. 输出中文,不要换行/列表/Markdown。
2. **是"重点提炼"不是"总结罗列"**:抓最值得注意的那一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进""术后回访已完成、可推新项目"。**不要**把所有回访类型都列出来。
3. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。
4. 客观,**禁止**承诺疗效、编造、给医疗建议。
5. 严格按 JSON schema 输出,只有一个 key:summary。
1. 输出中文,不要换行/列表/Markdown,总长 ≤70 字。
2. **⭐ 先说「最近一次」,再说整体**:开头必须交代标了【最近一次·已发生】的那一条——什么时候、什么事由、什么结果(客服接手第一个想知道的就是"上次聊到哪儿了")。其余记录压成半句趋势即可。没有任何【最近一次·已发生】条目时(全是未来排程/无日期),直接讲整体,不要硬凑。
3. **是"重点提炼"不是"总结罗列"**:整体那半句抓最值得注意的一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进"。**不要**把所有回访类型都列出来。
4. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。
5. 客观,**禁止**承诺疗效、编造、给医疗建议。
6. 严格按 JSON schema 输出,只有一个 key:summary。
# ⭐ 数据边界(硬约束,违反即错)
源系统的回访记录**只有事由标签和结果标签**(如"复查洁牙""方案跟进""无不适""未接""已约"),
**没有逐字沟通内容、没有患者原话、没有下次建议联系时间**。
- 只能复述记录里**写着的**字;记录里没有的,一个字都不要补。
- 某条只有事由没有结果(常见)→ 如实说"未留结果 / 结果未记录",**不要**推断当时聊了什么、患者什么态度。
- **禁止**输出"患者表示…""沟通中提到…"这类原话式表述,除非结果字段里原文就这么写。
# ⭐ 时间纪律(硬约束,违反即错)
记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。
- 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"——
它只是**还没到时间**,不是任何人的疏漏。需要提及时只能说"已排 X 月 X 日复查洁牙"这类中性表述。
- 只有标了【已过期·未执行】的条目才算真正漏做。
- "最近一次""最新一次"这类说法**只能指已过期的条目**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期**已经替你算好了**,直接用标记,不要自己比日期。
- "最近一次""最新一次"这类说法**只能指标了【最近一次·已发生】的那一条**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期、哪条是最近一次**已经替你算好了**,直接用标记,不要自己比日期。
# 示例(风格参考,不要照抄)
- "近 3 次活动邀约均未接通,建议换企微触达。"
- "术后回访已完成,有种植意向待跟进。"
- "半年内 5 次回访无到诊,流失风险高。"
- "4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`;
- "最近一次 4 月术后回访已完成、无不适;近 3 次活动邀约均未接通,建议换企微触达。"
- "最近一次 2 月复查已约到诊;此前半年 5 次回访无到诊,流失风险仍高。"
- "最近一次 5 月复查洁牙提醒未执行、未留结果;近一年 3 次回访均未跟进到位。" ← 只有事由没结果的正确写法
- "最近一次 4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines =
input.items.length > 0
? input.items
.map((it, i) => {
// 未来 / 过期 由 orchestrator 程序判定后传入(见 input.types.isFuture 注释),
// 这里只负责渲染成模型看得懂的显式标记 —— LLM 不做日期比较。
const when = it.isFuture ? '【未来排程·尚未到期】' : null;
// 未来 / 过期 / 最近一次 由 orchestrator 程序判定后传入(见 input.types 注释),
// 这里只负责渲染成模型看得懂的显式标记 —— LLM 不做日期比较、不做排序。
const when = it.isFuture ? '【未来排程·尚未到期】' : it.isLatestPast ? '【最近一次·已发生】' : null;
// 最近一次要展开讲,给它更长的正文预算;其余条目只是背景趋势,40 字够了。
// (源里偶有几百字的方案沟通记录,一刀切 40 字会把唯一一条有信息量的记录砍没)
const budget = it.isLatestPast ? 160 : 40;
const parts = [
it.taskDate ?? '—',
when,
......@@ -47,8 +61,10 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
// 「已预约」「创建新回访」跟「未完成」语义差很远,漏了会被当成没做
it.taskStatus ? `任务:${it.taskStatus}` : null,
it.treatmentItems ? `项目:${it.treatmentItems}` : null,
it.followContent ? `内容:${it.followContent.slice(0, 40)}` : null,
it.result ? `结果:${it.result.slice(0, 40)}` : null,
it.followContent ? `内容:${it.followContent.slice(0, budget)}` : null,
it.result ? `结果:${it.result.slice(0, budget)}` : null,
// ⭐ 显式说"这条没记结果",否则模型倾向于用别条的结果脑补这条
it.isLatestPast && !it.result ? '结果:未记录' : null,
].filter(Boolean);
return `${i + 1}. ${parts.join(' · ')}`;
})
......@@ -59,11 +75,16 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
const futureNote = futureCount
? `\n⚠️ 其中 ${futureCount} 条是**尚未到期的未来排程**(已标注),不得表述为未执行/漏做/需补做。`
: '';
const latestNote = input.items.some((it) => it.isLatestPast)
? '\n⭐ 标了【最近一次·已发生】的那条要放在摘要开头讲清楚(时间 + 事由 + 结果);它没记结果就说未留结果,不要脑补。'
: input.items.length > 0
? '\n⭐ 本列表**没有已发生的回访**(全是未来排程 / 无日期),不要说"最近一次…",直接讲整体。'
: '';
return `今天:${input.today}
患者:${input.patientNameMasked}
历史联系 / 回访记录(时间倒序,共 ${input.items.length} 条):
${lines}${futureNote}
${lines}${futureNote}${latestNote}
请概括以上历史联系的重点。`;
}
......@@ -57,7 +57,9 @@ export class RecallSummaryOrchestrator {
// 2) 取历史联系(诊所回访 patient_return_visits,patient 级,最近 12 条)
const visits = await this.prisma.patientReturnVisit.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: plan.patientId },
orderBy: { taskDate: 'desc' },
// nulls:'last' 是刻意的 —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会占住
// 列表头部(既冒充"最近一次",又吃掉 take:12 的名额)。无日期的当最老处理。
orderBy: { taskDate: { sort: 'desc', nulls: 'last' } },
take: 12,
select: {
taskDate: true, type: true, status: true, taskStatus: true,
......@@ -76,23 +78,30 @@ export class RecallSummaryOrchestrator {
// 口径:按**日期**比(taskDate 是 @db.Date,无时分秒),taskDate > today 即未到期。
const today = new Date().toISOString().slice(0, 10);
const dated = visits.map((v) => ({
...v,
taskDate: v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null,
}));
// ⭐「最近一次真正发生过的联系」同样程序判定:列表已按日期倒序,
// 第一条**有日期且已到期**的就是它(至多一条;全是未来排程/全无日期 → 一条都不标)。
// 业务第一诉求是"上次联系聊到哪儿了",摘要必须先答这条 —— 见 input.types.isLatestPast。
const latestPastIndex = dated.findIndex((v) => v.taskDate != null && v.taskDate <= today);
const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
today,
items: visits.map((v) => {
const taskDate = v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null;
return {
taskDate,
type: v.type,
status: v.status,
taskStatus: v.taskStatus,
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: taskDate != null && taskDate > today,
treatmentItems: v.treatmentItems,
followContent: v.followContent,
result: v.result,
};
}),
items: dated.map((v, i) => ({
taskDate: v.taskDate,
type: v.type,
status: v.status,
taskStatus: v.taskStatus,
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: v.taskDate != null && v.taskDate > today,
isLatestPast: i === latestPastIndex,
treatmentItems: v.treatmentItems,
followContent: v.followContent,
result: v.result,
})),
};
// 3) 跑 AiCall → upsert
......
......@@ -63,7 +63,9 @@ export class PlanAggregateService {
include: { relatedPatient: { select: { name: true, phone: true } } },
},
// 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
returnVisits: { orderBy: { taskDate: 'desc' }, take: 100 },
// nulls:'last' —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会顶到列表最前,
// 被前端当成「最近一次」;无日期的当最老处理,跟摘要侧口径保持一致。
returnVisits: { orderBy: { taskDate: { sort: 'desc', nulls: 'last' } }, take: 100 },
},
});
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
......
......@@ -4,6 +4,7 @@ import {
DRAFT_RECALL_SUMMARY_PROMPT_VERSION,
} from '../src/modules/ai/calls/draft-recall-summary/prompt';
import type { DraftRecallSummaryInput } from '../src/modules/ai/calls/draft-recall-summary/input.types';
import { DraftRecallSummaryCall } from '../src/modules/ai/calls/draft-recall-summary/call';
/**
* 历史联系摘要 —— 「未来排程 ≠ 漏做」回归。
......@@ -31,6 +32,7 @@ function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) {
status: '已回访',
taskStatus: '已完成',
isFuture: false,
isLatestPast: false,
treatmentItems: null,
followContent: null,
result: '治疗后回访',
......@@ -114,6 +116,133 @@ describe('DRAFT_RECALL_SUMMARY_SYSTEM — 硬约束文本', () => {
test('promptVersion 已 bump(改了 prompt 必须换版本,否则缓存/评估串档)', () => {
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).not.toBe('draft_recall_summary@2026-06-16-d');
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).not.toBe('draft_recall_summary@2026-07-24-e');
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).toMatch(/^draft_recall_summary@\d{4}-\d{2}-\d{2}-[a-z]$/);
});
});
/**
* 「最近一次」突出 —— 2026-07 业务可用性测试回归。
*
* 原始反馈(患者余同学 BJ0L028292 详情页):
* [被测者]「说了常规的提醒没有特殊的」/[主持人]「那就是暂时没有办法详细的看到具体一次沟通什么内容」
*
* 生产核过的两件事,决定了这里怎么改:
* ① 源侧确实没有更多 —— DW 只有 fact_returnvisit_out 一张联系表,该患者 5 条全部
* 落库无丢失;follow_content/return_visit_result 都是**标签**(「复查洁牙」「方案跟进」
* 「无不适」「未接」),全库 165 万条平均 12.4 / 9.1 字。**没有**逐字沟通内容、
* 患者原话、下次建议联系时间这三样东西。→ PAC 不造数据,摘要维持"只讲记录里写着的"。
* ② 但「最近一次」值得单独拎出来 —— 列表按日期倒序时头部常是未到期排程,
* 客服最想知道的"上次聊到哪儿了"反而被埋在中间。
*
* 纪律不变:哪条是"最近一次"是**排序问题**,由 orchestrator 算好 isLatestPast 传入,
* 不让 LLM 自己比日期(同 isFuture)。
*/
describe('最近一次·已发生 —— 程序标注 + 摘要开头必须讲它', () => {
test('⭐ isLatestPast 条目带【最近一次·已发生】标记', () => {
const p = build([item({ taskDate: '2026-05-20', isLatestPast: true, followContent: '复查洁牙' })]);
expect(p).toContain('【最近一次·已发生】');
});
test('⭐ 未来排程即使排在最前也拿不到「最近一次」标记', () => {
const p = build([
item({ taskDate: '2026-10-09', isFuture: true, followContent: '复查洁牙' }),
item({ taskDate: '2026-05-20', isLatestPast: true, followContent: '方案跟进' }),
]);
const lines = p.split('\n');
expect(lines.find((l) => l.includes('2026-10-09'))).toContain('【未来排程·尚未到期】');
expect(lines.find((l) => l.includes('2026-10-09'))).not.toContain('【最近一次·已发生】');
expect(lines.find((l) => l.includes('2026-05-20'))).toContain('【最近一次·已发生】');
});
test('⭐ 有最近一次 → 追加"放开头讲清楚"的指令', () => {
const p = build([item({ isLatestPast: true })]);
expect(p).toContain('放在摘要开头讲清楚');
});
test('⭐ 全是未来排程 → 明确告知"没有已发生的回访",不许硬凑最近一次', () => {
const p = build([item({ taskDate: '2026-10-09', isFuture: true })]);
expect(p).toContain('没有已发生的回访');
expect(p).not.toContain('【最近一次·已发生】');
});
test('⭐ 最近一次没记结果 → 显式写「结果:未记录」(不写模型会拿别条的结果脑补)', () => {
const p = build([
item({ taskDate: '2026-05-20', status: '未回访', taskStatus: '未完成', isLatestPast: true, followContent: '复查洁牙', result: null }),
item({ taskDate: '2026-02-23', result: '复查' }),
]);
expect(p.split('\n').find((l) => l.includes('2026-05-20'))).toContain('结果:未记录');
});
test('非最近一次的空结果**不**补「未记录」(只在焦点那条上花笔墨)', () => {
const p = build([item({ taskDate: '2026-02-23', result: null, followContent: '半年复查' })]);
expect(p).not.toContain('结果:未记录');
});
test('⭐ 最近一次的正文预算更长 —— 源里几百字的方案沟通记录不能被 40 字砍没', () => {
const long = '一'.repeat(150);
expect(build([item({ isLatestPast: true, followContent: long })])).toContain('一'.repeat(150));
expect(build([item({ followContent: long })])).not.toContain('一'.repeat(41));
});
test('⭐ system 明写"源系统没有逐字沟通内容",禁止补写患者原话', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('没有逐字沟通内容');
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('患者表示');
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toMatch(/先说「最近一次」/);
});
test('余同学 BJ0L028292 的生产原始形态 → 最近一次是 5-20 未回访、无结果', () => {
// 生产实测(2026-07-26 查 DW fact_returnvisit_out,customer_id=1683102 brand=瑞尔,共 5 条)
const p = buildDraftRecallSummaryPrompt({
patientNameMasked: '余*',
today: '2026-07-26',
items: [
item({ taskDate: '2026-05-20', status: '未回访', taskStatus: '未完成', isLatestPast: true, followContent: '复查洁牙', result: null }),
item({ taskDate: '2026-02-25', status: '未回访', taskStatus: '未完成', followContent: '方案跟进', result: null }),
item({ taskDate: '2026-02-23', status: '已回访', taskStatus: '已预约', followContent: null, result: '复查' }),
item({ taskDate: '2025-11-16', status: '未回访', taskStatus: '未完成', followContent: '半年复查', result: null }),
item({ taskDate: '2025-08-17', status: '已回访', taskStatus: '已完成', followContent: null, result: '复查' }),
],
});
expect(p).not.toContain('尚未到期的未来排程'); // 5 条全在过去
const latest = p.split('\n').find((l) => l.includes('2026-05-20'))!;
expect(latest).toContain('【最近一次·已发生】');
expect(latest).toContain('内容:复查洁牙');
expect(latest).toContain('结果:未记录');
});
});
/**
* 兜底文案(LLM 失败 / safety 拒收时展示给客服的那句)也走同一条时间纪律。
* 原实现直接拿 items[0] 当「最近一次」—— 而 items 按日期倒序,头部很可能是未到期排程,
* 等于把 2026-07 那次生产事故在兜底路径上原样复刻一遍。
*/
describe('DraftRecallSummaryCall.fallback — 兜底也不许把未来排程当最近一次', () => {
const call = new DraftRecallSummaryCall();
const run = (items: DraftRecallSummaryInput['items']) =>
call.fallback({ patientNameMasked: '余*', today: TODAY, items }).summary;
test('无记录 → 直说暂无', () => {
expect(run([])).toContain('暂无历史联系记录');
});
test('⭐ 头部是未来排程时,「最近一次」取的是 isLatestPast 那条,不是 items[0]', () => {
const s = run([
item({ taskDate: '2026-10-09', isFuture: true, result: null, followContent: '复查洁牙' }),
item({ taskDate: '2026-05-20', isLatestPast: true, result: '已约' }),
]);
expect(s).toContain('2026-05-20');
expect(s).not.toContain('2026-10-09');
});
test('⭐ 全是未来排程 → 不提「最近一次」', () => {
const s = run([item({ taskDate: '2026-10-09', isFuture: true })]);
expect(s).not.toContain('最近一次');
expect(s).toContain('尚未到期');
});
test('最近一次没结果 → 兜底也如实说未留结果,不留空', () => {
const s = run([item({ taskDate: '2026-05-20', isLatestPast: true, result: null, followContent: null })]);
expect(s).toContain('未留结果');
});
});
......@@ -309,20 +309,47 @@ function CBCTImageView() {
);
}
/** 历史联系(诊所回访)结构化列表 — 「历史联系」卡片「详情」抽屉内容。 */
/**
* 历史联系(诊所回访)结构化列表 — 「历史联系」卡片「详情」抽屉内容。
*
* 「最近一次」单独标出来:客服接手第一个问题就是"上次联系聊到哪儿了",而列表按日期倒序时
* 头部很可能是**还没到日子的排程**,肉眼分不出哪条才是真发生过的最后一次。
* 判定口径与后端摘要一致(taskDate ≤ 今天 的第一条),两处说法不会打架。
*
* ⚠️ 宿主回访表只有**事由标签 + 结果标签**,没有逐字沟通内容。空着不如明说"未留结果",
* 免得客服以为是 PAC 没展示(2026-07 可用性测试的原始反馈就是这个误会)。
*/
function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
if (visits.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无历史联系记录</div>;
}
const today = new Date().toISOString().slice(0, 10);
const latestPast = visits.findIndex((v) => v.taskDate != null && v.taskDate <= today);
return (
<div className="space-y-2">
{visits.map((v, i) => {
const done = v.status === '已回访' || v.taskStatus === '已完成';
const future = v.taskDate != null && v.taskDate > today;
const latest = i === latestPast;
return (
<div key={i} className="flex items-start gap-2.5 text-[12.5px] py-1 border-b border-slate-50 last:border-0">
<div
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',
)}
>
<span className={cn('flex-none mt-[5px] w-1.5 h-1.5 rounded-full', done ? 'bg-teal-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>
)}
{future && (
<span className="flex-none rounded bg-slate-100 px-1 py-px text-[10px] text-slate-500">未到期</span>
)}
<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>}
......@@ -331,8 +358,10 @@ function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
{v.followContent && (
<div className="text-slate-600 leading-snug break-words mt-0.5">{v.followContent}</div>
)}
{v.result && (
{v.result ? (
<div className="text-slate-400 leading-snug break-words mt-0.5">结果:{v.result}</div>
) : (
latest && <div className="text-slate-400 leading-snug mt-0.5">结果:宿主未记录</div>
)}
</div>
</div>
......
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