Commit d7a251a3 by luoqi

feat(plan-detail): 召回反馈剥离到 plan + 标题栏拇指弹窗;画像缺口口径统一;病历诊断原文;事实轴牙位筛选

1) 召回反馈剥离(执行 → plan 自身)
   - schema: FollowupPlan 加 recall_feedback(up/down)+ recall_feedback_note(2 列);
     PlanExecution 删 recall_feedback。migration 20260603090000。
   - 端点 POST /plans/:id/recall-feedback { feedback, note? } → 写 plan;/full 回显。
   - 详情页标题栏(优先级右边)拇指:👍 直接记;👎 弹 shadcn Dialog,
     预选项=快速输入到文本框(沿用 RECALL_FEEDBACK_OPTIONS)+ 自由补充。
   - 执行表单移除原召回反馈多选块;执行链路 schema/service/types 去掉 recallFeedback。

2) 画像治疗链缺口数口径统一(韩滨 1→2)
   - treatment_chain_status 旧版类别级判定漏算被无关同类治疗掩盖的缺口;
     改为复用 ChainComposerService,数 discovered 且非替代闭环的链 = 缺口,
     与治疗链面板 / 召回理由逐条一致。PersonaModule 注入 ChainComposerService。

3) 病历快读 评估段 加医生诊断原文,且与"本次治疗"同结构:[标签] 原文(深色) · 牙位
   (数据已在 diagnosis_record.content.name_zh,纯展示)。

