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,
},
};
}
}
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