Commit 524efac7 by luoqi

召回闭环 + 通话结果重构 + 召回反馈 + 工作台修复

召回闭环(snoozedUntil 列):
- execution 按 outcome 写 snoozedUntil(成功60/拒绝90/外院·无效永久/熔断30/约定回访到回访日)
- upsertPlan 信号级抑制:终态+冷静期内同 (scenario,subKey) 不复活;患者新长的别的诊断照常召回
- pool 视图过滤未到期 snooze;去掉 considering 的 likelihood 加权
- 回收 cron(每10min):超时 assigned 自动回池,跳过约定回访中的(snooze 未到不收)

详情页:
- 患者级"召回历史"卡(跨版本最近8条)+ "暂缓跟进"角标
- 终态(已完成/已放弃)提交按钮置灰 + 提示

工作台修复:
- 结案保留 assigneeUserId(我的已完成/转化率不再恒为0)
- "全部我的"含已完成;assigned 状态文案改"进行中"

通话结果重构:13→3 大类(结案/保持/放弃),group状态机列表tab 一一对应

召回反馈:plan_executions.recall_feedback,5 tag 选填收集一线对召回准确性的反馈(喂算法迭代)

日期组件:shadcn Date Picker(Calendar+Popover,captionLayout dropdown)+ 原生时间输入

迁移:20260601032022_add_plan_snoozed_until / 20260601072323_add_execution_recall_feedback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b8fbd7ad
-- AlterTable
ALTER TABLE "followup_plans" ADD COLUMN "snoozed_until" TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "plan_executions" ADD COLUMN "recall_feedback" TEXT[];
...@@ -755,6 +755,12 @@ model FollowupPlan { ...@@ -755,6 +755,12 @@ model FollowupPlan {
/// 不是聚合, assignment 时确定的固定值;cron 每分钟扫 WHERE status='assigned' AND recycle_at < NOW() /// 不是聚合, assignment 时确定的固定值;cron 每分钟扫 WHERE status='assigned' AND recycle_at < NOW()
recycleAt DateTime? @map("recycle_at") @db.Timestamptz(3) recycleAt DateTime? @map("recycle_at") @db.Timestamptz(3)
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写时按 outcome 计算, EXECUTION_OUTCOME_META.suppressDays):
/// - 终态(completed/abandoned)+ now<snoozedUntil plan 引擎 upsert **不重新生成**(防熔断/拒绝被重算复活)
/// - keep 类带 scheduledNextAt / declined_recent 写入后 active plan 在到期前**不进召回池**(到点自动浮现)
/// null = 无抑制(立即可召回 / 立即在池)。到期(now>=snoozedUntil)自愈。
snoozedUntil DateTime? @map("snoozed_until") @db.Timestamptz(3)
/// 实际指派给谁(宿主侧 user id, token );展示用 name 通过 token 字典查 /// 实际指派给谁(宿主侧 user id, token );展示用 name 通过 token 字典查
assigneeUserId String? @map("assignee_user_id") assigneeUserId String? @map("assignee_user_id")
/// 指派发生时间 /// 指派发生时间
...@@ -1052,6 +1058,11 @@ model PlanExecution { ...@@ -1052,6 +1058,11 @@ model PlanExecution {
/// outcome=scheduled_next ,下次回访时间 /// outcome=scheduled_next ,下次回访时间
scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3) 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")
/// 记录提交时间 通话结束时间。 /// 记录提交时间 通话结束时间。
/// 通话开始时间 / 时长由设备通话系统记录,不在 PAC 范围;客服手填不准故 PAC 不立柱 /// 通话开始时间 / 时长由设备通话系统记录,不在 PAC 范围;客服手填不准故 PAC 不立柱
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
......
...@@ -58,6 +58,7 @@ async function bootstrap() { ...@@ -58,6 +58,7 @@ async function bootstrap() {
logger.log(` plansSuperseded: ${r.plansSuperseded}`); logger.log(` plansSuperseded: ${r.plansSuperseded}`);
logger.log(` plansUnchanged: ${r.plansUnchanged}`); logger.log(` plansUnchanged: ${r.plansUnchanged}`);
logger.log(` plansSkippedAssigned: ${r.plansSkippedAssigned}`); logger.log(` plansSkippedAssigned: ${r.plansSkippedAssigned}`);
logger.log(` plansSuppressed: ${r.plansSuppressed}`);
logger.log(` duration: ${r.durationMs}ms`); logger.log(` duration: ${r.durationMs}ms`);
logger.log(`──────────────────────────────────────────────────────`); logger.log(`──────────────────────────────────────────────────────`);
} }
......
...@@ -217,7 +217,7 @@ async function bootstrap() { ...@@ -217,7 +217,7 @@ async function bootstrap() {
}); });
totalPlansCreated += r.plansCreated; totalPlansCreated += r.plansCreated;
totalHits += r.patientsHit; totalHits += r.patientsHit;
logger.log(` tenant=${t.tenantId}: patientsHit=${r.patientsHit} plansCreated=${r.plansCreated} skipped=${r.plansSkippedAssigned}`); logger.log(` tenant=${t.tenantId}: patientsHit=${r.patientsHit} plansCreated=${r.plansCreated} skipped=${r.plansSkippedAssigned} suppressed=${r.plansSuppressed}`);
} }
logger.log(` Total: patientsHit=${totalHits} plansCreated=${totalPlansCreated}`); logger.log(` Total: patientsHit=${totalHits} plansCreated=${totalPlansCreated}`);
logger.log('─────────────────────────────────────────'); logger.log('─────────────────────────────────────────');
......
...@@ -130,6 +130,10 @@ export class PlanAggregateService { ...@@ -130,6 +130,10 @@ export class PlanAggregateService {
c.target = hit; c.target = hit;
} }
// 召回历史(患者级,跨所有 plan 版本)—— 再次召回是新 plan id,旧执行挂在旧 plan 上,
// 必须按 patient 取才看得到"上次为什么被压住 / 上次结果"。也顺带补上之前缺失的通话历史。
const recallHistory = await this.loadRecallHistory(scope, patient.id);
return { return {
patient: serializePatient(patient), patient: serializePatient(patient),
profile: serializeProfile(patient, facts, facts.filter((f) => f.type === 'encounter_record')), profile: serializeProfile(patient, facts, facts.filter((f) => f.type === 'encounter_record')),
...@@ -138,10 +142,41 @@ export class PlanAggregateService { ...@@ -138,10 +142,41 @@ export class PlanAggregateService {
chains, chains,
facts: facts.map(serializeFact), facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null, script: script ? serializeScript(script) : null,
recallHistory,
}; };
} }
/** /**
* 患者级召回历史 —— 最近 N 次执行结果(跨该患者所有 plan 版本)。
* 用途:plan 再次被召回时,客服能看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
*/
private async loadRecallHistory(scope: TenantScopeContext, patientId: string) {
const rows = await this.prisma.planExecution.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, plan: { patientId } },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
outcome: true,
channel: true,
notes: true,
scheduledNextAt: true,
createdAt: true,
plan: { select: { version: true } },
},
});
return rows.map((e) => ({
id: e.id,
outcome: e.outcome,
channel: e.channel,
notes: e.notes,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
planVersion: e.plan?.version ?? null,
}));
}
/**
* W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。 * W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
* pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。 * pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
*/ */
...@@ -291,6 +326,7 @@ function serializePlan(plan: { ...@@ -291,6 +326,7 @@ function serializePlan(plan: {
assigneeUserId: string | null; assigneeUserId: string | null;
assignedAt: Date | null; assignedAt: Date | null;
recycleAt: Date | null; recycleAt: Date | null;
snoozedUntil: Date | null;
updatedAt: Date; updatedAt: Date;
reasons: Array<{ reasons: Array<{
id: string; id: string;
...@@ -322,6 +358,8 @@ function serializePlan(plan: { ...@@ -322,6 +358,8 @@ function serializePlan(plan: {
assigneeUserId: plan.assigneeUserId, assigneeUserId: plan.assigneeUserId,
assignedAt: plan.assignedAt?.toISOString() ?? null, assignedAt: plan.assignedAt?.toISOString() ?? null,
recycleAt: plan.recycleAt?.toISOString() ?? null, recycleAt: plan.recycleAt?.toISOString() ?? null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
reasons: plan.reasons.map((r) => ({ reasons: plan.reasons.map((r) => ({
id: r.id, id: r.id,
scenario: r.scenario, scenario: r.scenario,
......
...@@ -122,6 +122,7 @@ export class PlanEngineService { ...@@ -122,6 +122,7 @@ export class PlanEngineService {
let plansSuperseded = 0; let plansSuperseded = 0;
let plansUnchanged = 0; let plansUnchanged = 0;
let plansSkippedAssigned = 0; let plansSkippedAssigned = 0;
let plansSuppressed = 0;
// 2. 逐 patient 写 plan(每个 patient 一条 PlanGenerationLog,跟 schema 设计一致) // 2. 逐 patient 写 plan(每个 patient 一条 PlanGenerationLog,跟 schema 设计一致)
for (const [patientId, hits] of hitsByPatient.entries()) { for (const [patientId, hits] of hitsByPatient.entries()) {
...@@ -141,6 +142,7 @@ export class PlanEngineService { ...@@ -141,6 +142,7 @@ export class PlanEngineService {
else if (result === 'superseded') plansSuperseded++; else if (result === 'superseded') plansSuperseded++;
else if (result === 'unchanged') plansUnchanged++; else if (result === 'unchanged') plansUnchanged++;
else if (result === 'skipped_assigned') plansSkippedAssigned++; else if (result === 'skipped_assigned') plansSkippedAssigned++;
else if (result === 'suppressed') plansSuppressed++;
await this.prisma.planGenerationLog.update({ await this.prisma.planGenerationLog.update({
where: { id: log.id }, where: { id: log.id },
...@@ -172,6 +174,7 @@ export class PlanEngineService { ...@@ -172,6 +174,7 @@ export class PlanEngineService {
plansSuperseded, plansSuperseded,
plansUnchanged, plansUnchanged,
plansSkippedAssigned, plansSkippedAssigned,
plansSuppressed,
durationMs: Date.now() - startedAt.getTime(), durationMs: Date.now() - startedAt.getTime(),
}; };
} }
...@@ -181,7 +184,7 @@ export class PlanEngineService { ...@@ -181,7 +184,7 @@ export class PlanEngineService {
scope: ScenarioScope; scope: ScenarioScope;
patientId: string; patientId: string;
hits: ScenarioHitWithKey[]; hits: ScenarioHitWithKey[];
}): Promise<'created' | 'superseded' | 'unchanged' | 'skipped_assigned'> { }): Promise<'created' | 'superseded' | 'unchanged' | 'skipped_assigned' | 'suppressed'> {
const { scope, patientId, hits } = input; const { scope, patientId, hits } = input;
const latest = await this.prisma.followupPlan.findFirst({ const latest = await this.prisma.followupPlan.findFirst({
...@@ -195,8 +198,23 @@ export class PlanEngineService { ...@@ -195,8 +198,23 @@ export class PlanEngineService {
return 'skipped_assigned'; return 'skipped_assigned';
} }
const newPriorityScore = Math.max(...hits.map((h) => h.priorityScore)); // ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)。
const newReasonSet = new Set(hits.map((h) => h.scenarioKey + ':' + (h.subKey ?? ''))); // 只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
// 做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
const snoozedKeys = await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits =
snoozedKeys.size === 0
? hits
: hits.filter((h) => !snoozedKeys.has(`${h.scenarioKey}|${h.subKey ?? ''}`));
if (usableHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批)→ 不生成新 plan
return 'suppressed';
}
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
// 比对 active plan 的 reason 是否变化 // 比对 active plan 的 reason 是否变化
// ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario** // ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
...@@ -207,7 +225,7 @@ export class PlanEngineService { ...@@ -207,7 +225,7 @@ export class PlanEngineService {
if (isActive) { if (isActive) {
const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`; const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`;
const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey))); const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey)));
const newSubScenarios = new Set(hits.map((h) => key(h.scenarioKey, h.subKey))); const newSubScenarios = new Set(usableHits.map((h) => key(h.scenarioKey, h.subKey)));
const sameSubScenarios = const sameSubScenarios =
oldSubScenarios.size === newSubScenarios.size && oldSubScenarios.size === newSubScenarios.size &&
[...oldSubScenarios].every((k) => newSubScenarios.has(k)); [...oldSubScenarios].every((k) => newSubScenarios.has(k));
...@@ -226,7 +244,7 @@ export class PlanEngineService { ...@@ -226,7 +244,7 @@ export class PlanEngineService {
// 需要新版本:supersede 旧 + 创建新 // 需要新版本:supersede 旧 + 创建新
const nextVersion = (latest?.version ?? 0) + 1; const nextVersion = (latest?.version ?? 0) + 1;
// head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel) // head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
const head = [...hits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!; const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// 拉对应 patient 的最新 active persona(可空) // 拉对应 patient 的最新 active persona(可空)
const persona = await this.prisma.persona.findFirst({ const persona = await this.prisma.persona.findFirst({
...@@ -262,7 +280,7 @@ export class PlanEngineService { ...@@ -262,7 +280,7 @@ export class PlanEngineService {
// 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。 // 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。
// UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。 // UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。
createMany: { createMany: {
data: hits.map((h) => ({ data: usableHits.map((h) => ({
scenario: h.scenarioKey, scenario: h.scenarioKey,
subKey: h.subKey ?? null, subKey: h.subKey ?? null,
priorityScore: h.priorityScore, priorityScore: h.priorityScore,
...@@ -292,6 +310,39 @@ export class PlanEngineService { ...@@ -292,6 +310,39 @@ export class PlanEngineService {
return latest ? 'superseded' : 'created'; return latest ? 'superseded' : 'created';
} }
/**
* 信号级抑制集 —— 该患者所有"终态(completed/abandoned)+ 冷静期未到期(snoozedUntil>now)"plan
* 覆盖的召回理由 (scenario, subKey) 并集。
*
* 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
* 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集,而非只看最新一条。
* 返回 key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
*/
private async fetchSnoozedSignalKeys(
scope: ScenarioScope,
patientId: string,
now: Date,
): Promise<Set<string>> {
const terminalPlans = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
},
select: { reasons: { select: { scenario: true, subKey: true } } },
});
const keys = new Set<string>();
for (const plan of terminalPlans) {
for (const r of plan.reasons) {
keys.add(`${r.scenario}|${r.subKey ?? ''}`);
}
}
return keys;
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效) /// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
private lifecycleFor(scenario: string, subKey?: string): string { private lifecycleFor(scenario: string, subKey?: string): string {
// 复查类长效(种植年度 / 牙周维护),其余短效 // 复查类长效(种植年度 / 牙周维护),其余短效
...@@ -309,5 +360,6 @@ export interface EngineRunResult { ...@@ -309,5 +360,6 @@ export interface EngineRunResult {
plansSuperseded: number; plansSuperseded: number;
plansUnchanged: number; plansUnchanged: number;
plansSkippedAssigned: number; plansSkippedAssigned: number;
plansSuppressed: number;
durationMs: number; durationMs: number;
} }
...@@ -149,8 +149,11 @@ export function computeLikelihoodBonus( ...@@ -149,8 +149,11 @@ export function computeLikelihoodBonus(
// recall_risk:0=none / 1=low / 2=medium / 3=high — 越高触达可能性越低 // recall_risk:0=none / 1=low / 2=medium / 3=high — 越高触达可能性越低
const risk = riskScore ?? 1; const risk = riskScore ?? 1;
const riskBonus = Math.max(0, Math.round((3 - risk) * 2)); // 0~6 const riskBonus = Math.max(0, Math.round((3 - risk) * 2)); // 0~6
// 仅"真实正向进展"加权:成功转化 / 改约(都已敲定新时间)。
// 注:'considering'(考虑中)已移除 — 软意向不该把患者顶到列表最前天天打;
// 且 considering 现在走 snoozedUntil 冷静期,不应再额外加权(否则与抑制窗自相矛盾)。
const recentSuccessBonus = recentExecutions.some((e) => const recentSuccessBonus = recentExecutions.some((e) =>
['success_appointed', 'rescheduled', 'considering'].includes(e.outcome), ['success_appointed', 'rescheduled'].includes(e.outcome),
) )
? 4 ? 4
: 0; : 0;
......
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types'; import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { resolveSnoozedUntil } from './recall-suppression';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/** /**
...@@ -22,6 +23,10 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -22,6 +23,10 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
const MAX_CONTACT_ATTEMPTS_DEFAULT = 4; // 暂常量,后续接 host/scenario 配置 const MAX_CONTACT_ATTEMPTS_DEFAULT = 4; // 暂常量,后续接 host/scenario 配置
/// 约定回访后,给经办人在回访日之后再留多久才允许自动回收(宽限期)。
/// 让"约定回访"的 plan 在回访日当天仍归经办人,不会被 24h 超时提前回收 / 回访日一到就被别人捞走。
const RECYCLE_GRACE_AFTER_SNOOZE_MS = 24 * 3600_000;
/** /**
* outcome → plan.status 映射(spec § "按 outcome 驱动 Plan.status"): * outcome → plan.status 映射(spec § "按 outcome 驱动 Plan.status"):
* completed = 闭环成功 / 不再需要召回(转化 / 外院 / 明确拒绝 / 无效) * completed = 闭环成功 / 不再需要召回(转化 / 外院 / 明确拒绝 / 无效)
...@@ -48,6 +53,8 @@ export interface SubmitExecutionInput { ...@@ -48,6 +53,8 @@ export interface SubmitExecutionInput {
abandonReasons?: string[]; abandonReasons?: string[];
abandonOther?: string; abandonOther?: string;
scheduledNextAt?: string; scheduledNextAt?: string;
/// 召回反馈 tag(选填·多选)— 对召回算法准确性的反馈,不参与状态机
recallFeedback?: string[];
invalidReason?: string; invalidReason?: string;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */ /** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId?: string; executorClinicId?: string;
...@@ -127,6 +134,17 @@ export class ExecutionService { ...@@ -127,6 +134,17 @@ export class ExecutionService {
} }
} }
// ─── 3.5 计算 snoozedUntil(召回闭环核心,见 resolveSnoozedUntil)───
// - 终态 + snoozedUntil → plan 引擎 upsert 时不重新生成(防熔断/拒绝被重算复活)
// - keep 类 + snoozedUntil → active plan 到期前不进召回池(到点浮现)
const now = new Date();
const snoozedUntil = resolveSnoozedUntil({
outcome: input.outcome,
breakerTripped,
scheduledNextAt: input.scheduledNextAt,
now,
});
// ─── 4. 事务:写 execution + 更新 plan ─── // ─── 4. 事务:写 execution + 更新 plan ───
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
const execution = await tx.planExecution.create({ const execution = await tx.planExecution.create({
...@@ -143,6 +161,7 @@ export class ExecutionService { ...@@ -143,6 +161,7 @@ export class ExecutionService {
abandonReasons: input.abandonReasons ?? [], abandonReasons: input.abandonReasons ?? [],
abandonOther: input.abandonOther ?? null, abandonOther: input.abandonOther ?? null,
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null, scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
recallFeedback: input.recallFeedback ?? [],
}, },
select: { id: true }, select: { id: true },
}); });
...@@ -152,10 +171,19 @@ export class ExecutionService { ...@@ -152,10 +171,19 @@ export class ExecutionService {
data: { data: {
status: newStatus, status: newStatus,
contactAttempts: nextContactAttempts, contactAttempts: nextContactAttempts,
// 终态额外清理 assignee(让 plan 离开"我的工单"列表) // 召回冷静期 / 终态抑制窗(null 时显式清空 → 重新触达后老 snooze 不残留)
snoozedUntil,
// 终态:只清自动回收 deadline(不再相关)。
// ⭐ 保留 assigneeUserId / assignedAt 作业绩归属 —— 让"我的已完成" tab 看得到、
// "我完成"KPI + 转化率算得对。离开"进行中"靠 status 变化(列表按 status 过滤),
// 不靠清 assignee(清了会让 mineCompleted / 转化率 永远为 0)。
// keep 类 + 有 snooze(约定回访 6/10 等):把 recycleAt 顺延到回访日 + 宽限,
// 否则分配 24h 后 recycleAt 一过,回收 cron 会把约了回访的 plan 从经办人手里收走。
...(newStatus === 'completed' || newStatus === 'abandoned' ...(newStatus === 'completed' || newStatus === 'abandoned'
? { assigneeUserId: null, assignedAt: null, recycleAt: null } ? { recycleAt: null }
: {}), : snoozedUntil
? { recycleAt: new Date(snoozedUntil.getTime() + RECYCLE_GRACE_AFTER_SNOOZE_MS) }
: {}),
}, },
}); });
...@@ -197,6 +225,7 @@ export class ExecutionService { ...@@ -197,6 +225,7 @@ export class ExecutionService {
notes: e.notes, notes: e.notes,
invalidReason: e.invalidReason, invalidReason: e.invalidReason,
abandonReasons: e.abandonReasons, abandonReasons: e.abandonReasons,
recallFeedback: e.recallFeedback,
abandonOther: e.abandonOther, abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null, scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(), createdAt: e.createdAt.toISOString(),
......
...@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller'; import { PlanController } from './plan.controller';
import { PlanService } from './plan.service'; import { PlanService } from './plan.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { RecycleSchedulerService } from './recycle-scheduler.service';
import { PlanEngineService } from './engine/plan-engine.service'; import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service'; import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario'; import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
...@@ -15,6 +16,7 @@ import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment- ...@@ -15,6 +16,7 @@ import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-
providers: [ providers: [
PlanService, PlanService,
ExecutionService, ExecutionService,
RecycleSchedulerService,
PlanEngineService, PlanEngineService,
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
......
...@@ -66,7 +66,15 @@ export class PlanService { ...@@ -66,7 +66,15 @@ export class PlanService {
if (view === 'pool') { if (view === 'pool') {
where.status = 'active'; where.status = 'active';
where.assigneeUserId = null; where.assigneeUserId = null;
// 回访调度:snooze 未到期的不进召回池(约定回访 / 考虑中 / 近期不考虑 等到点自动浮现)。
// 仅作用于 pool 视图 — 'mine' 仍展示我 snooze 的工单(客服看得到自己的回访安排)。
where.AND = [
{ OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: new Date() } }] },
];
} else if (view === 'mine') { } else if (view === 'mine') {
// "全部我的" = 我名下所有状态(active/assigned/completed/abandoned);
// 具体状态由前端「进行中 / 已完成 / 已放弃」子 tab 通过 query.status 显式过滤。
// (结案现在保留 assignee,所以"已完成"能查到。)
where.assigneeUserId = scope.userId; where.assigneeUserId = scope.userId;
} else if (view === 'all') { } else if (view === 'all') {
if (!permissions.includes(Permission.PLAN_VIEW_ALL)) { if (!permissions.includes(Permission.PLAN_VIEW_ALL)) {
...@@ -417,6 +425,7 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan { ...@@ -417,6 +425,7 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
evidence, evidence,
status: p.status as import('@pac/types').PlanStatus, status: p.status as import('@pac/types').PlanStatus,
recycleAt: p.recycleAt?.toISOString() ?? null, recycleAt: p.recycleAt?.toISOString() ?? null,
snoozedUntil: p.snoozedUntil?.toISOString() ?? null,
assigneeUserId: p.assigneeUserId, assigneeUserId: p.assigneeUserId,
assignedAt: p.assignedAt?.toISOString() ?? null, assignedAt: p.assignedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
...@@ -519,6 +528,7 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu ...@@ -519,6 +528,7 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu
abandonReasons: e.abandonReasons, abandonReasons: e.abandonReasons,
abandonOther: e.abandonOther, abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null, scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
recallFeedback: e.recallFeedback,
createdAt: e.createdAt.toISOString(), createdAt: e.createdAt.toISOString(),
}; };
} }
......
import {
EXECUTION_OUTCOME_META,
ExecutionOutcome,
BREAKER_SUPPRESS_DAYS,
} from '@pac/types';
const DAY_MS = 86_400_000;
/**
* 计算 execution 回写后的 snoozedUntil(召回冷静期 / 终态抑制窗)—— 纯函数,便于单测。
*
* 优先级(高 → 低):
* 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d),
* 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。
* 2. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。
* 3. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
* 4. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
*
* @returns snoozedUntil Date,或 null(不抑制)
*/
export function resolveSnoozedUntil(params: {
outcome: string;
breakerTripped: boolean;
scheduledNextAt?: string | null;
now: Date;
}): Date | null {
const { outcome, breakerTripped, scheduledNextAt, now } = params;
if (breakerTripped) {
return new Date(now.getTime() + BREAKER_SUPPRESS_DAYS * DAY_MS);
}
const meta = EXECUTION_OUTCOME_META[outcome as ExecutionOutcome];
if (meta?.suppressDays != null) {
return new Date(now.getTime() + meta.suppressDays * DAY_MS);
}
if (scheduledNextAt) {
return new Date(scheduledNextAt);
}
return null;
}
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
/**
* RecycleSchedulerService — 召回工单超时自动回收(GAP3 闭环)
*
* 背景:assign 时锁定 recycleAt = assignedAt + RECYCLE_TIMEOUT_HOURS(默认 24h)。
* 旧实现只写了 deadline 却**没有任何 cron 执行回收** —— 客服领了工单去休假 / 漏跟,
* plan 永远停在 assigned,plan 引擎 upsert 时 `skipped_assigned` 永久跳过,
* 这个患者既不会被别人捡走、也不会更新。本服务补上自动回收。
*
* 行为:每 10 分钟扫一遍,把"已分配 + 已过 recycleAt"的 plan 退回召回池:
* status: assigned → active;清空 assignee / assignedAt / recycleAt。
* 用 updateMany 一条 SQL 批量处理(无需逐行),幂等、并发安全。
*
* 注:手动回收(POST /plans/{id}/recycle)仍保留,本 cron 只兜底超时未结案的。
*/
@Injectable()
export class RecycleSchedulerService {
private readonly logger = new Logger(RecycleSchedulerService.name);
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-recycle-stale' })
async runRecycle(): Promise<void> {
const now = new Date();
const r = await this.prisma.followupPlan.updateMany({
where: {
status: 'assigned',
recycleAt: { not: null, lt: now },
// ⭐ 跳过仍在冷静期/约定回访中的 plan(snoozedUntil 在未来)。
// 场景:客服约了 6/10 回访,plan snooze 到 6/10;在那之前**不能**因 24h 超时被回收,
// 否则客服丢了已承诺的回访关系、6/10 一到还会被别人从池里捞走。
// 回访日过后(snoozedUntil<=now)若仍未处理,才允许回收。
OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
},
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
},
});
if (r.count > 0) {
this.logger.log(`auto-recycle: ${r.count} 个超时未结案工单退回召回池`);
}
}
}
import {
EXECUTION_OUTCOME_META,
SUPPRESS_PERMANENT_DAYS,
BREAKER_SUPPRESS_DAYS,
} from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import { computeLikelihoodBonus } from '../src/modules/plan/engine/priority-scorer';
/**
* 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
*
* 修复的核心 bug(GAP1):终态 plan(completed/abandoned)在重算时被当成"没有活动 plan"
* 直接新建一版 active → "明确拒绝 / 4 次未接通熔断"第二天就复活、患者重回呼叫池。
* 现在 execution 回写按 outcome 算 snoozedUntil,plan 引擎 upsert 在抑制期内返回 suppressed。
*/
describe('召回抑制窗 snoozedUntil 计算', () => {
const NOW = new Date('2026-06-01T00:00:00Z');
const daysFromNow = (d: Date | null) =>
d == null ? null : Math.round((d.getTime() - NOW.getTime()) / 86_400_000);
test('明确拒绝 refused → 90d 冷静期', () => {
const r = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(90);
});
test('近期不考虑 declined_recent → 90d(虽 keep 类,仍冷静期内不进池)', () => {
const r = resolveSnoozedUntil({ outcome: 'declined_recent', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(90);
});
test('成功转化 success_appointed → 60d(覆盖预约→治疗→摄入延迟,避免重复召)', () => {
const r = resolveSnoozedUntil({ outcome: 'success_appointed', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(60);
});
test('客服放弃 abandoned → 60d', () => {
const r = resolveSnoozedUntil({ outcome: 'abandoned', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(60);
});
test('外院治疗 external_treatment → 永久(真闭环)', () => {
const r = resolveSnoozedUntil({ outcome: 'external_treatment', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(SUPPRESS_PERMANENT_DAYS);
});
test('标记无效 marked_invalid → 永久', () => {
const r = resolveSnoozedUntil({ outcome: 'marked_invalid', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(SUPPRESS_PERMANENT_DAYS);
});
test('熔断优先于 outcome 自身策略 → 固定 30d(联系不上,非拒绝,换天再试)', () => {
// outcome=no_answer 本身 suppressDays=null,但熔断触发 → 30d
const r = resolveSnoozedUntil({ outcome: 'no_answer', breakerTripped: true, now: NOW });
expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
});
test('熔断即便发生在终态 outcome 上,也用 30d 而非 outcome 的窗', () => {
// 防御:breaker 优先级最高
const r = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: true, now: NOW });
expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
});
test('keep 类带 scheduledNextAt(约定回访)→ snooze 到该时间点', () => {
const next = '2026-06-15T02:00:00Z';
const r = resolveSnoozedUntil({
outcome: 'scheduled_next',
breakerTripped: false,
scheduledNextAt: next,
now: NOW,
});
expect(r?.toISOString()).toBe(new Date(next).toISOString());
});
test('考虑中 considering 无 scheduledNextAt → 不 snooze(null,维持现状)', () => {
const r = resolveSnoozedUntil({ outcome: 'considering', breakerTripped: false, now: NOW });
expect(r).toBeNull();
});
test('未接通 no_answer 无 scheduledNextAt → 不 snooze', () => {
const r = resolveSnoozedUntil({ outcome: 'no_answer', breakerTripped: false, now: NOW });
expect(r).toBeNull();
});
});
describe('优先级:considering 不再加权(GAP2)', () => {
test('考虑中不应把患者顶到列表最前(冷静期内 + 不加权)', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'considering' }])).toBe(0);
});
test('真实正向进展(成功转化 / 改约)仍 +4', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'success_appointed' }])).toBe(4);
expect(computeLikelihoodBonus(3, [{ outcome: 'rescheduled' }])).toBe(4);
});
test('无近期正向执行 → 仅 riskBonus', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'no_answer' }])).toBe(0); // risk=3 → riskBonus 0
expect(computeLikelihoodBonus(0, [])).toBe(6); // risk=0 → (3-0)*2=6
});
});
describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () => {
test('每个 outcome 都声明了 suppressDays(number 或 null),无遗漏', () => {
for (const [k, v] of Object.entries(EXECUTION_OUTCOME_META)) {
expect(v).toHaveProperty('suppressDays');
expect(v.suppressDays === null || typeof v.suppressDays === 'number').toBe(true);
if (typeof v.suppressDays === 'number') {
expect(v.suppressDays).toBeGreaterThan(0);
}
void k;
}
});
});
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
...@@ -17,14 +17,17 @@ ...@@ -17,14 +17,17 @@
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next": "^16.2.4", "next": "^16.2.4",
"react": "^19.2.5", "react": "^19.2.5",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
......
...@@ -141,6 +141,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -141,6 +141,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
}, },
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now, assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null, recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null,
updatedAt: real.plan?.updatedAt ? new Date(real.plan.updatedAt) : null, updatedAt: real.plan?.updatedAt ? new Date(real.plan.updatedAt) : null,
recommendedAt: real.plan?.recommendedAt ? new Date(real.plan.recommendedAt) : now, recommendedAt: real.plan?.recommendedAt ? new Date(real.plan.recommendedAt) : now,
recommendedRole: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF, recommendedRole: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
...@@ -180,6 +181,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -180,6 +181,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
sections: real.script.sections, sections: real.script.sections,
} as typeof mockScript) } as typeof mockScript)
: mockScript, : mockScript,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [],
// outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import // outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import
fmtRel, fmtRel,
}; };
......
...@@ -16,6 +16,7 @@ export interface SubmitExecutionBody { ...@@ -16,6 +16,7 @@ export interface SubmitExecutionBody {
abandonReasons?: string[]; abandonReasons?: string[];
abandonOther?: string; abandonOther?: string;
scheduledNextAt?: string; scheduledNextAt?: string;
recallFeedback?: string[];
} }
/** /**
......
...@@ -289,6 +289,8 @@ export const mockPlan = { ...@@ -289,6 +289,8 @@ export const mockPlan = {
assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole }, assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole },
assignedAt: NOW, assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null, recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null,
updatedAt: NOW as Date | null, updatedAt: NOW as Date | null,
recommendedAt: NOW, recommendedAt: NOW,
recommendedRole: UserRole.STAFF as UserRole, recommendedRole: UserRole.STAFF as UserRole,
......
...@@ -17,7 +17,12 @@ import { ...@@ -17,7 +17,12 @@ import {
formatGender, formatGender,
formatDaysReadable, formatDaysReadable,
} from '@/lib/utils'; } from '@/lib/utils';
import { PersonaFeatureKey, treatmentCategoryNameZh } from '@pac/types'; import {
PersonaFeatureKey,
treatmentCategoryNameZh,
EXECUTION_OUTCOME_META,
type ExecutionOutcome,
} from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared'; import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover'; import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display'; import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display';
...@@ -47,6 +52,17 @@ import { submitExecution, adaptAbandonReasons } from './execution-api'; ...@@ -47,6 +52,17 @@ import { submitExecution, adaptAbandonReasons } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve) /// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash'; export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash';
/// 召回历史一条(患者级,跨所有 plan 版本)— 后端 plan-aggregate.recallHistory
export type RecallHistoryItem = {
id: string;
outcome: string;
channel: string;
notes: string | null;
scheduledNextAt: string | null;
createdAt: string;
planVersion: number | null;
};
export type PlanDetailAppData = { export type PlanDetailAppData = {
patient: typeof mockPatient; patient: typeof mockPatient;
chains: typeof mockChains; chains: typeof mockChains;
...@@ -56,6 +72,8 @@ export type PlanDetailAppData = { ...@@ -56,6 +72,8 @@ export type PlanDetailAppData = {
facts?: AdaptedFact[]; facts?: AdaptedFact[];
summaries: typeof mockSummaries; summaries: typeof mockSummaries;
script: typeof mockScript; script: typeof mockScript;
/// 召回历史(患者级)— 可选,缺省空数组
recallHistory?: RecallHistoryItem[];
fmtRel: typeof mockFmtRel; fmtRel: typeof mockFmtRel;
}; };
...@@ -84,6 +102,7 @@ export function PlanDetailApp({ ...@@ -84,6 +102,7 @@ export function PlanDetailApp({
}) { }) {
const { patient, chains, persona, plan, summaries, script, fmtRel } = data; const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
const facts = data.facts ?? []; const facts = data.facts ?? [];
const recallHistory = data.recallHistory ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash // 话术生成模型(具体型号);默认 deepseek-v4-flash
...@@ -170,6 +189,7 @@ export function PlanDetailApp({ ...@@ -170,6 +189,7 @@ export function PlanDetailApp({
notes: string; notes: string;
scheduledNextAt: string; scheduledNextAt: string;
abandonReasons: string[]; abandonReasons: string[];
recallFeedback: string[];
}) => { }) => {
try { try {
const { abandonReasons, abandonOther } = adaptAbandonReasons(formData.abandonReasons); const { abandonReasons, abandonOther } = adaptAbandonReasons(formData.abandonReasons);
...@@ -182,6 +202,7 @@ export function PlanDetailApp({ ...@@ -182,6 +202,7 @@ export function PlanDetailApp({
scheduledNextAt: formData.scheduledNextAt scheduledNextAt: formData.scheduledNextAt
? new Date(formData.scheduledNextAt).toISOString() ? new Date(formData.scheduledNextAt).toISOString()
: undefined, : undefined,
recallFeedback: formData.recallFeedback.length > 0 ? formData.recallFeedback : undefined,
}); });
// 本地立刻反映 plan 新态(不等下次拉数据) // 本地立刻反映 plan 新态(不等下次拉数据)
...@@ -255,6 +276,9 @@ export function PlanDetailApp({ ...@@ -255,6 +276,9 @@ export function PlanDetailApp({
persona={persona} persona={persona}
facts={facts} facts={facts}
/> />
{recallHistory.length > 0 && (
<RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
)}
<SidebarCard <SidebarCard
title="治疗链" title="治疗链"
meta={`${chains.length} 条`} meta={`${chains.length} 条`}
...@@ -1105,6 +1129,21 @@ function SuggestionCard({ ...@@ -1105,6 +1129,21 @@ function SuggestionCard({
</span> </span>
</div> </div>
)} )}
{/* 召回冷静期 / 暂缓 — snoozedUntil 未到期时提示客服"到点再跟,别现在打" */}
{plan.snoozedUntil && plan.snoozedUntil.getTime() > Date.now() && (
<div className="flex items-start gap-2 text-[11px] py-0.5 mt-1 bg-teal-50/60 rounded px-1 -mx-1">
<span className="flex-none w-5 h-5 rounded flex items-center justify-center mt-px bg-teal-100 text-teal-700">
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
</svg>
</span>
<span className="w-14 flex-none mt-0.5 text-teal-700 font-semibold">暂缓跟进</span>
<span className="font-medium flex-1 leading-snug min-w-0 text-teal-900 break-words">
{snoozeLabel(plan.snoozedUntil)}
</span>
</div>
)}
</div> </div>
<div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between"> <div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between">
<span className="text-[10.5px] text-slate-500"> <span className="text-[10.5px] text-slate-500">
...@@ -1130,6 +1169,56 @@ function SuggestionCard({ ...@@ -1130,6 +1169,56 @@ function SuggestionCard({
); );
} }
/// 日期 → "M月D日"(使用者习惯,不用 7/9 这种易歧义格式)
function mdLabel(d: Date): string {
return `${d.getMonth() + 1}${d.getDate()}日`;
}
/// snooze 到期时间 → 人话。永久哨兵(>~3 年)显示"长期暂缓",否则显示日期 + 剩余天数。
function snoozeLabel(until: Date): string {
const days = Math.ceil((until.getTime() - Date.now()) / 86_400_000);
if (days > 1000) return '长期暂缓(已闭环 / 标记无效)';
return `${mdLabel(until)} 后再跟进(约 ${days} 天)`;
}
/// RecallHistoryCard — 患者级召回历史(跨所有 plan 版本)。
/// 价值:plan 再次被召回时,客服看得到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
function RecallHistoryCard({
history,
fmtRel,
}: {
history: RecallHistoryItem[];
fmtRel: typeof mockFmtRel;
}) {
return (
<SidebarCard title="召回历史" meta={`${history.length} 次`}>
<div className="space-y-1.5">
{history.map((h) => {
const meta = EXECUTION_OUTCOME_META[h.outcome as ExecutionOutcome];
// 统一灰色样式(不按结果分色)— 跟"未接通"一致,弱化历史、突出当前
return (
<div key={h.id} className="flex items-start gap-2 text-[11px] py-0.5">
<span className="flex-none mt-[3px] w-1.5 h-1.5 rounded-full bg-slate-300" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-slate-600">{meta?.labelZh ?? h.outcome}</span>
<span className="text-slate-400 tabular-nums">{fmtRel(new Date(h.createdAt))}</span>
{h.scheduledNextAt && (
<span className="text-slate-400">· 约 {mdLabel(new Date(h.scheduledNextAt))}回访</span>
)}
</div>
{h.notes && (
<div className="text-slate-500 leading-snug break-words mt-0.5">{h.notes}</div>
)}
</div>
</div>
);
})}
</div>
</SidebarCard>
);
}
/** /**
* 计算风险规避提示 — PAC 已有数据驱动 * 计算风险规避提示 — PAC 已有数据驱动
* *
......
...@@ -51,6 +51,9 @@ export type PlanDetailData = { ...@@ -51,6 +51,9 @@ export type PlanDetailData = {
assigneeUserId: string | null; assigneeUserId: string | null;
assignedAt: string | null; assignedAt: string | null;
recycleAt: string | null; recycleAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
/// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: string | null;
/// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度 /// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度
updatedAt: string; updatedAt: string;
reasons: Array<{ reasons: Array<{
...@@ -169,4 +172,15 @@ export type PlanDetailData = { ...@@ -169,4 +172,15 @@ export type PlanDetailData = {
markdown: string; markdown: string;
}>; }>;
} | null; } | null;
/// 召回历史(患者级,跨所有 plan 版本,最近 8 条)。
/// 用途:plan 再次被召回时,客服看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
recallHistory?: Array<{
id: string;
outcome: string; // raw enum,前端用 EXECUTION_OUTCOME_META 翻译 label/tone
channel: string;
notes: string | null;
scheduledNextAt: string | null;
createdAt: string;
planVersion: number | null;
}>;
}; };
...@@ -87,7 +87,7 @@ const DENSITY = { ...@@ -87,7 +87,7 @@ const DENSITY = {
const STATUS_META: Record<string, { label: string; tone: string }> = { const STATUS_META: Record<string, { label: string; tone: string }> = {
active: { label: '待认领', tone: 'bg-sky-100 text-sky-700' }, active: { label: '待认领', tone: 'bg-sky-100 text-sky-700' },
assigned: { label: '我的工单', tone: 'bg-amber-100 text-amber-700' }, assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' },
completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' }, completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' },
abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' }, abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' },
superseded: { label: '已替代', tone: 'bg-slate-100 text-slate-600' }, superseded: { label: '已替代', tone: 'bg-slate-100 text-slate-600' },
......
'use client';
import * as React from 'react';
import { DayPicker } from 'react-day-picker';
import 'react-day-picker/style.css';
import { cn } from '@/lib/utils';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
/// shadcn 风格日历(react-day-picker v10)。teal 主题靠覆写 rdp 内置 CSS 变量,
/// 透传所有 DayPicker props(mode / captionLayout / disabled / startMonth / endMonth …)。
export function Calendar({ className, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays
className={cn('p-2', className)}
style={
{
'--rdp-accent-color': '#0d9488',
'--rdp-accent-background-color': '#f0fdfa',
'--rdp-day-width': '2rem',
'--rdp-day-height': '2rem',
'--rdp-day_button-width': '2rem',
'--rdp-day_button-height': '2rem',
'--rdp-font-family': 'inherit',
fontSize: '12.5px',
} as React.CSSProperties
}
{...props}
/>
);
}
'use client';
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { Button } from './button';
import { Calendar } from './calendar';
import { Popover, PopoverTrigger, PopoverContent } from './popover';
/// 纯日期选择器(shadcn「Date Picker」模式:outline Button + ChevronDown 触发 → Popover + Calendar)。
/// 时间另用 <input type="time">,不在此组件内(对齐 shadcn Date Picker with Time 示例)。
export function DatePicker({
value,
onChange,
placeholder = '选择日期',
min,
}: {
value?: Date;
onChange: (d: Date | undefined) => void;
placeholder?: string;
min?: Date;
}) {
const [open, setOpen] = React.useState(false);
// 年份下拉范围:当前月 → +2 年(回访不会更远),captionLayout=dropdown 用
const endMonth = new Date(new Date().getFullYear() + 2, 11);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between font-normal h-9 text-[12.5px]">
{value ? (
`${value.getFullYear()}年${value.getMonth() + 1}月${value.getDate()}日`
) : (
<span className="text-slate-400">{placeholder}</span>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={value}
captionLayout="dropdown"
defaultMonth={value ?? min}
startMonth={min}
endMonth={endMonth}
disabled={min ? { before: min } : undefined}
onSelect={(d) => {
onChange(d);
if (d) setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-auto rounded-md border border-slate-200 bg-white p-2 shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
...@@ -83,18 +83,18 @@ PAC 的算法连成一条流水线,客服拿到的是排好序、带背景、带 ...@@ -83,18 +83,18 @@ PAC 的算法连成一条流水线,客服拿到的是排好序、带背景、带
**每种病的默认调参值**(下表是**出厂默认**,列出来作基线对照——万一线上行为跟预期偏了,先核是不是配置被调过): **每种病的默认调参值**(下表是**出厂默认**,列出来作基线对照——万一线上行为跟预期偏了,先核是不是配置被调过):
| 诊断码 | 起步分(base) | 冷静期(天)<br/>诊断后多久才召 | 黄金窗末(天)<br/>超过开始衰减 | 紧迫临界(天)<br/>超过加紧迫分 | | 诊断码 | 起步分(base) | 冷静期(天)<br/>诊断后多久才召 | 黄金窗末(天)<br/>超过开始衰减 | 紧迫临界(天)<br/>超过加紧迫分 |
|---|---|---|---|---| | -------- | --------- | ------------------ | ------------------ | ------------------ |
| K08 缺失牙 | **60** | 30 | 180 | 120 | | K08 缺失牙 | **60** | 30 | 180 | 120 |
| K07 正畸 | 55 | 30 | 365 | 180 | | K07 正畸 | 55 | 30 | 365 | 180 |
| K04 根管 | 52 | 14 | 60 | 45 | | K04 根管 | 52 | 14 | 60 | 45 |
| K05 牙周 | 50 | 30 | 120 | 90 | | K05 牙周 | 50 | 30 | 120 | 90 |
| K09 颌骨囊肿 | 50 | 14 | 90 | 60 | | K09 颌骨囊肿 | 50 | 14 | 90 | 60 |
| K02 龋齿 | 45 | 14 | 90 | 60 | | K02 龋齿 | 45 | 14 | 90 | 60 |
| K03 牙体损伤 | 35 | 14 | 90 | 60 | | K03 牙体损伤 | 35 | 14 | 90 | 60 |
| K06 牙龈牙槽 | 35 | 14 | 120 | 60 | | K06 牙龈牙槽 | 35 | 14 | 120 | 60 |
| K01 阻生牙 | 30 | 14 | 180 | 90 | | K01 阻生牙 | 30 | 14 | 180 | 90 |
| K00 发育萌出 | 25 | 30 | 365 | 180 | | K00 发育萌出 | 25 | 30 | 365 | 180 |
> 📐 **证据 & 真理源**:起步分在 `SUB_SCENARIOS[*].base`;冷静期/黄金窗/紧迫临界在 > 📐 **证据 & 真理源**:起步分在 `SUB_SCENARIOS[*].base`;冷静期/黄金窗/紧迫临界在
> `DiagnosisTreatmentMap[K??]`(`packages/types/src/canonical-codes.ts`)。 > `DiagnosisTreatmentMap[K??]`(`packages/types/src/canonical-codes.ts`)。
......
...@@ -515,39 +515,93 @@ export type ExecutionOutcomeDrives = 'completed' | 'abandoned' | 'keep'; ...@@ -515,39 +515,93 @@ export type ExecutionOutcomeDrives = 'completed' | 'abandoned' | 'keep';
/// outcome 的 UI tone(对应 shadcn 调色板),前端按按钮渲染 /// outcome 的 UI tone(对应 shadcn 调色板),前端按按钮渲染
export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose'; export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose';
/// ExecutionOutcome 单一真理源(label / tone / 状态机) /// "永久"抑制的天数哨兵(患者级粒度下 = 该患者长期不再自动召回,除非人工重激活)。
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
/// 短冷静期后允许重新进池(后续可接换渠道策略)。
export const BREAKER_SUPPRESS_DAYS = 30;
/// 通话结果分组(UI 归类)。三大类正好对应状态机三态 + 列表三个 tab:
/// 结案 close → completed →「已完成」 / 保持 keep → keep →「进行中」 / 放弃 give_up → abandoned →「已放弃」
export type ExecutionOutcomeGroup = 'close' | 'keep' | 'give_up';
/// 分组元数据(label / 显示顺序 / tone)。outcome-form 据此渲染分组标题。
export const EXECUTION_OUTCOME_GROUP_META: Record<
ExecutionOutcomeGroup,
{ labelZh: string; order: number; tone: ExecutionOutcomeTone }
> = {
close: { labelZh: '结案', order: 1, tone: 'emerald' },
keep: { labelZh: '保持', order: 2, tone: 'amber' },
give_up: { labelZh: '放弃', order: 3, tone: 'rose' },
};
/// ExecutionOutcome 单一真理源(group / label / tone / 状态机 / 抑制窗)
/// ///
/// 用法: /// 用法:
/// - 前端 outcome-form 直接遍历这个 map 渲染按钮 + state hint /// - 前端 outcome-form 按 group 2 级渲染(大类 → 具体项)+ state hint
/// - 后端 execution.service.ts OUTCOME_TO_STATUS 派生自 drivesStatus /// - 后端 execution.service.ts OUTCOME_TO_STATUS 派生自 drivesStatus
/// → 前后端口径完全对齐,改 enum / 改 drivesStatus 一处改完处处生效 /// - 后端 execution.service.ts 用 suppressDays 算 snoozedUntil(回访冷静期 / 终态抑制窗)
/// → 前后端口径完全对齐,改 enum / group / drivesStatus / suppressDays 一处改完处处生效
/// ///
/// 顺序 = UI 显示顺序(JS 对象迭代保留插入顺序),按业务"喜好度 / 终态优先"排: /// suppressDays 语义(召回闭环核心):提交该 outcome 后,患者多久内不再自动进召回池
/// 1. 成功类(emerald)→ 2. 在途类(amber/sky)→ 3. 未触达(slate)→ 4. 终态拒绝(rose) /// - 数字 N — 写 snoozedUntil = now + N 天;到期自愈(重新可召回 / 重进池)
/// - null — 不设固定冷静期(若执行带 scheduledNextAt,则按该时间 snooze;否则维持现状)
/// - PERMANENT — 真闭环(外院治疗 / 无效),患者级长期不再召回
/// 标准档默认值(后续可接 tenant.rules_config 覆盖):
/// 明确拒绝/近期不考虑=90d;成功转化/客服放弃=60d;外院/无效=永久;(熔断另算=30d)
///
/// hiddenInForm:历史 enum 值,新建执行不再展示(如 rescheduled 已并入"约定下次回访"),
/// 但保留在 map 里让历史 plan_executions 仍能翻译 label。
export const EXECUTION_OUTCOME_META: Record< export const EXECUTION_OUTCOME_META: Record<
ExecutionOutcome, ExecutionOutcome,
{ labelZh: string; tone: ExecutionOutcomeTone; drivesStatus: ExecutionOutcomeDrives } {
group: ExecutionOutcomeGroup;
labelZh: string;
tone: ExecutionOutcomeTone;
drivesStatus: ExecutionOutcomeDrives;
/// 抑制天数:number=固定冷静期 / null=不固定(看 scheduledNextAt)
suppressDays: number | null;
/// 新建执行表单是否隐藏(历史值兼容用)
hiddenInForm?: boolean;
}
> = { > = {
// ── 成功转化 ── // ── 结案(completed,「已完成」tab)──
success_appointed: { labelZh: '成功转化为新预约', tone: 'emerald', drivesStatus: 'completed' }, success_appointed: { group: 'close', labelZh: '成功转化为新预约', tone: 'emerald', drivesStatus: 'completed', suppressDays: 60 },
// ── 在途(下次再跟)── declined_recent: { group: 'close', labelZh: '近期不考虑', tone: 'amber', drivesStatus: 'completed', suppressDays: 90 }, // 软结案:90d 后信号级自动复活
scheduled_next: { labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep' }, // ── 保持(keep,留工单「进行中」)──
considering: { labelZh: '考虑中,近期再跟进', tone: 'amber', drivesStatus: 'keep' }, scheduled_next: { group: 'keep', labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日
needs_doctor: { labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep' }, no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null },
rescheduled: { labelZh: '改约', tone: 'sky', drivesStatus: 'keep' }, considering: { group: 'keep', labelZh: '待跟进', tone: 'sky', drivesStatus: 'keep', suppressDays: null }, // 通用待跟进(细节进纪要)
pending_info: { labelZh: '需进一步确认信息', tone: 'slate', drivesStatus: 'keep' }, // ── 放弃(abandoned,「已放弃」tab)──
// ── 未触达 / 弱触达 ── refused: { group: 'give_up', labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 90 },
no_answer: { labelZh: '未接通', tone: 'slate', drivesStatus: 'keep' }, external_treatment: { group: 'give_up', labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
sms_sent: { labelZh: '电话未接,已发短信', tone: 'slate', drivesStatus: 'keep' }, marked_invalid: { group: 'give_up', labelZh: '无效', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
// ── 终态(客户决策)── // ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ──
declined_recent: { labelZh: '近期不考虑', tone: 'rose', drivesStatus: 'keep' }, rescheduled: { group: 'keep', labelZh: '改约', tone: 'amber', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, // 已并入"约定下次回访"
refused: { labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'completed' }, sms_sent: { group: 'keep', labelZh: '未接通·已发短信', tone: 'slate', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
external_treatment: { labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'completed' }, needs_doctor: { group: 'keep', labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
// ── 终态(系统决策)── pending_info: { group: 'keep', labelZh: '需进一步确认信息', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
marked_invalid: { labelZh: '标记为无效', tone: 'rose', drivesStatus: 'abandoned' }, abandoned: { group: 'give_up', labelZh: '客服放弃', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 60, hiddenInForm: true },
abandoned: { labelZh: '客服放弃', tone: 'rose', drivesStatus: 'abandoned' },
}; };
/// 召回反馈(选填·多选)— 一线客服对"这条召回本身准不准"的反馈,**正交于通话结果**。
/// 目的:把一线经验变成算法改进数据 —— 每个 tag 对应一个可改的召回算法维度,可聚合定位系统性问题。
/// info_wrong → 信号抽取 / 数据质量(反查 extraction 规则 / 反馈 DW)
/// already_handled → 排除闸 ⑤a/⑤b 漏(同步延迟 / 补排除)
/// duplicate_contact → 冷静期 / 去重(调 cooldown / 抑制窗)
/// bad_timing → 黄金窗 / 入池窗(调 windowDays)
/// not_worth → 打分 / 场景纳入(调 base 分 / scenario)
export const RECALL_FEEDBACK_OPTIONS = [
{ value: 'info_wrong', labelZh: '信息有误', hint: '诊断/牙位/姓名/电话不对' },
{ value: 'already_handled', labelZh: '其实已处理', hint: '本院/外院做过·已有预约' },
{ value: 'duplicate_contact', labelZh: '重复联系', hint: '最近已联系过' },
{ value: 'bad_timing', labelZh: '时机不对', hint: '太早 / 太晚' },
{ value: 'not_worth', labelZh: '不值得召', hint: '价值低 / 不是真机会' },
] as const;
export type RecallFeedbackTag = (typeof RECALL_FEEDBACK_OPTIONS)[number]['value'];
export const ExecutionChannel = { export const ExecutionChannel = {
PHONE: 'phone', PHONE: 'phone',
WECOM: 'wecom', WECOM: 'wecom',
......
...@@ -45,6 +45,9 @@ export const FollowupPlanSchema = z.object({ ...@@ -45,6 +45,9 @@ export const FollowupPlanSchema = z.object({
evidence: PlanEvidenceSchema, evidence: PlanEvidenceSchema,
status: PlanStatusSchema, status: PlanStatusSchema,
recycleAt: z.string().nullable(), recycleAt: z.string().nullable(),
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
/// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
snoozedUntil: z.string().nullable(),
/// 实际指派给谁(宿主侧 user id);展示用 name 通过 token 字典查 /// 实际指派给谁(宿主侧 user id);展示用 name 通过 token 字典查
assigneeUserId: z.string().nullable(), assigneeUserId: z.string().nullable(),
assignedAt: z.string().nullable(), assignedAt: z.string().nullable(),
...@@ -111,6 +114,8 @@ export const PlanExecutionSchema = z.object({ ...@@ -111,6 +114,8 @@ export const PlanExecutionSchema = z.object({
abandonReasons: z.array(z.string()), abandonReasons: z.array(z.string()),
abandonOther: z.string().nullable(), abandonOther: z.string().nullable(),
scheduledNextAt: z.string().nullable(), scheduledNextAt: z.string().nullable(),
/// 召回反馈(选填·多选)— 对召回算法准确性的反馈,正交于 outcome(见 RECALL_FEEDBACK_OPTIONS)
recallFeedback: z.array(z.string()),
createdAt: z.string().describe('提交时间 ≈ 通话结束时间'), createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
}); });
export type PlanExecution = z.infer<typeof PlanExecutionSchema>; export type PlanExecution = z.infer<typeof PlanExecutionSchema>;
...@@ -214,6 +219,8 @@ export const SubmitExecutionRequestSchema = z.object({ ...@@ -214,6 +219,8 @@ export const SubmitExecutionRequestSchema = z.object({
abandonReasons: z.array(AbandonReasonSchema).max(20).optional(), abandonReasons: z.array(AbandonReasonSchema).max(20).optional(),
abandonOther: z.string().optional(), abandonOther: z.string().optional(),
scheduledNextAt: 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>; export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;
......
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