4) 患者事实时间轴 加单牙位筛选(下拉,牙位归一去重),与类型筛选叠加。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 99b39c08
-- 召回反馈从执行表迁到 plan 表(反馈是对 plan 本身的判断,不属于某次执行)
-- 2 列:recall_feedback(up/down,立柱可聚合反推算法问题)+ note(文字,人读)
ALTER TABLE "followup_plans"
ADD COLUMN "recall_feedback" TEXT,
ADD COLUMN "recall_feedback_note" TEXT;
-- plan_executions: 删除旧的多选 recall_feedback(试部署无需保留)
ALTER TABLE "plan_executions" DROP COLUMN IF EXISTS "recall_feedback";
......@@ -773,6 +773,14 @@ model FollowupPlan {
/// 指派发生时间
assignedAt DateTime? @map("assigned_at") @db.Timestamptz(3)
/// 召回反馈(plan ,正交于执行) 客服在详情页对"这条召回准不准"打的拇指 + 文字。
/// 'up' = 召回准/有用;'down' = 不该召/有问题( recallFeedbackNote 文字说明)
/// PlanExecution.recall_feedback 迁来:反馈是对 plan 本身的判断,不属于某次执行。
/// 2 列即可:recallFeedback 立柱(聚合"哪些子场景被 👎"反推算法问题)+ note 文字(人读)
/// 不立 at/by(at plan.updatedAt 兜底,by 价值有限 轻量反馈不做审计)
recallFeedback String? @map("recall_feedback") // 'up' | 'down'
recallFeedbackNote String? @map("recall_feedback_note") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
/// 被新版本取代的时间;status='active'/'assigned' 等其他状态时为 null
......@@ -1065,10 +1073,7 @@ model PlanExecution {
/// outcome=scheduled_next ,下次回访时间
scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3)
/// 召回反馈(选填·多选) 一线对"这条召回准不准"的反馈,**正交于 outcome**
/// 值见 @pac/types RECALL_FEEDBACK_OPTIONS(info_wrong/already_handled/duplicate_contact/bad_timing/not_worth)
/// 用途:聚合定位召回算法系统性问题(产品/算法迭代输入),不参与状态机 / 抑制。
recallFeedback String[] @map("recall_feedback")
/// 召回反馈已迁出执行表 FollowupPlan.recall_feedback(反馈是对 plan 本身的判断,不属于某次执行)
/// 记录提交时间 通话结束时间。
/// 通话开始时间 / 时长由设备通话系统记录,不在 PAC 范围;客服手填不准故 PAC 不立柱
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import {
PersonaFeatureKey,
FactType,
FactKind,
lookupDxTreatment,
treatmentCategoryNameZh,
} from '@pac/types';
ChainComposerService,
type ChainComposeInputFact,
} from '../../plan/engine/chain-composer.service';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
ActiveFact,
} from './feature.interface';
/**
* treatment_chain_status 治疗链状态(v2.1 — 读独立 diagnosis_record / treatment_record)
* treatment_chain_status 治疗链状态(v2.2 — 复用 chain-composer,与治疗链面板/召回同口径)
*
* v2.1 重塑(对齐 canonical-fact-layer.md §四):
* 不再读 encounter_record.content.treatments[] 嵌套数组,改读独立 fact:
* - diagnosis_record(kind=actual)— 病人有哪些诊断
* - treatment_record(kind=actual)— 病人做过哪些治疗
* - treatment_record(kind=planned)— 计划但未执行的治疗
* v2.2 重塑(修"画像缺口数 ≠ 召回缺口数"):
* 旧版自己用**类别级**判定缺口(`actualByCat.has(cat)`)— 不看牙位/时间/替代闭环,
* 会把"缺牙11(K08)被无关 prosthodontic 治疗掩盖"漏算 → 画像显示 1,实际 2。
* 新版直接调 ChainComposerService.compose(facts)(就是画"治疗链面板"那套,已正确处理
* 牙位分链 / 时间方向 / 替代闭环),数 status='discovered' 且非 alternativeClosedBy 的链 =
* "潜在新链 = 缺口",跟召回理由 / 治疗链面板逐条一致。
*
* 规则(v1):
* - 无 diagnosis 且无 treatment → 'no_chain'(从未进入)
* - 有 treatment.kind=actual status=active → 'in_progress'(在管)
* - 有 diagnosis 但相应类别无 actual treatment → 'gap'(链有缺口,召回核心信号)
* - 所有 diagnosis 都有对应已完成 treatment → 'closed'
*
* "对应"通过 PACDiagnosisCode → PACTreatmentCategory 映射(集中此处):
* K02(龋病)→ restorative(充填)
* K04(牙髓/根尖周)→ endodontic(根管)
* K05(牙周炎)→ periodontic(牙周治疗)
* K08(缺牙)→ implant / prosthodontic(种植/修复)
* IMPLANT_RECOMMENDED → implant
* 规则(v1 语义不变,判据换成 chain):
* - 无任何 chain(无诊断/治疗/建议) → 'no_chain'(从未进入)
* - 有 discovered 且非替代闭环的链 → 'gap'(链有缺口,召回核心信号)
* - 否则有 entered/ongoing 链 → 'in_progress'(在管)
* - 否则有 closed 链 → 'closed'(已闭环)
*/
@Injectable()
export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.TREATMENT_CHAIN_STATUS;
// 诊断码 → 期望治疗 category 用单一真理源 canonical-codes.DiagnosisTreatmentMap
constructor(private readonly chainComposer: ChainComposerService) {}
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const diagnoses = ctx.factsByType.get(FactType.DIAGNOSIS_RECORD) ?? [];
......@@ -60,75 +51,48 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
};
}
// 已做的治疗按 category 索引
const actualByCat = new Map<string, ActiveFact[]>();
let inProgressCount = 0;
for (const tx of treatments) {
if (tx.kind !== FactKind.ACTUAL) continue;
const c = tx.content as Record<string, unknown>;
const cat = String(c.category ?? '');
const status = String(c.status ?? '');
if (status === 'in_progress' || tx.status === 'active') {
// active 但 content.status 未明 in_progress 也算进行中(DW 数据兜底)
if (status === 'in_progress' || status === 'active' || !status) {
inProgressCount++;
}
}
if (!cat) continue;
const arr = actualByCat.get(cat) ?? [];
arr.push(tx);
actualByCat.set(cat, arr);
}
// 复用 chain-composer:它消费所有 active facts,内部按 type 分组
const allFacts = [...ctx.factsByType.values()].flat() as ChainComposeInputFact[];
const chains = this.chainComposer.compose(allFacts);
// 找出"诊断 + 推荐但无对应治疗"的缺口
// gap 字符串短化(旧版含全口 26 颗牙位 + raw enum,UI 严重溢出):
// "K05/11;12;13;...37 → 未做 periodontic"(60+ 字符)
// → "牙周治疗 全口 26 牙"(短 + 用 chainLabel 中文,牙位 formatTooth 截断)
const gaps: string[] = [];
const factIds: string[] = [];
const signalSources = [...diagnoses, ...recommendations];
for (const sig of signalSources) {
factIds.push(sig.id);
const c = sig.content as Record<string, unknown>;
const code = String(c.code ?? '');
const tooth = String(c.tooth_position ?? '');
const rule = lookupDxTreatment(code);
const expectedCats = rule?.categories;
if (!expectedCats) continue;
const hasFollowup = expectedCats.some((cat) => actualByCat.has(cat));
if (!hasFollowup) {
const chainLabel = rule?.chainLabel ?? treatmentCategoryNameZh(expectedCats[0]!) ?? code;
// normalize 后再生成 gap 文本 — 跟 chain-composer 桶分一致:
// 同 N 颗牙不同顺序("17;47;37" / "47;17;37")算 1 个 gap,不重复
const normalizedTooth = rule?.wholeMouth
? '全口'
: formatToothShort(normalizeTooth(tooth));
gaps.push(`${chainLabel} ${normalizedTooth}`);
}
}
for (const tx of treatments) factIds.push(tx.id);
// 缺口 = 潜在新链:discovered(仅 S1 命中)且未被替代方案闭环覆盖
const gapChains = chains.filter(
(c) => c.status === 'discovered' && !c.alternativeClosedBy,
);
const activeChains = chains.filter(
(c) => c.status === 'entered' || c.status === 'ongoing',
);
const closedChains = chains.filter((c) => c.status === 'closed');
// 证据:诊断 / 治疗 / 建议 fact ids(反查链路)
const factIds = [
...diagnoses.map((f) => f.id),
...treatments.map((f) => f.id),
...recommendations.map((f) => f.id),
];
let status: string;
let description: string;
let score: number;
if (gaps.length > 0) {
if (gapChains.length > 0) {
status = 'gap';
// 去重(同 K 码 + 牙位的多次诊断 / 推荐 → 同一 gap;UI 只显示 distinct)
const dedupGaps = [...new Set(gaps)];
description = `${dedupGaps.length} 处缺口 — ${dedupGaps.slice(0, 3).join('、')}`;
const names = gapChains.map((c) => c.name).slice(0, 3);
const more = gapChains.length > 3 ? ` 等 ${gapChains.length} 处` : '';
description = `${gapChains.length} 处缺口 — ${names.join('、')}${more}`;
score = 3;
} else if (inProgressCount > 0) {
} else if (activeChains.length > 0) {
status = 'in_progress';
description = `治疗链进行中(${inProgressCount} 项治疗在进行)`;
description = `治疗链进行中(${activeChains.length} 条在管)`;
score = 1;
} else if (treatments.length > 0) {
} else if (closedChains.length > 0) {
status = 'closed';
description = `治疗链已闭环(${treatments.length} 项治疗已完成)`;
description = `治疗链已闭环(${closedChains.length}已完成)`;
score = 2;
} else {
// 只有诊断/推荐,无任何 treatment — 视为新链待启动
// 有信号但 chain 既非 gap 也非在管/闭环(罕见兜底)
status = 'gap';
description = `仅有诊断 / 推荐,未启动任何治疗(${signalSources.length} 个信号)`;
description = '有诊断 / 建议,治疗链待跟进';
score = 3;
}
......@@ -140,22 +104,3 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
};
}
}
/// 牙位规整化(去 trim + 去重 + 字典序)— 跟 chain-composer.normalizeTooth 同语义,
/// 让"17;47;37" / "47;17;37" 算同一个 gap,不因 host 录入顺序差异重复
function normalizeTooth(s: string): string {
if (!s.trim()) return '';
return Array.from(new Set(s.split(';').map((t) => t.trim()).filter(Boolean)))
.sort()
.join(';');
}
/// 牙位短化:>20 颗 → 全口 N 牙;≤4 颗 → 完整列;否则截前 4 + 等 N 颗
function formatToothShort(tooth: string, wholeMouthDefault?: boolean): string {
if (!tooth.trim()) return wholeMouthDefault ? '全口' : '未标注牙位';
const list = tooth.split(';').map((s) => s.trim()).filter(Boolean);
if (list.length === 0) return '未标注牙位';
if (list.length >= 20) return `全口 ${list.length} 牙`;
if (list.length <= 4) return list.join(';');
return `${list.slice(0, 4).join(';')}${list.length} 颗`;
}
......@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PersonaController } from './persona.controller';
import { PersonaService } from './persona.service';
import { FeatureRegistry } from './features/feature.registry';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
import { ValueFeatureExtractor } from './features/value.feature';
import { TreatmentChainStatusFeatureExtractor } from './features/treatment-chain-status.feature';
import { RecallRiskFeatureExtractor } from './features/recall-risk.feature';
......@@ -13,6 +14,8 @@ import { EntitlementStatusFeatureExtractor } from './features/entitlement-status
providers: [
PersonaService,
FeatureRegistry,
// 无依赖纯函数服务 — 治疗链缺口判定复用它(跟 治疗链面板/召回 同口径)
ChainComposerService,
ValueFeatureExtractor,
TreatmentChainStatusFeatureExtractor,
RecallRiskFeatureExtractor,
......
......@@ -343,6 +343,8 @@ function serializePlan(plan: {
assignedAt: Date | null;
recycleAt: Date | null;
snoozedUntil: Date | null;
recallFeedback: string | null;
recallFeedbackNote: string | null;
updatedAt: Date;
reasons: Array<{
id: string;
......@@ -376,6 +378,9 @@ function serializePlan(plan: {
recycleAt: plan.recycleAt?.toISOString() ?? null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
/// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null
recallFeedback: plan.recallFeedback ?? null,
recallFeedbackNote: plan.recallFeedbackNote ?? null,
reasons: plan.reasons.map((r) => ({
id: r.id,
scenario: r.scenario,
......
......@@ -9,7 +9,7 @@ import {
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permission } from '@pac/types';
import { Permission, SubmitRecallFeedbackRequestSchema } from '@pac/types';
import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import {
......@@ -177,6 +177,29 @@ export class PlansAggregateController {
return { ok: true as const, invocationId: script.agentInvocationId, feedback };
}
@Post(':id/recall-feedback')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: '召回反馈(plan 级)— 对"这条召回准不准"打拇指 + 文字(down 弹窗收集)',
})
async submitRecallFeedback(
@TenantScope() scope: TenantScopeContext,
@Param('id') planId: string,
@Body() body: unknown,
) {
const { feedback, note } = SubmitRecallFeedbackRequestSchema.parse(body);
// 反馈是对 plan 本身的判断 → 直接写 followup_plans(正交于执行,不进事实层)
const res = await this.prisma.followupPlan.updateMany({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
data: {
recallFeedback: feedback,
recallFeedbackNote: note?.trim() ? note.trim() : null,
},
});
if (res.count === 0) return { ok: false as const, reason: 'not_found' };
return { ok: true as const, feedback };
}
@Get(':id/summary:stream')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '流式重新生成 plan 摘要(SSE,1 次产 3 段)' })
......
......@@ -53,8 +53,6 @@ export interface SubmitExecutionInput {
abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string;
/// 召回反馈 tag(选填·多选)— 对召回算法准确性的反馈,不参与状态机
recallFeedback?: string[];
invalidReason?: string;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId?: string;
......@@ -161,7 +159,6 @@ export class ExecutionService {
abandonReasons: input.abandonReasons ?? [],
abandonOther: input.abandonOther ?? null,
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
recallFeedback: input.recallFeedback ?? [],
},
select: { id: true },
});
......@@ -225,7 +222,6 @@ export class ExecutionService {
notes: e.notes,
invalidReason: e.invalidReason,
abandonReasons: e.abandonReasons,
recallFeedback: e.recallFeedback,
abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
......
......@@ -530,7 +530,6 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu
abandonReasons: e.abandonReasons,
abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
recallFeedback: e.recallFeedback,
createdAt: e.createdAt.toISOString(),
};
}
......
......@@ -159,6 +159,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null,
recallFeedback: (real.plan?.recallFeedback ?? null) as 'up' | 'down' | null,
recallFeedbackNote: real.plan?.recallFeedbackNote ?? null,
updatedAt: real.plan?.updatedAt ? new Date(real.plan.updatedAt) : null,
recommendedAt: real.plan?.recommendedAt ? new Date(real.plan.recommendedAt) : now,
recommendedRole: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
......
......@@ -259,12 +259,17 @@ function EmrSection({
// host 原始 name_zh / name 在 host 端可能跟字典名略不同,但客服关心标准名 — 走字典
const badgeText = code ? diagnosisCodeNameZh(code) : String(dc.name_zh ?? dc.name ?? '—');
const tooth = String(dc.tooth_position ?? '');
// 医生原始诊断文字(name_zh)— 与标准名不同才显示(如"无功能牙"→标准名"牙列丢失/缺牙")
const rawName = String(dc.name_zh ?? dc.name ?? '').trim();
const showRaw = rawName && rawName !== badgeText;
// 跟"本次治疗"同结构:[标签] 原文(深色) · 牙位
return (
<li key={dx.id} className="text-[12px] text-slate-700 leading-relaxed">
<span className="px-1.5 py-px mr-1.5 bg-rose-50 text-rose-700 rounded text-[10.5px] font-medium">
{badgeText}
</span>
{tooth && <span className="text-[11px] text-slate-500">牙位 {formatToothPosition(tooth, 999)}</span>}
{showRaw && <span className="font-medium">{rawName}</span>}
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth, 999)}</span>}
</li>
);
})}
......
......@@ -16,7 +16,6 @@ export interface SubmitExecutionBody {
abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string;
recallFeedback?: string[];
}
/**
......
......@@ -55,14 +55,26 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
return next;
});
// ─ 牙位筛选(单选)─ 把某颗牙的所有事实(诊断/治疗/影像等带牙位的)挑出来
const allTeeth = useMemo(() => {
const s = new Set<string>();
for (const f of facts) for (const t of factTeeth(f)) s.add(t);
return [...s].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}, [facts]);
const [toothFilter, setToothFilter] = useState<string | null>(null);
// 时间轴排序键:actual 用 occurredAt;planned 用 plannedFor 兜底
const tkey = (f: AdaptedFact) => {
const t = f.occurredAt ?? f.plannedFor;
return t ? new Date(t).getTime() : -Infinity;
};
const sorted = useMemo(
() => [...facts].filter((f) => selected.has(f.type)).sort((a, b) => tkey(b) - tkey(a)),
[facts, selected],
() =>
[...facts]
.filter((f) => selected.has(f.type))
.filter((f) => !toothFilter || factTeeth(f).includes(toothFilter))
.sort((a, b) => tkey(b) - tkey(a)),
[facts, selected, toothFilter],
);
// W4 末:sticky 顶部 — stats + filter 滚动时固定,时间轴在下方滚过
......@@ -88,6 +100,31 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
);
})}
</div>
{allTeeth.length > 0 && (
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-slate-500">牙位</span>
<select
value={toothFilter ?? ''}
onChange={(e) => setToothFilter(e.target.value || null)}
className={cn(
'rounded-md border px-2 py-0.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-sky-300',
toothFilter
? 'border-sky-200 bg-sky-50 text-sky-700 font-medium'
: 'border-slate-200 bg-white text-slate-600',
)}
>
<option value="">全部牙位</option>
{allTeeth.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
{toothFilter && (
<span className="text-slate-400 tabular-nums">{sorted.length}</span>
)}
</div>
)}
</div>
{sorted.length === 0 ? (
<div className="text-center py-12 text-sm text-slate-400">已勾选类型无事实</div>
......@@ -642,6 +679,24 @@ function factSummary(f: AdaptedFact): { title: string; note: string } {
}
}
/// 牙位归一:剥面/方位后缀保留牙位 base("36 OB"→"36";"1D OD"→"1D";"11"→"11")
function toothBase(t: string): string {
const m = /^(\d{1,2}[A-E]?)/i.exec(t.trim());
return m ? m[1]!.toUpperCase() : t.trim().toUpperCase();
}
/// 取一条 fact 的牙位集合(content.tooth_position 字符串 或 tooth_positions 数组)
function factTeeth(f: AdaptedFact): string[] {
const c = (f.content ?? {}) as Record<string, unknown>;
const tp = c.tooth_positions;
const raw = Array.isArray(tp) ? tp.join(';') : String(c.tooth_position ?? tp ?? '');
return raw
.split(/[;,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map(toothBase);
}
/// 从 JSON 数组字段(EMR.disposal / treatment_plan 等)取第一条 message
function parseFirstMessage(raw: unknown): string {
if (typeof raw !== 'string' || !raw || raw === 'null') return '';
......
......@@ -297,6 +297,9 @@ export const mockPlan = {
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null,
/// 召回反馈(plan 级)— 标题栏拇指当前态 + 文字
recallFeedback: null as 'up' | 'down' | null,
recallFeedbackNote: null as string | null,
updatedAt: NOW as Date | null,
recommendedAt: NOW,
recommendedRole: UserRole.STAFF as UserRole,
......
......@@ -4,7 +4,6 @@ import { useState } from 'react';
import {
EXECUTION_OUTCOME_META,
EXECUTION_OUTCOME_GROUP_META,
RECALL_FEEDBACK_OPTIONS,
type ExecutionOutcome,
type ExecutionOutcomeGroup,
} from '@pac/types';
......@@ -87,7 +86,6 @@ export function OutcomeForm({
notes: string;
scheduledNextAt: string;
abandonReasons: string[];
recallFeedback: string[];
}) => void;
onCreateAppointment: () => void;
defaultChannel?: string;
......@@ -97,7 +95,6 @@ export function OutcomeForm({
const [notes, setNotes] = useState('');
const [scheduledNextAt, setScheduledNextAt] = useState('');
const [abandonReasons, setAbandonReasons] = useState<string[]>([]);
const [recallFeedback, setRecallFeedback] = useState<string[]>([]);
const [submitted, setSubmitted] = useState(false);
const curMeta = outcome ? EXECUTION_OUTCOME_META[outcome as ExecutionOutcome] : null;
......@@ -114,14 +111,13 @@ export function OutcomeForm({
const handleSubmit = () => {
if (!canSubmit) return;
setSubmitted(true);
onSubmit({ channel, outcome: outcome!, notes, scheduledNextAt, abandonReasons, recallFeedback });
onSubmit({ channel, outcome: outcome!, notes, scheduledNextAt, abandonReasons });
setTimeout(() => {
setSubmitted(false);
setOutcome(null);
setNotes('');
setScheduledNextAt('');
setAbandonReasons([]);
setRecallFeedback([]);
setChannel(defaultChannel);
}, 2400);
};
......@@ -261,38 +257,7 @@ export function OutcomeForm({
</div>
)}
{/* 召回反馈(选填·多选)— 对"这条召回准不准"的反馈,正交于通话结果,默认不选=没问题 */}
<div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
召回反馈
<span className="ml-2 font-normal normal-case text-[10px] text-slate-400 tracking-normal">选填,帮我们改进召回</span>
</div>
<div className="flex flex-wrap gap-1">
{RECALL_FEEDBACK_OPTIONS.map((f) => {
const on = recallFeedback.includes(f.value);
return (
<button
key={f.value}
type="button"
title={f.hint}
onClick={() =>
setRecallFeedback(
on ? recallFeedback.filter((x) => x !== f.value) : [...recallFeedback, f.value],
)
}
className={cn(
'px-2 py-1 rounded text-[11px] border transition-colors',
on
? 'bg-rose-50 text-rose-700 border-rose-300 font-medium ring-1 ring-rose-200'
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300 hover:bg-slate-50',
)}
>
{f.labelZh}
</button>
);
})}
</div>
</div>
{/* 召回反馈已移出执行表单 → 详情页标题栏拇指(RecallFeedbackControl);此处不再收集 */}
<div className="flex-1 min-h-0 flex flex-col">
<label className="flex-none text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1">
......
......@@ -2,7 +2,15 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { toast } from 'sonner';
import { RefreshCw, ChevronDown } from 'lucide-react';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
......@@ -25,6 +33,7 @@ import {
treatmentCategoryNameZh,
diagnosisCodeNameZh,
EXECUTION_OUTCOME_META,
RECALL_FEEDBACK_OPTIONS,
type ExecutionOutcome,
} from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
......@@ -218,7 +227,6 @@ export function PlanDetailApp({
notes: string;
scheduledNextAt: string;
abandonReasons: string[];
recallFeedback: string[];
}) => {
try {
const { abandonReasons, abandonOther } = adaptAbandonReasons(formData.abandonReasons);
......@@ -231,7 +239,6 @@ export function PlanDetailApp({
scheduledNextAt: formData.scheduledNextAt
? new Date(formData.scheduledNextAt).toISOString()
: undefined,
recallFeedback: formData.recallFeedback.length > 0 ? formData.recallFeedback : undefined,
});
// 本地立刻反映 plan 新态(不等下次拉数据)
......@@ -357,7 +364,7 @@ export function PlanDetailApp({
)}
</div>
<p className="text-[10.5px] text-slate-500 mt-0.5">
{hasScriptContent ? `共 ${displayedSections.length} 步` : '点右上「生成」实时生成'}
{`共 ${displayedSections.length} 步`}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
......@@ -545,6 +552,155 @@ function ResponsiveDetail({
// ──────────────────────────────────────────
// TopBar
// ──────────────────────────────────────────
// ──────────────────────────────────────────
// RecallFeedbackControl — 召回反馈(plan 级,正交于执行)
// 标题栏拇指:👍 直接记;👎 弹 shadcn Dialog(预选项=快速输入到文本框)+ 文字 → 存 followup_plans
// ──────────────────────────────────────────
function RecallFeedbackControl({
planId,
initialFeedback,
initialNote,
showToast,
}: {
planId: string;
initialFeedback: 'up' | 'down' | null;
initialNote: string | null;
showToast?: (kind: string, title: string, msg: string) => void;
}) {
const [feedback, setFeedback] = useState<'up' | 'down' | null>(initialFeedback);
const [note, setNote] = useState(initialNote ?? '');
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const submitUp = async () => {
if (busy) return;
setBusy(true);
try {
await plansApi.submitRecallFeedback(planId, 'up');
setFeedback('up');
setNote('');
showToast?.('emerald', '已记录召回反馈', '标记为"召回准 / 有用"');
} catch (e) {
showToast?.('rose', '反馈失败', e instanceof Error ? e.message.slice(0, 80) : String(e));
} finally {
setBusy(false);
}
};
const submitDown = async () => {
if (busy) return;
setBusy(true);
try {
await plansApi.submitRecallFeedback(planId, 'down', note.trim() || undefined);
setFeedback('down');
setOpen(false);
showToast?.('amber', '已记录召回反馈', note.trim() ? '问题已提交,谢谢反馈' : '已标记为"有问题"');
} catch (e) {
showToast?.('rose', '反馈失败', e instanceof Error ? e.message.slice(0, 80) : String(e));
} finally {
setBusy(false);
}
};
// 预选项 = 快速输入:点一下把标签文字插进文本框(已含则不重复,顿号分隔)
const quickInsert = (label: string) => {
setNote((cur) => {
const has = cur.split(/[\n]/).map((s) => s.trim()).includes(label);
if (has) return cur;
return cur.trim() ? `${cur.trim()}${label}` : label;
});
};
return (
<>
<div
className="hidden sm:flex flex-none items-center gap-0.5 rounded-md border border-slate-200 px-1 py-0.5"
title="召回反馈:这条召回准不准?"
>
<span className="hidden lg:inline px-1 text-[10.5px] text-slate-400">召回反馈</span>
<button
type="button"
onClick={submitUp}
disabled={busy}
aria-label="召回准 / 有用"
className={cn(
'inline-flex items-center justify-center rounded p-1 transition-colors disabled:opacity-50',
feedback === 'up'
? 'text-emerald-600 bg-emerald-50'
: 'text-slate-400 hover:text-emerald-600 hover:bg-slate-50',
)}
>
<ThumbsUp className="h-3.5 w-3.5" fill={feedback === 'up' ? 'currentColor' : 'none'} />
</button>
<button
type="button"
onClick={() => setOpen(true)}
disabled={busy}
aria-label="召回有问题"
className={cn(
'inline-flex items-center justify-center rounded p-1 transition-colors disabled:opacity-50',
feedback === 'down'
? 'text-rose-600 bg-rose-50'
: 'text-slate-400 hover:text-rose-600 hover:bg-slate-50',
)}
>
<ThumbsDown className="h-3.5 w-3.5" fill={feedback === 'down' ? 'currentColor' : 'none'} />
</button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>召回反馈 · 哪里不对?</DialogTitle>
<DialogDescription>
帮我们改进召回算法。点下面的标签可快速填入,也可直接补充说明。
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{RECALL_FEEDBACK_OPTIONS.map((f) => (
<button
key={f.value}
type="button"
title={f.hint}
onClick={() => quickInsert(f.labelZh)}
className="px-2 py-1 rounded-full text-[12px] border border-slate-200 bg-white text-slate-600 hover:border-rose-300 hover:bg-rose-50 hover:text-rose-700 transition-colors"
>
{f.labelZh}
</button>
))}
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="补充说明(选填)…"
rows={3}
className="w-full px-2.5 py-2 rounded border border-slate-200 bg-white text-[13px] resize-none focus:outline-none focus:ring-2 focus:ring-rose-200 focus:border-rose-300"
/>
</div>
<DialogFooter>
<button
type="button"
onClick={() => setOpen(false)}
className="px-3 py-1.5 rounded-md border border-slate-200 text-[13px] text-slate-600 hover:bg-slate-50"
>
取消
</button>
<button
type="button"
onClick={submitDown}
disabled={busy}
className="px-3 py-1.5 rounded-md bg-rose-600 text-white text-[13px] font-medium hover:bg-rose-700 disabled:opacity-50"
>
提交反馈
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function TopBar({
plan,
reason,
......@@ -617,6 +773,13 @@ function TopBar({
<PriorityBar score={plan.priorityScore} label="优先级" />
</span>
</PriorityHover>
{/* 召回反馈(plan 级)— 拇指 + 弹窗收集;放优先级右边 */}
<RecallFeedbackControl
planId={plan.id}
initialFeedback={plan.recallFeedback}
initialNote={plan.recallFeedbackNote}
showToast={showToast}
/>
</div>
<div className="flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600">
{/* 数据新鲜度 — 该 plan 版本重算时间;宽屏才显,跟刷新按钮成对 */}
......
......@@ -56,6 +56,9 @@ export type PlanDetailData = {
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
/// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: string | null;
/// 召回反馈(plan 级)— 标题栏拇指当前态;'up' | 'down' | null
recallFeedback: 'up' | 'down' | null;
recallFeedbackNote: string | null;
/// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度
updatedAt: string;
reasons: Array<{
......
......@@ -61,6 +61,15 @@ export const plansApi = {
{ feedback },
),
/** 召回反馈(plan 级)— 详情页标题栏拇指 + 文字("这条召回准不准")
* - POST /pac/v1/plans/:id/recall-feedback { feedback: 'up'|'down', note? }
* - 写入 followup_plans.recall_feedback(+note/at/by);反复点覆盖以最新为准 */
submitRecallFeedback: (planId: string, feedback: 'up' | 'down', note?: string) =>
api.post<{ ok: boolean; feedback?: 'up' | 'down'; reason?: string }>(
`/pac/v1/plans/${encodeURIComponent(planId)}/recall-feedback`,
{ feedback, note },
),
/** W4 末:单患者按需刷新(从 DW 直连重拉该 patient 全量,触发 persona+plan recompute)
* - POST /sync/patient/:id/refresh
* - 跟 daily incremental cursor 完全隔离(resource='patient_refresh')
......
......@@ -114,8 +114,7 @@ export const PlanExecutionSchema = z.object({
abandonReasons: z.array(z.string()),
abandonOther: z.string().nullable(),
scheduledNextAt: z.string().nullable(),
/// 召回反馈(选填·多选)— 对召回算法准确性的反馈,正交于 outcome(见 RECALL_FEEDBACK_OPTIONS)
recallFeedback: z.array(z.string()),
/// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行
createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
});
export type PlanExecution = z.infer<typeof PlanExecutionSchema>;
......@@ -224,11 +223,17 @@ export const SubmitExecutionRequestSchema = z.object({
abandonReasons: z.array(AbandonReasonSchema).max(20).optional(),
abandonOther: z.string().optional(),
scheduledNextAt: z.string().optional(),
/// 召回反馈 tag(选填·多选,见 RECALL_FEEDBACK_OPTIONS)
recallFeedback: z.array(z.string()).max(10).optional(),
});
export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;
/// 召回反馈(plan 级)— 详情页标题栏拇指 + 文字。data 本质 = 正/反 + 文字。
export const SubmitRecallFeedbackRequestSchema = z.object({
feedback: z.enum(['up', 'down']),
/// down 时弹窗收集的文字说明(预选项是快速输入,最终都落这一段文字);up 一般为空
note: z.string().max(2000).optional(),
});
export type SubmitRecallFeedbackRequest = z.infer<typeof SubmitRecallFeedbackRequestSchema>;
export const SubmitExecutionResponseSchema = z.object({
/// plan 新状态(execution outcome 驱动状态机后的结果)
planStatus: z.string(),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment