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 { ...@@ -114,7 +114,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// base 分级:K08 缺牙 60(单价 1.5-3 万)/ K07 正畸 55(单价 3-5 万)/ K04 牙髓 52(防恶化)/ // base 分级:K08 缺牙 60(单价 1.5-3 万)/ K07 正畸 55(单价 3-5 万)/ K04 牙髓 52(防恶化)/
// K05 牙周 50 / K02 龋齿 45 / K03 牙体 35 / K06 牙龈 35 / K01 阻生 30 / K00 萌出 25 // K05 牙周 50 / K02 龋齿 45 / K03 牙体 35 / K06 牙龈 35 / K01 阻生 30 / K00 萌出 25
// 不漏码原则:host 数据出现的诊断都进召回池,priority 自然衰减(低 base + 慢窗口 → 排到后面) // 不漏码原则:host 数据出现的诊断都进召回池,priority 自然衰减(低 base + 慢窗口 → 排到后面)
private static readonly SUB_SCENARIOS = { // public(原 private):召回归因调试 RecallDebugService 需读 primaryCode↔subKey 映射做交叉核对。
static readonly SUB_SCENARIOS = {
missing_tooth: { missing_tooth: {
base: 60, base: 60,
primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories) primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
......
...@@ -6,13 +6,15 @@ import { RecycleSchedulerService } from './recycle-scheduler.service'; ...@@ -6,13 +6,15 @@ 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';
import { RecallDebugController } from './recall-debug/recall-debug.controller';
import { RecallDebugService } from './recall-debug/recall-debug.service';
/** /**
* v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。 * v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。
* 链已完成召回(aftercare)留后续,文件已删。 * 链已完成召回(aftercare)留后续,文件已删。
*/ */
@Module({ @Module({
controllers: [PlanController], controllers: [PlanController, RecallDebugController],
providers: [ providers: [
PlanService, PlanService,
ExecutionService, ExecutionService,
...@@ -20,6 +22,7 @@ import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment- ...@@ -20,6 +22,7 @@ import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-
PlanEngineService, PlanEngineService,
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
RecallDebugService,
], ],
exports: [PlanService, ExecutionService, PlanEngineService, ChainComposerService], 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);
}
}
'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)}`,
),
};
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