Commit ed9c1bbe by luoqi

refactor(cleanup): 删死 scaffold + 影子实现(审计 A1/A2/D1)

- 删 modules/agent/(AI Gateway scaffold:AgentService 全 throw NotImplemented、
  controller 路由非功能、前端不调、AiGatewayClient 注入从不调用)+ app.module 接线
- 删 pac-web recall-oracle.ts(529 行召回算法影子第二实现,全库零 import)
- 删 cli/verify-scenarios.ts(自标 DEPRECATED,读已拆走的 content->treatments,
  SQL 恒命中 0 行)+ package.json script

tsc --noEmit 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent cc3eb275
{
"version": "0.0.1",
"configurations": [
{
"name": "pac-web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@pac/web", "exec", "next", "dev"],
"autoPort": true
}
]
}
......@@ -31,7 +31,6 @@
"recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js",
"timeline": "ts-node --transpile-only src/cli/timeline.cli.ts",
"timeline:prod": "node dist/cli/timeline.cli.js",
"verify-scenarios": "ts-node --transpile-only src/cli/verify-scenarios.ts",
"verify-field-mapper": "ts-node --transpile-only src/cli/verify-field-mapper.ts",
"pac:host": "ts-node --transpile-only src/cli/host-admin.cli.ts",
"pac:host:prod": "node dist/cli/host-admin.cli.js",
......
......@@ -14,7 +14,6 @@ import { PatientModule } from './modules/patient/patient.module';
import { PersonaModule } from './modules/persona/persona.module';
import { PlanModule } from './modules/plan/plan.module';
import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.module';
import { AgentModule } from './modules/agent/agent.module';
import { AiModule } from './modules/ai/ai.module';
import { RealtimeCoachModule } from './modules/realtime-coach/realtime-coach.module';
import { AdminModule } from './modules/admin/admin.module';
......@@ -48,7 +47,6 @@ import { HealthController } from './health.controller';
PatientModule,
PersonaModule,
PlanModule,
AgentModule,
AiModule,
RealtimeCoachModule,
McpModule,
......
/**
* Scenario Selector Verifier — ⚠️ DEPRECATED(v2.1)
*
* 本 CLI 基于 v2.0 嵌套 encounter_record.content.treatments[] 设计;
* v2.1 已拆出独立 diagnosis_record / treatment_record / recommendation_record,SQL 已过时。
*
* 当前 PAC v2.1 一期只跑 treatment_initiation_recall(潜在治疗新链召回);
* aftercare 系列 4 子场景留后续。
*
* **不要再跑本 CLI**(SQL 永远命中 0 行);文件保留作为 v2.0 基线参考。
* 真实验证用 plan-engine 直接跑 initiation scenario(待 W3 加 CLI 入口)。
*/
import { PrismaClient } from '@prisma/client';
const p = new PrismaClient();
// 锚点跟 gen.py 一致
const TODAY = new Date('2026-05-14T14:00:00+08:00');
const days = (n: number) => new Date(TODAY.getTime() - n * 86400_000);
async function scenario1_endoNoCrown(): Promise<string[]> {
// 时间窗 30 天 - 18 个月
// 命中:encounter_record.content.treatments[] 含 endodontic+expected_next_step=crown_restoration,
// 且无后续 crown treatment,无未来 crown 预约。
const rows: Array<{ external_id: string }> = await p.$queryRaw`
SELECT DISTINCT p.external_id
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts pf_endo ON pf_endo.patient_id = p.id
WHERE pf_endo.status = 'active'
AND pf_endo.type = 'encounter_record'
AND pf_endo.kind = 'actual'
AND pf_endo.occurred_at BETWEEN ${days(540)} AND ${days(30)}
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_endo.content->'treatments') t
WHERE t->>'treatment_category' = 'endodontic'
AND t->>'status' = 'completed'
AND t->>'expected_next_step' = 'crown_restoration'
)
AND p.active = true
AND pp.do_not_contact = false
AND pp.deceased = false
AND p.phone IS NOT NULL AND p.phone <> ''
-- 无后续 crown 治疗(treatment_subtype = crown 在某次 encounter 内)
AND NOT EXISTS (
SELECT 1 FROM patient_facts pf_crown
WHERE pf_crown.patient_id = p.id
AND pf_crown.status = 'active'
AND pf_crown.type = 'encounter_record'
AND pf_crown.occurred_at > pf_endo.occurred_at
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_crown.content->'treatments') t2
WHERE t2->>'treatment_subtype' = 'crown'
)
)
-- 无未来 crown_restoration 预约(appointment_record kind=planned active)
AND NOT EXISTS (
SELECT 1 FROM patient_facts pf_apt
WHERE pf_apt.patient_id = p.id
AND pf_apt.status = 'active'
AND pf_apt.type = 'appointment_record'
AND pf_apt.kind = 'planned'
AND pf_apt.content->>'appointment_type' = 'crown_restoration'
)
ORDER BY p.external_id
`;
return rows.map((r) => r.external_id);
}
async function scenario2_orthoRetention(): Promise<string[]> {
// 时间窗 6-24 个月,encounter 内有正畸治疗 completed,近 6 月无 ortho 相关活动
const rows: Array<{ external_id: string }> = await p.$queryRaw`
SELECT DISTINCT p.external_id
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts pf_ortho ON pf_ortho.patient_id = p.id
WHERE pf_ortho.status = 'active'
AND pf_ortho.type = 'encounter_record'
AND pf_ortho.kind = 'actual'
AND pf_ortho.occurred_at BETWEEN ${days(720)} AND ${days(180)}
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_ortho.content->'treatments') t
WHERE t->>'treatment_category' = 'orthodontic'
AND t->>'status' = 'completed'
)
AND p.active = true
AND pp.do_not_contact = false AND pp.deceased = false
AND p.phone IS NOT NULL AND p.phone <> ''
AND NOT EXISTS (
SELECT 1 FROM patient_facts pf_recent
WHERE pf_recent.patient_id = p.id
AND pf_recent.occurred_at >= ${days(180)}
AND (
pf_recent.content->>'related_treatment_category' = 'orthodontic'
OR EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_recent.content->'treatments') t2
WHERE t2->>'treatment_category' = 'orthodontic'
)
)
)
ORDER BY p.external_id
`;
return rows.map((r) => r.external_id);
}
async function scenario3_implantAnnual(): Promise<string[]> {
// 时间窗 330-540 天,encounter 内有 implant 治疗 completed,自该次 encounter 后 >300 天无 review encounter
const rows: Array<{ external_id: string }> = await p.$queryRaw`
SELECT DISTINCT p.external_id
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts pf_impl ON pf_impl.patient_id = p.id
WHERE pf_impl.status = 'active'
AND pf_impl.type = 'encounter_record'
AND pf_impl.kind = 'actual'
AND pf_impl.occurred_at BETWEEN ${days(540)} AND ${days(330)}
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_impl.content->'treatments') t
WHERE t->>'treatment_category' = 'implant'
AND t->>'status' = 'completed'
)
AND p.active = true
AND pp.do_not_contact = false AND pp.deceased = false
AND p.phone IS NOT NULL AND p.phone <> ''
AND NOT EXISTS (
SELECT 1 FROM patient_facts pf_review
WHERE pf_review.patient_id = p.id
AND pf_review.type = 'encounter_record'
AND pf_review.content->>'related_treatment_subject_id' = pf_impl.subject_id
AND pf_review.occurred_at > pf_impl.occurred_at + INTERVAL '300 days'
)
ORDER BY p.external_id
`;
return rows.map((r) => r.external_id);
}
async function scenario4_perioMaintenance(): Promise<string[]> {
// 时间窗 150-360 天,encounter 内有 periodontic 治疗 completed,后续无更新的 periodontic
const rows: Array<{ external_id: string }> = await p.$queryRaw`
SELECT DISTINCT p.external_id
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts pf_perio ON pf_perio.patient_id = p.id
WHERE pf_perio.status = 'active'
AND pf_perio.type = 'encounter_record'
AND pf_perio.kind = 'actual'
AND pf_perio.occurred_at BETWEEN ${days(360)} AND ${days(150)}
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_perio.content->'treatments') t
WHERE t->>'treatment_category' = 'periodontic'
AND t->>'status' = 'completed'
)
AND p.active = true
AND pp.do_not_contact = false AND pp.deceased = false
AND p.phone IS NOT NULL AND p.phone <> ''
AND NOT EXISTS (
SELECT 1 FROM patient_facts pf_recent_perio
WHERE pf_recent_perio.patient_id = p.id
AND pf_recent_perio.type = 'encounter_record'
AND pf_recent_perio.occurred_at > pf_perio.occurred_at
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(pf_recent_perio.content->'treatments') t2
WHERE t2->>'treatment_category' = 'periodontic'
)
)
ORDER BY p.external_id
`;
return rows.map((r) => r.external_id);
}
function assertEqual(label: string, got: string[], expected: string[]): boolean {
const sortedGot = [...got].sort();
const sortedExp = [...expected].sort();
const ok =
sortedGot.length === sortedExp.length &&
sortedGot.every((v, i) => v === sortedExp[i]);
const tag = ok ? '✅' : '❌';
console.log(`${tag} ${label}`);
console.log(` expected (${sortedExp.length}): ${sortedExp.join(', ')}`);
console.log(` got (${sortedGot.length}): ${sortedGot.join(', ')}`);
if (!ok) {
const missing = sortedExp.filter((x) => !sortedGot.includes(x));
const extra = sortedGot.filter((x) => !sortedExp.includes(x));
if (missing.length) console.log(` missing: ${missing.join(', ')}`);
if (extra.length) console.log(` extra: ${extra.join(', ')}`);
}
return ok;
}
(async () => {
let allOk = true;
console.log(`\nScenario hit verification — TODAY = ${TODAY.toISOString().slice(0, 10)}`);
console.log('────────────────────────────────────────────────────────');
const s1 = await scenario1_endoNoCrown();
allOk = assertEqual(
'#1 根管未戴冠',
s1,
['P001', 'P002'],
) && allOk;
const s2 = await scenario2_orthoRetention();
allOk = assertEqual(
'#2 正畸保持器期',
s2,
['P003', 'P004'],
) && allOk;
const s3 = await scenario3_implantAnnual();
allOk = assertEqual(
'#3 种植年度复查',
s3,
['P005', 'P006'],
) && allOk;
const s4 = await scenario4_perioMaintenance();
allOk = assertEqual(
'#4 牙周维护期',
s4,
['P007', 'P008'],
) && allOk;
console.log('────────────────────────────────────────────────────────');
console.log(allOk ? '\n🎉 ALL SCENARIO SELECTORS PASS' : '\n⚠️ SOME SCENARIOS FAILED');
await p.$disconnect();
process.exit(allOk ? 0 : 1);
})();
import { Controller, Param, Query, Sse } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Permission, type AgentScriptStreamEvent } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import {
TenantScope,
TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator';
import { AgentService } from './agent.service';
@ApiTags('agent')
@ApiBearerAuth('accessToken')
@Controller('agents')
export class AgentController {
constructor(private readonly agent: AgentService) {}
@Sse('scripts/stream')
@RequirePermission(Permission.AGENT_INVOKE)
@ApiOperation({
summary: 'Stream a recall script for a plan (SSE)',
description:
'Calls the AI Gateway script workflow and yields token chunks. workflowId is supplied ' +
'either in the query string or resolved by the server (default per AgentKind).',
})
streamScript(
@TenantScope() scope: TenantScopeContext,
@Query('planId') planId: string,
@Query('workflowId') workflowId: string,
): Observable<{ data: AgentScriptStreamEvent }> {
return this.agent
.streamScriptForPlan(scope, planId, workflowId)
.pipe(map((event) => ({ data: event })));
}
}
import { Module } from '@nestjs/common';
import { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
import { AiGatewayClient } from './ai-gateway.client';
@Module({
controllers: [AgentController],
providers: [AgentService, AiGatewayClient],
exports: [AgentService, AiGatewayClient],
})
export class AgentModule {}
import { Injectable, Logger, NotImplementedException } from '@nestjs/common';
import { Observable } from 'rxjs';
import { AgentKind, type AgentScriptStreamEvent } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AiGatewayClient } from './ai-gateway.client';
/**
* AgentService — AI 调用统一入口(经 AI Gateway → Dify)
*
* 暂未实施。AI 产物(plan_script / plan_summary / persona feature description)
* 由 BullMQ 异步队列驱动,接入 Vercel AI SDK 后落地。
*/
@Injectable()
export class AgentService {
private readonly logger = new Logger(AgentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly gateway: AiGatewayClient,
) {}
async invoke(_input: {
scope: TenantScopeContext;
agentKind: AgentKind;
workflowId: string;
inputSnapshot: Record<string, unknown>;
promptTemplate?: string;
linkedPatientId?: string;
linkedPersonaId?: string;
linkedPlanId?: string;
}): Promise<{ invocationId: string; outputText: string; outputs: Record<string, unknown> }> {
throw new NotImplementedException('AgentService.invoke 暂未实施,等待 AI Gateway / Vercel AI SDK 接入');
}
streamScriptForPlan(
_scope: TenantScopeContext,
_planId: string,
_workflowId?: string,
): Observable<AgentScriptStreamEvent> {
throw new NotImplementedException('AgentService.streamScriptForPlan 暂未实施(异步 BullMQ + AI Gateway)');
}
}
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* Client for the AI Gateway, whose API mirrors Dify's workflow API.
* The gateway is currently a passthrough — once the real gateway lands, only
* env vars change.
*
* Two ops we need:
* - run : POST /v1/workflows/run — JSON in, JSON out (blocking)
* - stream : POST /v1/workflows/run with response_mode=streaming, returns SSE
*
* Auth: Bearer <AI_GATEWAY_API_KEY>.
*/
export interface GatewayRunInput {
workflowId: string;
inputs: Record<string, unknown>;
user: string; // tenant scope identifier (tenantId:userId)
responseMode?: 'blocking' | 'streaming';
conversationId?: string;
}
export interface GatewayRunResult {
outputs: Record<string, unknown>;
text: string;
metadata: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
costCents: number;
raw?: unknown;
};
}
export type GatewayStreamChunk =
| { type: 'token'; data: string }
| { type: 'done'; data: { outputs: Record<string, unknown>; text: string; usage: GatewayRunResult['metadata'] } }
| { type: 'error'; data: { message: string } };
@Injectable()
export class AiGatewayClient {
private readonly logger = new Logger(AiGatewayClient.name);
constructor(private readonly config: ConfigService) {}
/**
* Blocking call. Throws on HTTP/network/gateway error so callers can catch
* and record the failure on AgentInvocation.
*/
async run(input: GatewayRunInput): Promise<GatewayRunResult> {
const { url, key } = this.endpoint();
const res = await fetch(`${url}/v1/workflows/run`, {
method: 'POST',
headers: this.headers(key),
body: JSON.stringify({
workflow_id: input.workflowId,
inputs: input.inputs,
user: input.user,
response_mode: 'blocking',
conversation_id: input.conversationId,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`AI Gateway ${res.status}: ${text || res.statusText}`);
}
const json = (await res.json()) as Record<string, unknown>;
return this.normalize(json);
}
/**
* Streaming call — yields parsed chunks. The gateway returns Dify-shaped
* SSE: `data: { event, ... }` lines. We translate to a small union type.
*/
async *stream(input: GatewayRunInput): AsyncGenerator<GatewayStreamChunk, void, void> {
const { url, key } = this.endpoint();
const res = await fetch(`${url}/v1/workflows/run`, {
method: 'POST',
headers: this.headers(key),
body: JSON.stringify({
workflow_id: input.workflowId,
inputs: input.inputs,
user: input.user,
response_mode: 'streaming',
conversation_id: input.conversationId,
}),
});
if (!res.ok || !res.body) {
const text = res.body ? await res.text().catch(() => '') : '';
yield { type: 'error', data: { message: `AI Gateway ${res.status}: ${text || res.statusText}` } };
return;
}
const decoder = new TextDecoder();
const reader = res.body.getReader();
let buffer = '';
let collectedText = '';
let lastUsage: GatewayRunResult['metadata'] = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
costCents: 0,
};
let lastOutputs: Record<string, unknown> = {};
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let event: Record<string, unknown>;
try {
event = JSON.parse(payload) as Record<string, unknown>;
} catch {
continue;
}
const evType = event['event'];
if (evType === 'text_chunk' || evType === 'message') {
const data = (event['data'] as Record<string, unknown> | undefined) ?? event;
const text = (data['text'] as string | undefined) ?? (event['answer'] as string | undefined) ?? '';
if (text) {
collectedText += text;
yield { type: 'token', data: text };
}
} else if (evType === 'workflow_finished' || evType === 'message_end') {
const data = (event['data'] as Record<string, unknown> | undefined) ?? event;
lastOutputs = (data['outputs'] as Record<string, unknown> | undefined) ?? {};
const usage = (data['metadata'] as Record<string, unknown> | undefined)?.['usage'] as
| Record<string, unknown>
| undefined;
if (usage) {
lastUsage = {
promptTokens: Number(usage['prompt_tokens'] ?? 0),
completionTokens: Number(usage['completion_tokens'] ?? 0),
totalTokens: Number(usage['total_tokens'] ?? 0),
costCents: Number(usage['cost_cents'] ?? 0),
raw: usage,
};
}
} else if (evType === 'error') {
const data = (event['data'] as Record<string, unknown> | undefined) ?? event;
yield {
type: 'error',
data: { message: (data['message'] as string | undefined) ?? 'gateway error' },
};
return;
}
}
}
} finally {
reader.releaseLock();
}
yield {
type: 'done',
data: { outputs: lastOutputs, text: collectedText, usage: lastUsage },
};
}
private endpoint(): { url: string; key: string } {
const url = this.config.getOrThrow<string>('ai.gatewayUrl');
const key = this.config.getOrThrow<string>('ai.gatewayApiKey');
return { url: url.replace(/\/+$/, ''), key };
}
private headers(key: string): Record<string, string> {
return {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
private normalize(json: Record<string, unknown>): GatewayRunResult {
const data = (json['data'] as Record<string, unknown> | undefined) ?? json;
const outputs = (data['outputs'] as Record<string, unknown> | undefined) ?? {};
const text =
(outputs['text'] as string | undefined) ??
(data['answer'] as string | undefined) ??
'';
const usage = (data['metadata'] as Record<string, unknown> | undefined)?.['usage'] as
| Record<string, unknown>
| undefined;
return {
outputs,
text,
metadata: {
promptTokens: Number(usage?.['prompt_tokens'] ?? 0),
completionTokens: Number(usage?.['completion_tokens'] ?? 0),
totalTokens: Number(usage?.['total_tokens'] ?? 0),
costCents: Number(usage?.['cost_cents'] ?? 0),
raw: usage,
},
};
}
}
/**
* recall-oracle —— 召回算法的**独立第二实现**(对抗 / 差分验证用)
*
* 目的:用一套**完全不同范式**的实现重算"这颗牙该不该召回",跟生产 SQL
* (treatment-initiation-recall.scenario.ts)的结论比对。两边一致才可信,
* 分歧处即 bug 捕获点(可能是生产误召/漏召,也可能是本 oracle 写错)。
*
* ⚠️ 对抗的纪律线(务必遵守,否则对抗失效):
* - 可以共享:**配置数据** = DiagnosisTreatmentMap / 废用牙名单 / 主诉映射(单一真理源,
* 本就该两边引同一份,改窗口不漂移)。
* - 绝不共享:**判定逻辑本身**。生产是 SQL 集合运算(array overlap / LATERAL 按牙相减 /
* union-find 聚类);本 oracle 故意用**按单颗牙的时间序状态机**(chronological walk),
* 心智模型不同 → 同一个 bug 不会同时出现在两边互相掩护。
* - 本文件照**算法语义**写,不照 SQL 逐行翻译。
*
* 覆盖的召回门控(对齐 scenario.ts 注释编号):
* ③ 信号(diagnosis/recommendation + 召回码) ④ cooldown 下界(per-code)
* ④' 废用牙剔除 ⑤a 同类实治(afterDx)
* ⑤c 同牙拔除(afterDx) ⑤e 同牙替代定性(种植/冠 afterDx)
* ⑤b 未来预约 ⑤d 主诉匹配预约(afterSig)
* ⑤f 14 天就诊冷静 按牙相减 / wholeMouth(K05/K07)
*
* 不覆盖(患者级硬闸,plan 存在即已通过,不在单牙粒度判):
* ① 隔离(host/tenant) ② 合规(active/DNC/deceased)
*/
import {
DiagnosisTreatmentMap,
APPT_COMPLAINT_TO_CATEGORY,
isRestorationIneligibleDxName,
NO_RESTORATION_GAP_EXAM_PATTERNS,
resolverCategoriesFor,
STRUCTURAL_DX_CODE_LIST,
treatmentCategoryNameZh,
} from '@pac/types';
/// 结构码集合 —— 同牙位"取代"只认结构码(牙周/正畸不 moot 结构病)
const STRUCTURAL_CODES = new Set<string>(STRUCTURAL_DX_CODE_LIST);
import type { AdaptedFact } from './adapt-data';
export const WHOLE_PERIO = '全口 · 牙周';
export const WHOLE_ORTHO = '全口 · 正畸';
export const WHOLE_OTHER = '全口 · 其他';
const POST_VISIT_COOLDOWN_DAYS = 14;
/// 召回码 → 子场景(subKey + 取 rule 用的 primaryCode)。
/// 跟 scenario.ts 的 SUB_SCENARIOS 一一对应(配置同源,允许共享)。
const CODE_TO_SUB: Record<string, { subKey: string; primary: string; label: string }> = {
K08: { subKey: 'missing_tooth', primary: 'K08', label: '缺失牙修复' },
IMPLANT_RECOMMENDED: { subKey: 'missing_tooth', primary: 'K08', label: '缺失牙修复' },
K07: { subKey: 'ortho_no_consult', primary: 'K07', label: '正畸矫治' },
ORTHO_CONSULT_RECOMMENDED: { subKey: 'ortho_no_consult', primary: 'K07', label: '正畸矫治' },
K04: { subKey: 'endo_no_rct', primary: 'K04', label: '根管治疗' },
RCT_RECOMMENDED: { subKey: 'endo_no_rct', primary: 'K04', label: '根管治疗' },
K05: { subKey: 'perio_no_srp', primary: 'K05', label: '牙周治疗' },
SRP_RECOMMENDED: { subKey: 'perio_no_srp', primary: 'K05', label: '牙周治疗' },
K02: { subKey: 'caries_no_filling', primary: 'K02', label: '龋齿充填' },
FILLING_RECOMMENDED: { subKey: 'caries_no_filling', primary: 'K02', label: '龋齿充填' },
K03: { subKey: 'hard_tissue_damage', primary: 'K03', label: '牙体修复' },
HARD_TISSUE_REPAIR_RECOMMENDED: { subKey: 'hard_tissue_damage', primary: 'K03', label: '牙体修复' },
CROWN_RECOMMENDED: { subKey: 'hard_tissue_damage', primary: 'K03', label: '牙体修复' },
K06: { subKey: 'gum_alveolar_lesion', primary: 'K06', label: '牙龈/牙槽嵴' },
GUM_TREATMENT_RECOMMENDED: { subKey: 'gum_alveolar_lesion', primary: 'K06', label: '牙龈/牙槽嵴' },
K01: { subKey: 'impacted_tooth', primary: 'K01', label: '阻生牙拔除' },
EXTRACTION_RECOMMENDED: { subKey: 'impacted_tooth', primary: 'K01', label: '阻生牙拔除' },
K09: { subKey: 'jaw_cyst', primary: 'K09', label: '颌骨囊肿' },
JAW_CYST_REMOVAL_RECOMMENDED: { subKey: 'jaw_cyst', primary: 'K09', label: '颌骨囊肿' },
K00: { subKey: 'development_eruption', primary: 'K00', label: '发育/萌出' },
ERUPTION_INTERVENTION_RECOMMENDED: { subKey: 'development_eruption', primary: 'K00', label: '发育/萌出' },
};
export type OracleVerdictKind =
| 'recall' // 应召回
| 'resolved' // 已被【实际治疗】解决(同类/拔除/替代/术后证据)
| 'superseded' // 被【后续诊断/建议】取代并入(非治疗:深龋→缺失、楔缺→建议充填…)
| 'cooldown' // 诊断未过冷静期
| 'ineligible' // 废用牙/无功能牙,非修复对象
| 'suppressed'; // 被患者级闸压制(未来预约/主诉预约/近期到诊)
export type OracleVerdict = {
laneKey: string; // 牙位 toothBase(如 "26")或全口泳道名(WHOLE_PERIO 等)— 跟 ToothTimeline 泳道同空间
isWhole: boolean;
subKey: string; // 'caries_no_filling' …
subLabel: string; // '龋齿充填'
code: string; // 'K02'
signalFactId: string;
signalDate: string | null; // ISO
daysSince: number | null;
kind: OracleVerdictKind;
detail: string; // 人读判定理由
};
const VERDICT_META: Record<OracleVerdictKind, { zh: string; tone: string }> = {
recall: { zh: '应召回', tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
resolved: { zh: '已治疗', tone: 'bg-slate-100 text-slate-500 border-slate-100' },
superseded: { zh: '被取代', tone: 'bg-slate-100 text-slate-400 border-slate-100' },
cooldown: { zh: '考虑期', tone: 'bg-amber-50 text-amber-700 border-amber-200' },
ineligible: { zh: '非修复', tone: 'bg-slate-100 text-slate-400 border-slate-100' },
suppressed: { zh: '被压制', tone: 'bg-slate-100 text-slate-500 border-slate-100' },
};
export function verdictMeta(kind: OracleVerdictKind) {
return VERDICT_META[kind];
}
// ── 牙位归一(独立实现,跟 tooth-timeline 同义:剥牙面后缀,保留牙位 base)──
function toothBase(t: string): string {
const m = /^(\d{1,2}[A-E]?)/i.exec(t.trim());
return m ? m[1]!.toUpperCase() : t.trim().toUpperCase();
}
// 合法牙位 token:数字开头 + ≥2 字符(口径同后端 util.isValidToothToken)。
// 裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")丢弃 → 不造幽灵召回。
function isValidTooth(t: string): boolean {
return t.length >= 2 && /^\d/.test(t);
}
function factTeeth(c: Record<string, unknown>): string[] {
const tp = c.tooth_positions;
const raw = Array.isArray(tp) ? tp.join(';') : String(c.tooth_position ?? tp ?? '');
return raw
.split(/[;,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map(toothBase)
.filter(isValidTooth);
}
type Rule = {
categories: readonly string[];
cooldownDays: number;
wholeMouth?: boolean;
excludeIfEverTreated?: boolean;
};
/// 反查:哪些 host 主诉文本(complaint_category 值)对应这些治疗类别 → 给 ⑤d 用
function complaintTextsFor(cats: readonly string[]): string[] {
return Object.entries(APPT_COMPLAINT_TO_CATEGORY)
.filter(([, v]) => cats.includes(v))
.map(([text]) => text);
}
/**
* 独立 oracle 主入口:对一个患者的全部 fact 跑一遍,产出每颗牙(+ 全口泳道)的召回判定。
*/
/// 解析 exam_findings(字符串或 array;同 emr-soap-view.parseJsonArray)
function parseExamFindings(raw: unknown): Array<{ toothPosition?: string; message?: string }> {
if (Array.isArray(raw)) return raw as Array<{ toothPosition?: string; message?: string }>;
if (typeof raw !== 'string' || !raw.trim() || raw === 'null') return [];
try {
const p = JSON.parse(raw);
return Array.isArray(p) ? p : [];
} catch {
return [];
}
}
export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[] {
const ms = now.getTime();
const daysAgoMs = (n: number) => ms - n * 86400000;
// ── 一遍扫描,收集治疗 / 预约 / 到诊 / 信号 ──
const treatments: Array<{ teeth: Set<string>; cat: string; t: number; iso: string }> = [];
let hasFutureAppt = false;
let recentVisit = false;
const complaintAppts: Array<{ t: number; cats: Set<string> }> = [];
const signals: Array<{
factId: string;
c: Record<string, unknown>;
code: string;
t: number;
iso: string;
}> = [];
// 每颗牙的"最晚真实诊断"时间戳 —— 用于"同牙位最新诊断取代旧诊断"。
const maxDxByTooth = new Map<string, number>();
// 每颗牙的"最晚真实建议"时间戳 —— 用于"诊断 vs 建议冲突,以建议(医生决定)为准"。
const maxRecByTooth = new Map<string, number>();
// 每颗牙的"无修复指征"时间戳 —— 检查所见(exam_findings)写"缺牙间隙关闭/无修复间隙"。
// 与后端 gap (a''') 分支同口径(词典 NO_RESTORATION_GAP_EXAM_PATTERNS,需"缺失/缺牙"共现)。
const noRestorByTooth = new Map<string, number>();
const noRestorRe = new RegExp(NO_RESTORATION_GAP_EXAM_PATTERNS.join('|'));
for (const f of facts) {
const c = (f.content ?? {}) as Record<string, unknown>;
if (
f.type === 'treatment_record' &&
f.kind === 'actual' &&
(f.status === 'active' || f.status === 'fulfilled')
) {
const iso = f.occurredAt ?? f.plannedFor;
if (iso) {
treatments.push({
teeth: new Set(factTeeth(c)),
cat: String(c.category ?? ''),
t: new Date(iso).getTime(),
iso,
});
}
} else if (f.type === 'appointment_record') {
const iso = f.plannedFor ?? f.occurredAt;
const at = iso ? new Date(iso).getTime() : null;
if (f.status === 'active' && at != null && at > ms) hasFutureAppt = true;
if ((f.status === 'active' || f.status === 'fulfilled') && at != null) {
const cats = new Set(
String(c.complaint_category ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
if (cats.size) complaintAppts.push({ t: at, cats });
}
} else if (f.type === 'encounter_record' || f.type === 'emr_record') {
if (f.occurredAt) {
const vt = new Date(f.occurredAt).getTime();
if (vt > daysAgoMs(POST_VISIT_COOLDOWN_DAYS)) recentVisit = true;
}
// 检查所见:缺牙 message 含"间隙关闭/无修复间隙" → 该牙无修复指征(同后端 gap a''')
if (f.type === 'emr_record') {
const iso = f.occurredAt ?? f.plannedFor;
const ts = iso ? new Date(iso).getTime() : null;
if (ts != null) {
for (const seg of parseExamFindings((f.content ?? {}).exam_findings)) {
const msg = String(seg.message ?? '');
if (!/缺[牙失]/.test(msg) || !noRestorRe.test(msg)) continue;
for (const t of String(seg.toothPosition ?? '')
.split(';')
.map((x) => toothBase(x.trim()))
.filter(isValidTooth)) {
const cur = noRestorByTooth.get(t);
if (cur == null || ts > cur) noRestorByTooth.set(t, ts);
}
}
}
}
}
// ⭐ 同牙位最新诊断 —— 收每颗牙的【最晚真实诊断】时间(任意真实码,不止召回码)。
// 用于"后续诊断取代旧诊断":某牙存在比信号更晚的真实诊断 → 旧信号对该牙失效。
// 只收【结构码】的诊断/建议(牙周 K05/K06 / 正畸 K07 不取代结构病)
if (f.type === 'diagnosis_record' && f.status === 'active') {
const code = String(c.code ?? '').trim();
const iso = f.occurredAt ?? f.plannedFor;
if (STRUCTURAL_CODES.has(code) && iso) {
const ts = new Date(iso).getTime();
for (const t of factTeeth(c)) {
const cur = maxDxByTooth.get(t);
if (cur == null || ts > cur) maxDxByTooth.set(t, ts);
}
}
}
// 每颗牙最晚【结构建议】(医生治疗决定)→ 诊断 vs 建议冲突时压过诊断
if (f.type === 'recommendation_record' && f.status === 'active') {
const code = String(c.code ?? '').trim();
const iso = f.occurredAt ?? f.plannedFor;
if (STRUCTURAL_CODES.has(code) && iso) {
const ts = new Date(iso).getTime();
for (const t of factTeeth(c)) {
const cur = maxRecByTooth.get(t);
if (cur == null || ts > cur) maxRecByTooth.set(t, ts);
}
}
}
// 信号(诊断 / 推荐)
if (
(f.type === 'diagnosis_record' || f.type === 'recommendation_record') &&
f.status === 'active'
) {
const code = String(c.code ?? '');
const iso = f.occurredAt ?? f.plannedFor;
if (CODE_TO_SUB[code] && iso) {
signals.push({ factId: f.id, c, code, t: new Date(iso).getTime(), iso });
}
}
}
const out: OracleVerdict[] = [];
for (const sig of signals) {
const sub = CODE_TO_SUB[sig.code]!;
const rule = DiagnosisTreatmentMap[sub.primary as keyof typeof DiagnosisTreatmentMap] as Rule;
const wholeMouth = !!rule.wholeMouth;
// ⭐ 治疗家族 resolver(共享单一真理源 resolverCategoriesFor;判定逻辑下面独立走)
// 结构码 = 局部结构治疗全集(含 cosmetic/endodontic/restorative…);牙周/正畸沿用 rule.categories。
const resolverCats = new Set<string>(resolverCategoriesFor(sub.primary));
const nameZh = String(sig.c.name_zh ?? '');
const daysSince = Math.floor((ms - sig.t) / 86400000);
const inCooldown = sig.t > daysAgoMs(rule.cooldownDays);
const complaintTexts = complaintTextsFor(rule.categories);
const evalUnit = (laneKey: string, isWhole: boolean, toothFilter: string | null) => {
let kind: OracleVerdictKind;
let detail: string;
// ⭐ 同牙位取代规则(wholeMouth toothFilter=null 不走此路径):
// (b) 该牙存在更晚的真实诊断 → 旧信号失效(深龋→缺失、龋→牙髓炎、复发只认最新)。
// (c) 诊断 vs 建议冲突,以建议(医生决定)为准:本 sig 是诊断 且 该牙有 ≥ 本诊断时间的建议 → 诊断失效。
const sigIsDx = /^K\d/i.test(sig.code);
const laterDx = toothFilter != null ? maxDxByTooth.get(toothFilter) : undefined;
const recOnTooth = toothFilter != null ? maxRecByTooth.get(toothFilter) : undefined;
const supersededByDx = laterDx != null && laterDx > sig.t;
const supersededByRec = sigIsDx && recOnTooth != null && recOnTooth >= sig.t;
if (supersededByDx || supersededByRec) {
kind = 'superseded';
detail = supersededByRec
? `被该牙建议(医生治疗决定)取代,非实际治疗`
: `被该牙后续诊断取代(同牙位以最新诊断为准),非实际治疗`;
} else if (isRestorationIneligibleDxName(nameZh)) {
// ④' 废用牙
kind = 'ineligible';
detail = `「${nameZh}」非修复对象(该拔除/观察)`;
} else if (
toothFilter != null &&
(noRestorByTooth.get(toothFilter) ?? -1) >= sig.t
) {
// (a''') 检查所见:缺牙间隙已关闭 / 无修复间隙 → 无修复指征
kind = 'ineligible';
detail = `检查所见缺牙间隙已关闭,无修复指征`;
} else {
// 解决者:resolverCats 家族里任一治疗。
// wholeMouth → 任意时间 if excludeIfEverTreated,否则 afterDx;
// 按牙 → afterDx,且落在本颗牙。
let resolver: { cat: string; iso: string } | undefined;
if (wholeMouth) {
resolver = treatments.find(
(tr) =>
resolverCats.has(tr.cat) &&
(rule.excludeIfEverTreated ? true : tr.t >= sig.t),
);
} else {
resolver = treatments.find(
(tr) =>
resolverCats.has(tr.cat) &&
tr.t >= sig.t &&
(toothFilter == null || tr.teeth.has(toothFilter)),
);
}
if (resolver) {
kind = 'resolved';
detail = `诊断后做过 ${treatmentCategoryNameZh(resolver.cat) || resolver.cat}(${resolver.iso.slice(0, 10)})`;
} else if (inCooldown) {
kind = 'cooldown';
detail = `诊断仅 ${daysSince} 天 < 冷静期 ${rule.cooldownDays} 天`;
} else if (hasFutureAppt) {
kind = 'suppressed';
detail = '患者已有未来预约(⑤b)';
} else if (
complaintAppts.some(
(a) => a.t >= sig.t && [...a.cats].some((x) => complaintTexts.includes(x)),
)
) {
kind = 'suppressed';
detail = '诊断后有主诉匹配的预约(⑤d)';
} else if (recentVisit) {
kind = 'suppressed';
detail = `近 ${POST_VISIT_COOLDOWN_DAYS} 天内到过诊(⑤f)`;
} else {
kind = 'recall';
detail = `已 ${daysSince} 天未启动 ${rule.categories.map((c) => treatmentCategoryNameZh(c) || c).join('/')}`;
}
}
out.push({
laneKey,
isWhole,
subKey: sub.subKey,
subLabel: sub.label,
code: sig.code,
signalFactId: sig.factId,
signalDate: sig.iso,
daysSince,
kind,
detail,
});
};
if (wholeMouth) {
const laneKey =
sub.primary === 'K05' ? WHOLE_PERIO : sub.primary === 'K07' ? WHOLE_ORTHO : WHOLE_OTHER;
evalUnit(laneKey, true, null);
} else {
let teeth = [...new Set(factTeeth(sig.c))];
let skipAll = false;
// 乳牙不进种植/修复召回(同生产 missing_tooth.excludeDeciduous)。
// 独立内联实现(不共享生产的判定):乳牙 = FDI 51-85 或 宿主象限 1A-4E。
// 全乳牙缺失 → 不召(乳牙会被恒牙替换),且不可塌成全口召回。
if (sub.subKey === 'missing_tooth') {
const isDecid = (t: string) => /^[5-8][1-5]$/.test(t) || /^[1-4][A-E]$/.test(t);
const kept = teeth.filter((t) => !isDecid(toothBase(t)));
if (teeth.length > 0 && kept.length === 0) skipAll = true;
teeth = kept;
}
if (!skipAll) {
if (teeth.length === 0) {
evalUnit(WHOLE_OTHER, true, null);
} else {
for (const t of teeth) evalUnit(toothBase(t), false, toothBase(t));
}
}
}
}
return out;
}
// ─────────────────────────────────────────────────────────
// 对账:oracle 判定 vs 生产 plan_reasons
// ─────────────────────────────────────────────────────────
export type ReconMark = 'agree' | 'oracle_only' | 'prod_only';
export type ReconRow = {
key: string; // subKey|laneKey
laneKey: string;
toothLabel: string; // 牙位展示(全口泳道直接用泳道名)
subKey: string;
subLabel: string;
oracle: OracleVerdict | null; // 该单元的 oracle 判定(可能非 recall)
oracleRecall: boolean;
prodRecall: boolean;
mark: ReconMark;
};
/// 生产侧召回单元:从 plan.reasons[].signals 摊平成 (subKey, laneKey)
export type ProdSignal = {
subKey?: string | null;
toothPosition?: string | null;
} | null;
function wholeLaneForSub(subKey: string): string {
if (subKey === 'perio_no_srp') return WHOLE_PERIO;
if (subKey === 'ortho_no_consult') return WHOLE_ORTHO;
return WHOLE_OTHER;
}
export function reconcile(
verdicts: OracleVerdict[],
prodSignals: ProdSignal[],
): { rows: ReconRow[]; agree: number; oracleOnly: number; prodOnly: number } {
// 生产召回 key 集合
const prodSet = new Set<string>();
for (const s of prodSignals) {
if (!s?.subKey) continue;
const teethRaw = (s.toothPosition ?? '').trim();
if (!teethRaw) {
prodSet.add(`${s.subKey}|${wholeLaneForSub(s.subKey)}`);
} else {
for (const t of teethRaw.split(/[;,]+/).map((x) => x.trim()).filter(Boolean)) {
const tb = toothBase(t);
if (isValidTooth(tb)) prodSet.add(`${s.subKey}|${tb}`);
}
}
}
// oracle 单元(同 (subKey, laneKey) 取最强:recall > suppressed/cooldown/resolved/ineligible)
const rank: Record<OracleVerdictKind, number> = {
recall: 5,
suppressed: 4,
cooldown: 3,
resolved: 2,
superseded: 2,
ineligible: 1,
};
const oracleByKey = new Map<string, OracleVerdict>();
for (const v of verdicts) {
const k = `${v.subKey}|${v.laneKey}`;
const cur = oracleByKey.get(k);
if (!cur || rank[v.kind] > rank[cur.kind]) oracleByKey.set(k, v);
}
const allKeys = new Set<string>([...prodSet, ...oracleByKey.keys()]);
const rows: ReconRow[] = [];
let agree = 0,
oracleOnly = 0,
prodOnly = 0;
for (const k of allKeys) {
const [subKey, laneKey] = k.split('|') as [string, string];
const oracle = oracleByKey.get(k) ?? null;
const oracleRecall = oracle?.kind === 'recall';
const prodRecall = prodSet.has(k);
let mark: ReconMark;
if (oracleRecall && prodRecall) {
mark = 'agree';
agree++;
} else if (oracleRecall && !prodRecall) {
mark = 'oracle_only';
oracleOnly++;
} else if (!oracleRecall && prodRecall) {
mark = 'prod_only';
prodOnly++;
} else {
// 两边都不召(oracle 判 resolved/cooldown/… 且生产也没召)→ 不算分歧,不进对账行
continue;
}
const isWhole = laneKey.startsWith('全口');
rows.push({
key: k,
laneKey,
toothLabel: isWhole ? laneKey : `牙位 ${laneKey}`,
subKey,
subLabel: oracle?.subLabel ?? CODE_TO_SUB[subKeyToCode(subKey)]?.label ?? subKey,
oracle,
oracleRecall,
prodRecall,
mark,
});
}
// 排序:分歧优先(prod_only / oracle_only)→ agree;同组按牙位
const markOrder: Record<ReconMark, number> = { prod_only: 0, oracle_only: 1, agree: 2 };
rows.sort(
(a, b) =>
markOrder[a.mark] - markOrder[b.mark] ||
a.laneKey.localeCompare(b.laneKey, undefined, { numeric: true }),
);
return { rows, agree, oracleOnly, prodOnly };
}
/// subKey → 任一对应 code(仅用于 prod_only 行兜底 subLabel)
function subKeyToCode(subKey: string): string {
for (const [code, v] of Object.entries(CODE_TO_SUB)) {
if (v.subKey === subKey) return code;
}
return '';
}
export { toothBase as oracleToothBase };
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