Commit 9a895395 by luoqi

feat(ai): AI 摘要/话术触发感知 — 仅医生建议时不假设牙已缺失

reason.leadTrigger 透传到话术/召回简报:lead=建议(无诊断)→ 病种名用
建议名(如「建议种植」),不套「缺失牙」;诊断/影像仍以诊断为准。
召回简报 prompt 增来源标注【医生建议·非确诊缺失】+ 硬规则 + 建议示例,
修复张宇晨这类"仅建议"被 LLM 说成"缺牙空了X个月"。
reason-line 前端 subLabel 同步触发感知。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 7eb8dbed
......@@ -6,6 +6,8 @@
* (subKey → 患者口径病种名),标准/深度档拿它得到病种 canonical 名,措辞自行组织。
*/
import { diagnosisCodeNameZh } from '@pac/types';
/** subKey(去 @tooth 后缀)→ 患者口径病种名 */
const SUBKEY_LABEL: Record<string, string> = {
missing_tooth: '缺失牙',
......@@ -26,10 +28,16 @@ export function diseaseLabelForSubKey(subKey: string | null | undefined): string
return base ? (SUBKEY_LABEL[base] ?? null) : null;
}
/** reason → 病种名(subKey 优先,缺失用 fallbackLabel) */
/** reason → 病种名(触发感知):
* lead=医生建议(无诊断)→ 用建议名(如"建议种植"),不套子场景诊断词("缺失牙");
* lead=诊断/影像(影像也是 K 诊断)→ subKey 病种名,以诊断为准。subKey 缺失用 fallbackLabel。 */
export function resolveDiseaseLabel(
reason: { subKey?: string | null } | null | undefined,
reason: { subKey?: string | null; leadTrigger?: { type: string; code: string } | null } | null | undefined,
fallbackLabel: string,
): string {
const lead = reason?.leadTrigger;
if (lead?.type === 'recommendation' && lead.code) {
return diagnosisCodeNameZh(lead.code) || diseaseLabelForSubKey(reason?.subKey) || fallbackLabel;
}
return diseaseLabelForSubKey(reason?.subKey) ?? fallbackLabel;
}
......@@ -56,6 +56,9 @@ export interface ScriptContext {
subKey: string | null;
/** ⭐ ICD-10 K-code(K00-K09,skill composer.applies.diagnosisCodePrefix 用) */
dxCode: string | null;
/** ⭐ 最高优先触发源(诊断>建议>影像,来自 plan_reason.signals.triggers[0])。
* type='recommendation' 且无诊断 ⟹ 该 gap 仅医生建议 → 病种文案用建议名(不套"缺失牙"诊断词)。 */
leadTrigger: { type: string; code: string } | null;
/** ⭐ 涉及牙位(FDI 号,结构化;来源 plan_reason.signals.toothPosition 拆分)。
* 空数组 = 全口/无具体牙位。俗称("上门牙")由消费方按需渲染,不在快照里拍平。
* 缺失牙位占位等场景从此取牙位,不再丢进散文。 */
......
......@@ -11,6 +11,7 @@
* resolveDisease 行为与重组前一致(稳健档输出不变):subKey 优先精确,文本兜底归一。
*/
import { diseaseLabelForSubKey } from '../../shared/disease-knowledge';
import { diagnosisCodeNameZh } from '@pac/types';
// ─────────────────────────────────────────────────────────
// 漏诊项关键要点配置(稳健档"告知应治未治"小节2/3 灵活组合用)
......@@ -200,13 +201,25 @@ export interface DiseaseKnowledge {
*/
export function resolveDisease(
reason:
| { subKey?: string | null; dxCode?: string | null; reason?: string | null; scenarioLabel?: string | null }
| {
subKey?: string | null;
dxCode?: string | null;
reason?: string | null;
scenarioLabel?: string | null;
leadTrigger?: { type: string; code: string } | null;
}
| null
| undefined,
fallbackLabel: string,
): DiseaseKnowledge {
const base = (reason?.subKey ?? '').split('@')[0]!.trim();
const label = diseaseLabelForSubKey(base) ?? null;
// 触发感知:仅医生建议为首(无诊断)→ {应治未治项}用建议名(如"建议种植"),不套"缺失牙";
// 风险/优势仍按子场景(种植/修复缺口的风险本就适用),只换病种名。
const lead = reason?.leadTrigger;
const isRecLead = lead?.type === 'recommendation' && !!lead.code;
const label = isRecLead
? diagnosisCodeNameZh(lead!.code) || diseaseLabelForSubKey(base)
: (diseaseLabelForSubKey(base) ?? null);
const phrasing = base ? SUBKEY_PHRASING[base] : undefined;
if (label && phrasing) {
const kp = MISSED_DIAGNOSIS_KEY_POINTS[phrasing.kp];
......
......@@ -17,8 +17,11 @@ export interface DraftRecallBriefInput {
treatmentHistory: Array<{ category: string; count: number }>;
/** 本次召回原因(应治未治项)*/
reasons: Array<{
subLabel: string; // 子场景中文(如 缺失牙未启动修复)
diagnosis: string | null; // 触发诊断中文(如 牙列丢失/缺牙)
subLabel: string; // 子场景中文(如 缺失牙未启动修复;仅医生建议时为建议名"建议种植")
/** 触发来源:diagnosis=医生确诊 / recommendation=医生建议(非确诊) / image_finding=影像AI。
* recommendation 时**不可假设牙已缺失/空着**,按医生建议的治疗动作措辞。 */
source: 'diagnosis' | 'recommendation' | 'image_finding';
diagnosis: string | null; // 触发诊断中文(如 牙列丢失/缺牙);仅建议时为 null
tooth: string | null; // 牙位(如 47)
daysSinceText: string; // 距今(如 131 天(4 个月))
expectedCategories: string[]; // 期望/未启动治疗类目中文(如 种植 / 修复 / 冠桥)
......
import type { DraftRecallBriefInput } from './input.types';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-06-16-c';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-06-26-a';
export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做一句话交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像」。请提炼成**一句话召回简报**。
......@@ -24,6 +24,7 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**:
- 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。
- 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。
- ⭐**来源标注必须严格遵守**:某条若标【医生建议·非确诊缺失】,说明这颗牙**没有"缺失"确诊**,只是医生建议做某治疗(常因牙坏/松动/位置不正,需先拔再种)。**绝不能**说成"缺牙 / 牙空着 / 缺了X个月",要按**医生建议的治疗动作**措辞(如"医生建议种植""医生建议拔除后修复")。标【影像AI所见】的,措辞用"影像提示…"更谨慎,不当医生确诊。
3. 客观、口吻是"为患者着想"而非"催单"。**禁止**给医疗建议、禁止承诺一定治好。
4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。
5. 严格按 JSON schema 输出,只有一个 key:summary。
......@@ -31,7 +32,8 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
# 示例(患者立场、重心在"他为什么该来",风格参考,不要照抄)
- "47 缺了 4 个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;患者是做过 2 次种植的老客。"
- "36 的龋齿拖了 3 个月还没补,再放任可能伤到牙神经会疼,趁早补上;新客,目前只洁过牙。"
- "正畸后保持器到期没复查,不及时戴查牙齿易反弹、白做一场,该回来复查;成熟期老客。"`;
- "正畸后保持器到期没复查,不及时戴查牙齿易反弹、白做一场,该回来复查;成熟期老客。"
- (【医生建议·非确诊缺失】示例,注意不说"缺牙/空着")"35 牙医生建议种植修复,拖了 3 个多月没启动,早处理咀嚼更稳;做过 7 次种植的高价值老客。"`;
export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string {
const personaLines =
......@@ -48,8 +50,15 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin
input.reasons.length > 0
? input.reasons
.map((r, i) => {
// ⭐ 来源标注:仅医生建议(非确诊)→ 明确告知,LLM 不得假设牙已缺失/空着
const srcTag =
r.source === 'recommendation'
? '【医生建议·非确诊缺失】'
: r.source === 'image_finding'
? '【影像AI所见】'
: '';
const parts = [
r.subLabel,
`${srcTag}${r.subLabel}`,
r.tooth ? `牙位 ${r.tooth}` : null,
r.diagnosis,
`${r.daysSinceText}前`,
......
......@@ -619,7 +619,10 @@ export class PlanScriptOrchestrator {
// sub_key 形如 'caries_no_filling@36',base 去 @ 后缀
const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
// 牙位结构化:优先 signals.toothPosition,兜底 sub_key 的 @后缀
const sig = (r.signals ?? {}) as { toothPosition?: string | null };
const sig = (r.signals ?? {}) as {
toothPosition?: string | null;
triggers?: Array<{ type: string; code: string }>;
};
const toothPositions = splitToothPositions(
sig.toothPosition ?? (r.subKey ?? '').split('@')[1] ?? null,
);
......@@ -627,6 +630,8 @@ export class PlanScriptOrchestrator {
scenarioLabel: planScenarioLabel(r.scenario),
subKey: baseSubKey,
dxCode: subKeyToDxCode(baseSubKey),
// 最高优先触发源(signals.triggers 已按 诊断>建议>影像 排序)→ 病种文案分流
leadTrigger: sig.triggers?.[0] ?? null,
toothPositions,
reason: r.reason,
priorityScore: r.priorityScore,
......
......@@ -96,8 +96,21 @@ export class RecallBriefOrchestrator {
const reasons = plan.reasons.map((r) => {
const s = (r.signals ?? {}) as ReasonSignals;
const code = (s.triggers ?? []).find((t) => /^K\d/i.test(t.code ?? ''))?.code ?? null;
// 触发感知(同 ReasonLine/话术):lead=最高优先来源(诊断>建议>影像)。
// lead=建议(无诊断)→ subLabel 用建议名("建议种植"),不套子场景诊断词"缺失牙未启动修复"。
const lead = (s.triggers ?? [])[0];
const isRecLead = lead?.type === 'recommendation' && !!lead.code;
const source: 'diagnosis' | 'recommendation' | 'image_finding' =
lead?.type === 'recommendation'
? 'recommendation'
: lead?.type === 'image_finding'
? 'image_finding'
: 'diagnosis';
return {
subLabel: subLabelZh(r.scenario, s.subKey ?? '') || r.scenario,
subLabel: isRecLead
? diagnosisCodeNameZh(lead.code as string)
: subLabelZh(r.scenario, s.subKey ?? '') || r.scenario,
source,
diagnosis: code ? diagnosisCodeNameZh(code) : null,
tooth: (s.toothPosition ?? '').trim() || null,
daysSinceText: daysText(s.daysSince ?? 0),
......
......@@ -42,7 +42,13 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
: trig
? triggerTypeLabelZh(trig.type)
: '';
const subLabel = subLabelZh(reason.scenario, s.subKey);
// 标签分流(triggers 已按 诊断>建议>影像 排序,triggers[0]=最高优先源):
// triggers[0]=建议 ⟹ 该 gap 只有医生建议、无诊断 → 主标签用建议名(如"建议种植"),
// 不再套子场景的诊断词"缺失牙"(否则把"建议拔除同期种植"误说成缺失牙)。
// triggers[0]=诊断/影像 ⟹ 有诊断(影像AI 也是 K 诊断)→ 仍用病种名,以诊断为准。
const isRecLead = trig?.type === 'recommendation';
const subLabel =
isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey);
// 乳牙 restorative 只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
const cats = s.expectedCategories
.map((c) => treatmentCategoryNameZhForTeeth(c, s.toothPosition))
......@@ -51,7 +57,8 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
<span>
<strong className="text-slate-900">{subLabel}</strong>
{s.toothPosition && ` · 牙位 ${formatToothPosition(s.toothPosition)}`}
{trig?.code && ` — ${diagnosisCodeNameZh(trig.code)}`}
{/* 建议为首时主标签已是建议名,不再重复追加 code 名 */}
{!isRecLead && trig?.code && ` — ${diagnosisCodeNameZh(trig.code)}`}
{sourceLabel && `(${sourceLabel})`}{' '}
<strong className="text-rose-600 tabular-nums">{formatDaysReadable(s.daysSince)}</strong>
{cats && `,未启动 ${cats}`}
......
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