Commit 5b44e310 by luoqi

feat(admin): 召回归因调试页 — 为什么召/为什么没召

独立 /admin/recall-debug 研发页:为什么召走 plan→reason→fact→transaction 溯源;为什么没召跑引擎 ground-truth + gate 漏斗。支持 ?patientId 深链、姓名搜索(重名)、版本选择、JSON 深解析、rawPayload 懒加载。SUB_SCENARIOS 改 public 供归因交叉核对。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent f0a34afd
......@@ -114,7 +114,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// base 分级:K08 缺牙 60(单价 1.5-3 万)/ K07 正畸 55(单价 3-5 万)/ K04 牙髓 52(防恶化)/
// K05 牙周 50 / K02 龋齿 45 / K03 牙体 35 / K06 牙龈 35 / K01 阻生 30 / K00 萌出 25
// 不漏码原则:host 数据出现的诊断都进召回池,priority 自然衰减(低 base + 慢窗口 → 排到后面)
private static readonly SUB_SCENARIOS = {
// public(原 private):召回归因调试 RecallDebugService 需读 primaryCode↔subKey 映射做交叉核对。
static readonly SUB_SCENARIOS = {
missing_tooth: {
base: 60,
primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
......
......@@ -6,13 +6,15 @@ import { RecycleSchedulerService } from './recycle-scheduler.service';
import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
import { RecallDebugController } from './recall-debug/recall-debug.controller';
import { RecallDebugService } from './recall-debug/recall-debug.service';
/**
* v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。
* 链已完成召回(aftercare)留后续,文件已删。
*/
@Module({
controllers: [PlanController],
controllers: [PlanController, RecallDebugController],
providers: [
PlanService,
ExecutionService,
......@@ -20,6 +22,7 @@ import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-
PlanEngineService,
ChainComposerService,
TreatmentInitiationRecallScenario,
RecallDebugService,
],
exports: [PlanService, ExecutionService, PlanEngineService, ChainComposerService],
})
......
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permission } from '@pac/types';
import { RequirePermission } from '../../../common/decorators/permissions.decorator';
import {
TenantScope,
TenantScopeContext,
} from '../../../common/decorators/tenant-scope.decorator';
import {
RecallDebugService,
type RecallDebugReport,
type PatientCandidate,
type TransactionRaw,
} from './recall-debug.service';
/**
* 召回归因调试 — /admin/recall-debug
*
* 给研发用:一个患者「为什么召 / 为什么没召」一页讲清。
* - 为什么召:倒读 plan → reason →(evidence.factIds)→ fact →(transactionIds)→ transaction(T0 快照)
* - 为什么没召:跑真·引擎 ground truth + 人话闸门漏斗(合规/冷静期/已治疗/未来预约)定位卡点
*
* Scope:host self(TenantScope 自动锁 hostId,碰不到别家)。权限 PLATFORM_MANAGE。
*
* 路由顺序:静态段(search / transaction)必须在 `:patientId` 之前声明,否则会被参数路由吞掉。
*/
@ApiTags('admin')
@ApiBearerAuth('accessToken')
@Controller('admin/recall-debug')
@RequirePermission(Permission.PLATFORM_MANAGE)
export class RecallDebugController {
constructor(private readonly debug: RecallDebugService) {}
/** 按姓名 / 病历号 / external_id / 手机号查患者(重名返回多条供挑选)。 */
@Get('search')
@ApiOperation({ summary: '查患者(姓名/病历号/external_id/手机号)' })
search(
@TenantScope() scope: TenantScopeContext,
@Query('q') q: string,
): Promise<PatientCandidate[]> {
return this.debug.search(scope, q ?? '');
}
/** 链底:某 transaction 的 rawPayload(懒加载,证据链溯源最后一跳)。 */
@Get('transaction/:txId/raw')
@ApiOperation({ summary: '看 transaction 原始 rawPayload(证据链链底)' })
raw(
@TenantScope() scope: TenantScopeContext,
@Param('txId') txId: string,
): Promise<TransactionRaw> {
return this.debug.getRawPayload(scope, txId);
}
@Get(':patientId')
@ApiOperation({ summary: '患者召回归因(为什么召 / 为什么没召)' })
explain(
@TenantScope() scope: TenantScopeContext,
@Param('patientId') patientId: string,
): Promise<RecallDebugReport> {
return this.debug.explain(scope, patientId);
}
}
import { Injectable, NotFoundException } from '@nestjs/common';
import {
lookupDxTreatment,
resolverCategoriesFor,
diagnosisCodeNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { GAP_PRIMARY_GROUPS } from '../../clinical-gap/potential-treatment-gap.sql';
import { TreatmentInitiationRecallScenario } from '../engine/scenarios/treatment-initiation-recall.scenario';
/**
* RecallDebugService — 研发自查:一个患者「为什么召 / 为什么没召」。
*
* 设计立场(见 docs/algorithm 证据链讨论):证据链要的是**可定位**,不是可重建。
*
* 「为什么召」(presence)= 倒读现有行。引擎产出的 plan → reason →(evidence.factIds)
* → fact →(transactionIds)→ transaction。这是 **T0 快照**(reason.evidence 引的是
* 生成时冻结的 fact 版本 id),忠实反映"引擎当时凭什么召"。
*
* 「为什么没召」(absence)= 缺失没有产出行,无从倒读 → 只能**现在时**回放闸门。
* 做法:① 跑真·引擎 selectHits({patientId}) 拿 ground truth(是否当下可召);
* ② 再跑一遍人话版闸门漏斗(合规/冷静期/已治疗/未来预约)定位**卡在哪道闸**;
* ③ 漏斗与 ground truth 交叉核对,不一致就标注"引擎有更细牙位级判定,以引擎为准"。
* 漏斗是**近似定位器**(patient/category 级),精确牙位级 gap 以引擎 selectHits 为准。
*
* 零新增存储:全是 on-demand 读 / 重放。仅研发用(控制器 PLATFORM_MANAGE 门控)。
*/
@Injectable()
export class RecallDebugService {
/** 信号 code → 主码(primaryCode);用于查 cooldown / resolverCats。 */
private static readonly CODE_TO_PRIMARY: Record<string, string> = (() => {
const m: Record<string, string> = {};
for (const [primary, grp] of Object.entries(GAP_PRIMARY_GROUPS)) {
for (const c of [...grp.dxCodes, ...grp.recCodes]) m[c] = primary;
}
return m;
})();
private static readonly ALL_RECALL_CODES = new Set(
Object.keys(RecallDebugService.CODE_TO_PRIMARY),
);
/** primaryCode → subKey(missing_tooth 等);用于把信号对齐到引擎 hit.subKey 做交叉核对。 */
private static readonly PRIMARY_TO_SUBKEY: Record<string, string> = (() => {
const m: Record<string, string> = {};
for (const [subKey, cfg] of Object.entries(
TreatmentInitiationRecallScenario.SUB_SCENARIOS,
)) {
m[(cfg as { primaryCode: string }).primaryCode] = subKey;
}
return m;
})();
constructor(
private readonly prisma: PrismaService,
private readonly initiation: TreatmentInitiationRecallScenario,
) {}
async explain(
scope: { hostId: string; tenantId: string },
patientId: string,
): Promise<RecallDebugReport> {
const now = new Date();
const { hostId, tenantId } = scope;
const patient = await this.prisma.patient.findFirst({
where: { id: patientId, hostId, tenantId },
include: { profile: true },
});
if (!patient) {
throw new NotFoundException('patient 不在本 host 范围内或不存在');
}
// ── 当下事实(active 信号 + actual 治疗 active/fulfilled + active 预约)──
const facts = await this.prisma.patientFact.findMany({
where: {
patientId,
hostId,
tenantId,
status: { in: ['active', 'fulfilled'] },
},
select: {
id: true,
type: true,
kind: true,
status: true,
occurredAt: true,
plannedFor: true,
content: true,
},
});
// ── 为什么没召:合规闸 + 逐信号漏斗 ──
const compliance = {
active: patient.active,
doNotContact: patient.profile?.doNotContact ?? false,
deceased: patient.profile?.deceased ?? false,
};
const compliancePass =
compliance.active && !compliance.doNotContact && !compliance.deceased;
const futureAppt = facts.find((f) => {
if (f.type !== 'appointment_record' || f.status !== 'active') return false;
const at = f.plannedFor ?? f.occurredAt;
return !!at && at.getTime() > now.getTime();
});
const actualTreatments = facts.filter(
(f) =>
f.type === 'treatment_record' &&
f.kind === 'actual' &&
(f.status === 'active' || f.status === 'fulfilled'),
);
const signalFacts = facts.filter(
(f) =>
(f.type === 'diagnosis_record' || f.type === 'recommendation_record') &&
f.status === 'active' &&
RecallDebugService.ALL_RECALL_CODES.has(codeOf(f.content)),
);
// ground truth:跑真·引擎(单患者收窄)。拿到引擎确认的 subKey 集合,给逐信号交叉核对
// ——漏斗是 patient/category 级近似,引擎做牙位级/先天剔除等更细判定,以引擎为准。
const hits = await this.initiation.selectHits({ hostId, tenantId, now, patientId });
const engineWouldRecall = hits.length > 0;
const engineSubKeys = new Set(
hits.map((h) => (h.subKey ?? '').split('@')[0]).filter(Boolean),
);
const signals: SignalFunnel[] = signalFacts.map((f) => {
const code = codeOf(f.content);
const primary = RecallDebugService.CODE_TO_PRIMARY[code] ?? code;
const subKey = RecallDebugService.PRIMARY_TO_SUBKEY[primary] ?? null;
const rule = lookupDxTreatment(primary);
const resolverCats = resolverCategoriesFor(primary) as readonly string[];
const sigTime = f.occurredAt ?? f.plannedFor;
const daysSince = sigTime
? Math.floor((now.getTime() - sigTime.getTime()) / 86_400_000)
: null;
// ④ 冷静期:诊断后 < cooldownDays 不召
const cooldownDays = rule?.cooldownDays ?? 0;
const cooldownPass =
sigTime != null &&
sigTime.getTime() <= now.getTime() - cooldownDays * 86_400_000;
// ⑤a 已治疗:同治疗家族 actual 在诊断之后(K05/K07 whole-mouth → 任何历史治疗即算)
const everTreated = rule?.excludeIfEverTreated === true;
const treated = actualTreatments.filter((t) => {
const cat = String((t.content as Record<string, unknown>)?.category ?? '');
if (!resolverCats.includes(cat)) return false;
if (everTreated) return true;
return !!t.occurredAt && !!sigTime && t.occurredAt >= sigTime;
});
// 阻断闸:按引擎顺序取第一道未过(合规已在外层统一判)
let blockingGate: string | null = null;
if (!cooldownPass) blockingGate = 'cooldown';
else if (treated.length > 0) blockingGate = 'already_treated';
else if (futureAppt) blockingGate = 'future_appointment';
return {
factId: f.id,
code,
codeName: diagnosisCodeNameZh(code) || code,
subKey,
// 引擎是否真的因该子场景召了(对齐 hit.subKey)。漏斗判 would_recall 但此处 false
// = 引擎用更细判定排除了,以引擎为准。
engineConfirmed: subKey ? engineSubKeys.has(subKey) : false,
type: f.type,
occurredAt: sigTime,
daysSince,
gates: {
cooldown: {
pass: cooldownPass,
detail: `诊断后 ${daysSince ?? '—'}d / ${cooldownDays}d`,
},
alreadyTreated: {
pass: treated.length === 0,
detail: everTreated
? '全口码(K05/K07):任何历史同类治疗即排除'
: '诊断之后启动的同类 actual 治疗即排除',
evidence: treated.map((t) => ({
factId: t.id,
category: String(
(t.content as Record<string, unknown>)?.category ?? '',
),
occurredAt: t.occurredAt,
})),
},
futureAppointment: {
pass: !futureAppt,
evidence: futureAppt
? {
factId: futureAppt.id,
plannedFor: futureAppt.plannedFor ?? futureAppt.occurredAt,
}
: null,
},
},
verdict: !compliancePass
? 'blocked_compliance'
: blockingGate
? 'blocked'
: 'would_recall',
blockingGate: compliancePass ? blockingGate : 'compliance',
};
});
// ── 为什么召:倒读 plan → reason → fact → transaction(含历史版本,前端可选)──
const plans = await this.prisma.followupPlan.findMany({
where: { patientId, hostId, tenantId },
orderBy: { version: 'desc' },
take: 10,
include: { reasons: { orderBy: { priorityScore: 'desc' } } },
});
const activePlanCount = plans.filter(
(p) => p.supersededAt === null && (p.status === 'active' || p.status === 'assigned'),
).length;
const evidenceFactIds = uniq(
plans.flatMap((p) =>
p.reasons.flatMap((r) => evidenceFactIdsOf(r.evidence)),
),
);
const evFacts = evidenceFactIds.length
? await this.prisma.patientFact.findMany({
where: { id: { in: evidenceFactIds } },
select: {
id: true,
type: true,
status: true,
version: true,
occurredAt: true,
content: true,
transactionIds: true,
},
})
: [];
const evFactMap = new Map(evFacts.map((f) => [f.id, f]));
const evTxIds = uniq(evFacts.flatMap((f) => f.transactionIds));
const evTxs = evTxIds.length
? await this.prisma.patientTransaction.findMany({
where: { id: { in: evTxIds } },
select: { id: true, sourceEventId: true, subjectType: true, occurredAt: true },
})
: [];
const txMap = new Map(evTxs.map((t) => [t.id, t]));
const recalledPlans: TracePlan[] = plans.map((p) => ({
planId: p.id,
version: p.version,
status: p.status,
isActive: p.supersededAt === null && (p.status === 'active' || p.status === 'assigned'),
priorityScore: p.priorityScore,
generatedAt: p.updatedAt,
reasons: p.reasons.map((r) => ({
scenario: r.scenario,
subKey: r.subKey,
priorityScore: r.priorityScore,
reason: r.reason,
breakdown: r.breakdown ?? null,
signals: r.signals ?? null,
evidence: evidenceFactIdsOf(r.evidence).map((fid) => {
const f = evFactMap.get(fid);
const content = (f?.content ?? null) as Record<string, unknown> | null;
return {
factId: fid,
type: f?.type ?? null,
status: f?.status ?? null,
version: f?.version ?? null,
code: content ? codeOf(content) || null : null,
doctor: content?.doctor_name ? String(content.doctor_name) : null,
occurredAt: f?.occurredAt ?? null,
transactions: (f?.transactionIds ?? []).map((tid) => {
const t = txMap.get(tid);
return {
transactionId: tid,
sourceEventId: t?.sourceEventId ?? null,
subjectType: t?.subjectType ?? null,
occurredAt: t?.occurredAt ?? null,
};
}),
};
}),
})),
}));
// ── 交叉核对说明 ──
// 漏斗判 would_recall 但引擎未确认该子场景(engineConfirmed=false)= 引擎用更细判定排除。
const divergent = uniq(
signals
.filter((s) => s.verdict === 'would_recall' && !s.engineConfirmed)
.map((s) => s.subKey ?? s.code),
);
let note: string;
if (signals.length === 0) {
note = '无召回信号(该患者无 K 码诊断 / 医嘱推荐)。';
} else if (engineWouldRecall && activePlanCount === 0) {
note = '引擎判当下可召,但尚无 active plan —— 等下次重算/手动重算即生成。';
} else if (divergent.length > 0) {
note = `漏斗判应召的 [${divergent.join(', ')}] 引擎按更细判定(牙位级 / 先天剔除等)排除了 —— 以引擎 ground truth 为准。`;
} else {
note = '人话漏斗与引擎 ground truth 一致。';
}
return {
patient: {
id: patient.id,
externalId: patient.externalId,
name: patient.name,
active: patient.active,
},
recalled: {
hasActivePlan: activePlanCount > 0,
engineWouldRecall,
plans: recalledPlans,
},
notRecalled: {
compliance: { pass: compliancePass, ...compliance },
signalCount: signals.length,
signals,
},
groundTruth: {
engineHitCount: hits.length,
hitSubKeys: uniq(hits.map((h) => h.subKey).filter(Boolean) as string[]),
},
note,
computedAt: now,
};
}
/**
* 按姓名 / 病历号 / external_id / 手机号查患者(研发用人名查,重名返回多条供挑选)。
* 按 host/tenant scope。
*/
async search(
scope: { hostId: string; tenantId: string },
q: string,
): Promise<PatientCandidate[]> {
const term = q.trim();
if (!term) return [];
const rows = await this.prisma.patient.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
OR: [
{ name: { contains: term } },
{ externalId: term },
{ medicalRecordNumber: term },
{ phone: term },
],
},
select: {
id: true,
externalId: true,
name: true,
gender: true,
birthDate: true,
medicalRecordNumber: true,
active: true,
},
take: 25,
orderBy: { name: 'asc' },
});
return rows.map((r) => ({
id: r.id,
externalId: r.externalId,
name: r.name,
gender: r.gender,
age: ageFromBirth(r.birthDate),
medicalRecordNumber: r.medicalRecordNumber,
active: r.active,
}));
}
/**
* 链底:某 transaction 的 rawPayload(宿主推送 / DW 那条最原始字段级记录)。
* 懒加载——证据链溯源最后一跳"点开看原始",不进主响应避免撑大 payload。
* 按 host/tenant scope,碰不到别家。
*/
async getRawPayload(
scope: { hostId: string; tenantId: string },
transactionId: string,
): Promise<TransactionRaw> {
const tx = await this.prisma.patientTransaction.findFirst({
where: { id: transactionId, hostId: scope.hostId, tenantId: scope.tenantId },
select: {
id: true,
sourceEventId: true,
subjectType: true,
occurredAt: true,
rawPayload: true,
},
});
if (!tx) {
throw new NotFoundException('transaction 不在本 host 范围内或不存在');
}
return tx;
}
}
function ageFromBirth(birth: Date | null): number | null {
if (!birth) return null;
const ms = Date.now() - birth.getTime();
if (ms <= 0) return null;
return Math.floor(ms / (365.25 * 86_400_000));
}
function codeOf(content: unknown): string {
const c = content as Record<string, unknown> | null;
return c?.code != null ? String(c.code) : '';
}
function evidenceFactIdsOf(evidence: unknown): string[] {
const e = evidence as { factIds?: unknown } | null;
return Array.isArray(e?.factIds) ? (e!.factIds as unknown[]).map(String) : [];
}
function uniq<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
// ── 返回类型(控制器直出 JSON;研发调试端点不强约束 zod)──
export interface RecallDebugReport {
patient: { id: string; externalId: string; name: string | null; active: boolean };
recalled: {
hasActivePlan: boolean;
engineWouldRecall: boolean;
plans: TracePlan[];
};
notRecalled: {
compliance: {
pass: boolean;
active: boolean;
doNotContact: boolean;
deceased: boolean;
};
signalCount: number;
signals: SignalFunnel[];
};
groundTruth: { engineHitCount: number; hitSubKeys: string[] };
note: string;
computedAt: Date;
}
export interface SignalFunnel {
factId: string;
code: string;
codeName: string;
subKey: string | null;
engineConfirmed: boolean;
type: string;
occurredAt: Date | null;
daysSince: number | null;
gates: {
cooldown: { pass: boolean; detail: string };
alreadyTreated: {
pass: boolean;
detail: string;
evidence: { factId: string; category: string; occurredAt: Date | null }[];
};
futureAppointment: {
pass: boolean;
evidence: { factId: string; plannedFor: Date | null } | null;
};
};
verdict: 'would_recall' | 'blocked' | 'blocked_compliance';
blockingGate: string | null;
}
export interface TracePlan {
planId: string;
version: number;
status: string;
isActive: boolean;
priorityScore: number;
generatedAt: Date;
reasons: TraceReason[];
}
export interface PatientCandidate {
id: string;
externalId: string;
name: string | null;
gender: string | null;
age: number | null;
medicalRecordNumber: string | null;
active: boolean;
}
export interface TraceReason {
scenario: string;
subKey: string | null;
priorityScore: number;
reason: string;
breakdown: unknown;
signals: unknown;
evidence: TraceEvidenceFact[];
}
export interface TransactionRaw {
id: string;
sourceEventId: string | null;
subjectType: string | null;
occurredAt: Date | null;
rawPayload: unknown;
}
export interface TraceEvidenceFact {
factId: string;
type: string | null;
status: string | null;
version: number | null;
code: string | null;
doctor: string | null;
occurredAt: Date | null;
transactions: {
transactionId: string;
sourceEventId: string | null;
subjectType: string | null;
occurredAt: Date | null;
}[];
}
'use client';
import { Permission } from '@pac/types';
import { Can } from '@/components/can';
import { Card, CardContent } from '@/components/ui/card';
import { RecallDebugApp } from '@/components/recall-debug/recall-debug-app';
export default function RecallDebugPage() {
return (
<Can
perm={Permission.PLATFORM_MANAGE}
fallback={
<main className="container mx-auto max-w-3xl p-8">
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
需要 admin 权限才能查看召回归因调试页。
</CardContent>
</Card>
</main>
}
>
<RecallDebugApp />
</Can>
);
}
'use client';
import { api } from '@/lib/api-client';
// 后端 RecallDebugReport 的镜像类型(@pac/service 不对 web 导出,这里本地声明)。
export interface RecallDebugReport {
patient: { id: string; externalId: string; name: string | null; active: boolean };
recalled: {
hasActivePlan: boolean;
engineWouldRecall: boolean;
plans: TracePlan[];
};
notRecalled: {
compliance: { pass: boolean; active: boolean; doNotContact: boolean; deceased: boolean };
signalCount: number;
signals: SignalFunnel[];
};
groundTruth: { engineHitCount: number; hitSubKeys: string[] };
note: string;
computedAt: string;
}
export interface SignalFunnel {
factId: string;
code: string;
codeName: string;
subKey: string | null;
engineConfirmed: boolean;
type: string;
occurredAt: string | null;
daysSince: number | null;
gates: {
cooldown: { pass: boolean; detail: string };
alreadyTreated: {
pass: boolean;
detail: string;
evidence: { factId: string; category: string; occurredAt: string | null }[];
};
futureAppointment: {
pass: boolean;
evidence: { factId: string; plannedFor: string | null } | null;
};
};
verdict: 'would_recall' | 'blocked' | 'blocked_compliance';
blockingGate: string | null;
}
export interface TracePlan {
planId: string;
version: number;
status: string;
isActive: boolean;
priorityScore: number;
generatedAt: string;
reasons: TraceReason[];
}
export interface PatientCandidate {
id: string;
externalId: string;
name: string | null;
gender: string | null;
age: number | null;
medicalRecordNumber: string | null;
active: boolean;
}
export interface TraceReason {
scenario: string;
subKey: string | null;
priorityScore: number;
reason: string;
breakdown: unknown;
signals: unknown;
evidence: TraceEvidenceFact[];
}
export interface TraceEvidenceFact {
factId: string;
type: string | null;
status: string | null;
version: number | null;
code: string | null;
doctor: string | null;
occurredAt: string | null;
transactions: {
transactionId: string;
sourceEventId: string | null;
subjectType: string | null;
occurredAt: string | null;
}[];
}
export interface TransactionRaw {
id: string;
sourceEventId: string | null;
subjectType: string | null;
occurredAt: string | null;
rawPayload: unknown;
}
export const recallDebugApi = {
explain: (patientId: string) =>
api.get<RecallDebugReport>(
`/pac/v1/admin/recall-debug/${encodeURIComponent(patientId)}`,
),
rawPayload: (txId: string) =>
api.get<TransactionRaw>(
`/pac/v1/admin/recall-debug/transaction/${encodeURIComponent(txId)}/raw`,
),
search: (q: string) =>
api.get<PatientCandidate[]>(
`/pac/v1/admin/recall-debug/search?q=${encodeURIComponent(q)}`,
),
};
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ApiError } from '@/lib/api-client';
import {
recallDebugApi,
type PatientCandidate,
type RecallDebugReport,
type SignalFunnel,
type TraceReason,
} from './recall-debug-api';
type LoadState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; data: RecallDebugReport }
| { status: 'error'; message: string };
const GATE_ZH: Record<string, string> = {
cooldown: '冷静期',
already_treated: '已治疗',
future_appointment: '未来预约',
compliance: '合规闸',
};
function fmt(d: string | null): string {
if (!d) return '—';
return new Date(d).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function fmtDay(d: string | null): string {
if (!d) return '—';
return new Date(d).toLocaleDateString('zh-CN');
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function errMsg(err: unknown): string {
return err instanceof ApiError
? `${err.message}${err.code ? ` (${err.code})` : ''}`
: err instanceof Error
? err.message
: String(err);
}
export function RecallDebugApp() {
const [input, setInput] = useState('');
const [state, setState] = useState<LoadState>({ status: 'idle' });
const [candidates, setCandidates] = useState<PatientCandidate[] | null>(null);
const [searching, setSearching] = useState(false);
const load = useCallback(async (pid: string) => {
const id = pid.trim();
if (!id) return;
setCandidates(null);
setState({ status: 'loading' });
try {
const data = await recallDebugApi.explain(id);
setState({ status: 'ready', data });
} catch (err) {
setState({ status: 'error', message: errMsg(err) });
}
}, []);
// uuid → 直查;否则按姓名/病历号/external_id 搜索,重名给列表挑选
const submit = useCallback(
async (term: string) => {
const q = term.trim();
if (!q) return;
if (UUID_RE.test(q)) {
void load(q);
return;
}
setSearching(true);
setCandidates(null);
try {
const list = await recallDebugApi.search(q);
const only = list.length === 1 ? list[0] : undefined;
if (only) void load(only.id);
else setCandidates(list);
} catch (err) {
setState({ status: 'error', message: errMsg(err) });
} finally {
setSearching(false);
}
},
[load],
);
// 深链:?patientId= 直接加载(研发可收藏 / 分享具体患者)
useEffect(() => {
const pid = new URLSearchParams(window.location.search).get('patientId');
if (pid) {
setInput(pid);
void load(pid);
}
}, [load]);
return (
<main className="container mx-auto max-w-6xl space-y-4 p-6">
<div>
<h1 className="text-lg font-medium">召回归因调试</h1>
<p className="text-sm text-muted-foreground">
研发自查:一个患者「为什么召 / 为什么没召」。为什么召 = 倒读现有
plan→reason→fact→transaction(T0 快照);为什么没召 = 引擎 ground truth +
人话闸门漏斗(现在时定位)。
</p>
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void submit(input);
}}
>
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="姓名 / 病历号 / external_id / PAC id"
className="max-w-md text-sm"
/>
<Button type="submit" disabled={searching || state.status === 'loading'}>
{searching ? '搜索中…' : state.status === 'loading' ? '查询中…' : '查询'}
</Button>
</form>
{candidates && <CandidateList list={candidates} onPick={(id) => void load(id)} />}
{state.status === 'error' && (
<Card>
<CardContent className="py-6 text-sm text-destructive">
{state.message}
</CardContent>
</Card>
)}
{state.status === 'ready' && <Report data={state.data} />}
</main>
);
}
function CandidateList({
list,
onPick,
}: {
list: PatientCandidate[];
onPick: (id: string) => void;
}) {
if (list.length === 0) {
return (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
没找到匹配的患者。
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="py-3">
<p className="mb-2 text-xs text-muted-foreground">
{list.length} 个匹配,点选一个:
</p>
<div className="divide-y divide-border/60">
{list.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onPick(c.id)}
className="flex w-full flex-wrap items-center gap-x-3 gap-y-1 rounded px-1 py-2 text-left text-sm hover:bg-muted/40"
>
<span className="font-medium">{c.name ?? '(无名)'}</span>
{c.gender && <span className="text-muted-foreground">{c.gender}</span>}
{c.age != null && <span className="text-muted-foreground">{c.age}</span>}
<span className="font-mono text-xs text-muted-foreground">
external {c.externalId}
</span>
{c.medicalRecordNumber && (
<span className="font-mono text-xs text-muted-foreground">
病历 {c.medicalRecordNumber}
</span>
)}
{!c.active && <Badge variant="destructive">已停用</Badge>}
</button>
))}
</div>
</CardContent>
</Card>
);
}
function Report({ data }: { data: RecallDebugReport }) {
const { patient, recalled, notRecalled, groundTruth, note } = data;
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-wrap items-center gap-x-4 gap-y-2 py-4 text-sm">
<span className="font-medium">{patient.name ?? '(无名)'}</span>
<span className="font-mono text-muted-foreground">{patient.externalId}</span>
{!patient.active && <Badge variant="destructive">已停用</Badge>}
<Badge variant={groundTruth.engineHitCount > 0 ? 'default' : 'secondary'}>
引擎 ground truth:{groundTruth.engineHitCount > 0 ? '可召' : '不召'}
{groundTruth.hitSubKeys.length > 0 && ` · ${groundTruth.hitSubKeys.join(', ')}`}
</Badge>
<span className="text-muted-foreground">
生成于 {fmt(data.computedAt)}
</span>
<span className="basis-full text-muted-foreground">{note}</span>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<WhyRecalled data={data} />
<WhyNot notRecalled={notRecalled} engineWouldRecall={recalled.engineWouldRecall} />
</div>
</div>
);
}
function WhyRecalled({ data }: { data: RecallDebugReport }) {
const { plans, hasActivePlan, engineWouldRecall } = data.recalled;
const pick = (ps: typeof plans) => (ps.find((p) => p.isActive) ?? ps[0])?.planId ?? null;
const [selectedId, setSelectedId] = useState<string | null>(pick(plans));
// 患者切换 → plans 变了,重置到该患者的 active/最新版
useEffect(() => {
setSelectedId(pick(plans));
}, [plans]);
const selected = plans.find((p) => p.planId === selectedId) ?? plans[0] ?? null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">为什么召 · 证据链(T0)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!hasActivePlan && (
<p className="text-sm text-muted-foreground">
无 active plan。
{engineWouldRecall && ' 引擎判当下可召,等下次重算即生成 plan。'}
</p>
)}
{plans.length > 1 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">版本</span>
{plans.map((p) => (
<button
key={p.planId}
type="button"
onClick={() => setSelectedId(p.planId)}
className={`rounded px-2 py-0.5 text-xs ${
p.planId === selected?.planId
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
v{p.version}
{p.isActive ? ' ·当前' : ''}
</button>
))}
</div>
)}
{selected && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Badge variant={selected.isActive ? 'default' : 'secondary'}>
{selected.status}
{!selected.isActive && ' · 历史'}
</Badge>
<span className="text-muted-foreground">v{selected.version}</span>
<span className="font-medium">{selected.priorityScore.toFixed(0)}</span>
<span className="text-muted-foreground">· {fmt(selected.generatedAt)}</span>
</div>
{selected.reasons.map((r, i) => (
<ReasonTrace key={i} r={r} />
))}
</div>
)}
</CardContent>
</Card>
);
}
function ReasonTrace({ r }: { r: TraceReason }) {
return (
<div className="rounded-lg border border-border/60 p-3 text-sm">
<div className="flex items-center gap-2">
<Badge variant="secondary">{r.subKey ?? r.scenario}</Badge>
<span className="font-medium">{r.priorityScore.toFixed(0)}</span>
</div>
<p className="mt-1 text-muted-foreground">{r.reason}</p>
<div className="mt-2 space-y-1.5">
{r.evidence.map((f) => (
<div key={f.factId} className="rounded-md bg-muted/40 px-2 py-1.5">
<div className="flex flex-wrap items-center gap-x-2 text-xs">
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 font-medium text-emerald-700 dark:text-emerald-300">
{f.type ?? 'fact'}
</span>
{f.code && <span className="font-mono">{f.code}</span>}
{f.version != null && (
<span className="text-muted-foreground">v{f.version}</span>
)}
{f.status && f.status !== 'active' && (
<span className="text-amber-600 dark:text-amber-400">{f.status}</span>
)}
{f.doctor && <span className="text-muted-foreground">{f.doctor}</span>}
<span className="text-muted-foreground">{fmtDay(f.occurredAt)}</span>
</div>
{f.transactions.map((t) => (
<TxRow key={t.transactionId} t={t} />
))}
</div>
))}
{r.evidence.length === 0 && (
<p className="text-xs text-muted-foreground">该 reason 无 evidence.factIds。</p>
)}
</div>
</div>
);
}
function TxRow({
t,
}: {
t: {
transactionId: string;
sourceEventId: string | null;
subjectType: string | null;
occurredAt: string | null;
};
}) {
const [raw, setRaw] = useState<unknown | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const toggle = async () => {
if (open) {
setOpen(false);
return;
}
setOpen(true);
if (raw !== undefined || loading) return;
setLoading(true);
setErr(null);
try {
const r = await recallDebugApi.rawPayload(t.transactionId);
setRaw(r.rawPayload);
} catch (e) {
setErr(
e instanceof ApiError
? e.message
: e instanceof Error
? e.message
: String(e),
);
} finally {
setLoading(false);
}
};
return (
<div className="mt-1 pl-3 text-xs text-muted-foreground">
<button
type="button"
onClick={() => void toggle()}
className="text-left hover:text-foreground"
>
↳ transaction · sourceEventId{' '}
<span className="font-mono">{t.sourceEventId ?? '—'}</span>
{t.subjectType && ` · ${t.subjectType}`}
<span className="ml-1 underline">{open ? '收起原始' : '看原始'}</span>
</button>
{open && (
<div className="mt-1">
{loading && <span>加载中…</span>}
{err && <span className="text-destructive">{err}</span>}
{raw !== undefined && <RawJson value={raw} />}
</div>
)}
</div>
);
}
// 宿主 rawPayload 里 diag/plan/dispose 这些字段本身是 JSON 字符串(双重编码)。
// 递归把能 parse 的字符串展开成对象,读起来才不是一坨转义。
function deepParseJson(value: unknown): unknown {
if (typeof value === 'string') {
const s = value.trim();
if (
(s.startsWith('{') && s.endsWith('}')) ||
(s.startsWith('[') && s.endsWith(']'))
) {
try {
return deepParseJson(JSON.parse(s));
} catch {
return value;
}
}
return value;
}
if (Array.isArray(value)) return value.map(deepParseJson);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepParseJson(v);
}
return out;
}
return value;
}
function RawJson({ value }: { value: unknown }) {
const [expanded, setExpanded] = useState(true);
const text = JSON.stringify(deepParseJson(value), null, 2);
return (
<div className="rounded bg-muted/60">
<div className="flex items-center gap-2 px-2 pt-1.5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-[11px] text-muted-foreground underline hover:text-foreground"
>
{expanded ? '折叠嵌套' : '展开嵌套'} JSON
</button>
<button
type="button"
onClick={() => void navigator.clipboard?.writeText(text)}
className="text-[11px] text-muted-foreground underline hover:text-foreground"
>
复制
</button>
</div>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words p-2 font-mono text-[11px] leading-relaxed text-foreground/80">
{expanded ? text : JSON.stringify(value, null, 2)}
</pre>
</div>
);
}
function WhyNot({
notRecalled,
engineWouldRecall,
}: {
notRecalled: RecallDebugReport['notRecalled'];
engineWouldRecall: boolean;
}) {
const { compliance, signals } = notRecalled;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">为什么没召 · 闸门漏斗(现在时)</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">合规闸</span>
{compliance.pass ? (
<Badge variant="secondary">通过</Badge>
) : (
<Badge variant="destructive">
{[
!compliance.active && '已停用',
compliance.doNotContact && '勿打扰',
compliance.deceased && '已故',
]
.filter(Boolean)
.join(' / ')}
</Badge>
)}
</div>
{signals.length === 0 && (
<p className="text-muted-foreground">无召回信号(无 K 码诊断 / 医嘱)。</p>
)}
{signals.map((s) => (
<SignalRow key={s.factId} s={s} />
))}
{!engineWouldRecall && signals.some((s) => s.verdict === 'would_recall') && (
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
漏斗判应召,但引擎按更细的牙位级 gap 排除了 —— 以引擎 ground truth 为准。
</p>
)}
</CardContent>
</Card>
);
}
function SignalRow({ s }: { s: SignalFunnel }) {
const blocked = s.verdict !== 'would_recall';
return (
<div className="rounded-lg border border-border/60 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs">{s.code}</span>
<span className="font-medium">{s.codeName}</span>
<span className="text-xs text-muted-foreground">{s.daysSince ?? '—'}d 前</span>
{blocked ? (
<Badge variant="destructive">
卡:{GATE_ZH[s.blockingGate ?? ''] ?? s.blockingGate}
</Badge>
) : s.engineConfirmed ? (
<Badge>应召 · 引擎确认</Badge>
) : (
<Badge variant="outline" title="漏斗判应召,但引擎按更细判定排除">
应召 · 引擎未取
</Badge>
)}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<GateChip
label="冷静期"
pass={s.gates.cooldown.pass}
blocking={s.blockingGate === 'cooldown'}
title={s.gates.cooldown.detail}
/>
<GateChip
label="已治疗"
pass={s.gates.alreadyTreated.pass}
blocking={s.blockingGate === 'already_treated'}
title={s.gates.alreadyTreated.detail}
/>
<GateChip
label="未来预约"
pass={s.gates.futureAppointment.pass}
blocking={s.blockingGate === 'future_appointment'}
/>
</div>
{s.gates.alreadyTreated.evidence.length > 0 && (
<div className="mt-2 space-y-0.5 text-xs text-muted-foreground">
{s.gates.alreadyTreated.evidence.map((e) => (
<div key={e.factId}>
↳ 已治疗证据:{e.category} · {fmtDay(e.occurredAt)}
</div>
))}
</div>
)}
{s.gates.futureAppointment.evidence && (
<div className="mt-1 text-xs text-muted-foreground">
↳ 未来预约:{fmt(s.gates.futureAppointment.evidence.plannedFor)}
</div>
)}
</div>
);
}
function GateChip({
label,
pass,
blocking,
title,
}: {
label: string;
pass: boolean;
blocking?: boolean;
title?: string;
}) {
const cls = blocking
? 'bg-destructive/15 text-destructive'
: pass
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'
: 'bg-muted text-muted-foreground';
return (
<span
title={title}
className={`rounded px-1.5 py-0.5 text-xs ${cls}`}
>
{label} {pass ? '✓' : '✕'}
</span>
);
}
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