Files
AIclinicalresearch/backend/src/modules/asl/extraction/controllers/ExtractionController.ts
HaHafeng 5c5fec52c1 fix(aia,ssa,asl,infra): harden SSE transport and stabilize attachment context
Deliver SSE protocol hardening for SAE/HTTP2 paths, add graceful shutdown health behavior, and improve SSA retry UX for transient stream failures. For AIA, persist attachment extraction results in database with cache read-through fallback, plus production cache safety guard to prevent memory-cache drift in multi-instance deployments; also restore ASL SR page scrolling behavior.

Made-with: Cursor
2026-03-09 18:45:12 +08:00

244 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '../../../../config/database.js';
import { templateService } from '../services/TemplateService.js';
import { extractionService } from '../services/ExtractionService.js';
import { pkbBridgeService } from '../services/PkbBridgeService.js';
import { extractionEventBus } from '../services/ExtractionEventBus.js';
import { extractionExcelExporter } from '../services/ExtractionExcelExporter.js';
import { logger } from '../../../../common/logging/index.js';
function getUserId(request: FastifyRequest): string {
const userId = (request as any).user?.userId;
if (!userId) throw new Error('User not authenticated');
return userId;
}
/**
* 工具 3 全文提取 API 控制器
*/
// ═══════════════════════════════════════════
// 模板 API
// ═══════════════════════════════════════════
export async function listTemplates(request: FastifyRequest, reply: FastifyReply) {
const templates = await templateService.listSystemTemplates();
return reply.send({ success: true, data: templates });
}
export async function getTemplate(
request: FastifyRequest<{ Params: { templateId: string } }>,
reply: FastifyReply,
) {
const template = await templateService.getSystemTemplate(request.params.templateId);
return reply.send({ success: true, data: template });
}
export async function cloneTemplate(
request: FastifyRequest<{ Body: { projectId: string; baseTemplateId: string } }>,
reply: FastifyReply,
) {
const userId = getUserId(request);
const { projectId, baseTemplateId } = request.body;
const projectTemplate = await templateService.cloneToProject(projectId, baseTemplateId, userId);
return reply.send({ success: true, data: projectTemplate });
}
// ═══════════════════════════════════════════
// 提取任务 API
// ═══════════════════════════════════════════
export async function createTask(
request: FastifyRequest<{
Body: {
projectId: string;
projectTemplateId: string;
pkbKnowledgeBaseId: string;
documentIds: string[];
idempotencyKey?: string;
};
}>,
reply: FastifyReply,
) {
const userId = getUserId(request);
const { projectId, projectTemplateId, pkbKnowledgeBaseId, documentIds, idempotencyKey } = request.body;
const result = await extractionService.createTask({
projectId,
userId,
projectTemplateId,
pkbKnowledgeBaseId,
documentIds,
idempotencyKey,
pkbBridge: pkbBridgeService,
});
return reply.send({ success: true, ...result });
}
export async function getTaskStatus(
request: FastifyRequest<{ Params: { taskId: string } }>,
reply: FastifyReply,
) {
const status = await extractionService.getTaskStatus(request.params.taskId);
return reply.send({ success: true, data: status });
}
export async function getTaskResults(
request: FastifyRequest<{ Params: { taskId: string } }>,
reply: FastifyReply,
) {
const results = await extractionService.getResults(request.params.taskId);
return reply.send({ success: true, data: results });
}
// ═══════════════════════════════════════════
// PKB 数据代理 API前端通过 ASL 访问,不直接调 PKB
// ═══════════════════════════════════════════
export async function listKnowledgeBases(
request: FastifyRequest,
reply: FastifyReply,
) {
const userId = getUserId(request);
const kbs = await pkbBridgeService.listKnowledgeBases(userId);
return reply.send({ success: true, data: kbs });
}
export async function listDocuments(
request: FastifyRequest<{ Params: { kbId: string } }>,
reply: FastifyReply,
) {
const docs = await pkbBridgeService.listPdfDocuments(request.params.kbId);
return reply.send({ success: true, data: docs });
}
// ═══════════════════════════════════════════
// 单条提取结果详情 + 审核 APIM2 新增)
// ═══════════════════════════════════════════
export async function getResultDetail(
request: FastifyRequest<{ Params: { resultId: string } }>,
reply: FastifyReply,
) {
const result = await prisma.aslExtractionResult.findUnique({
where: { id: request.params.resultId },
include: {
task: {
select: {
projectTemplate: {
include: { baseTemplate: true },
},
},
},
},
});
if (!result) {
return reply.status(404).send({ success: false, error: 'Result not found' });
}
const baseFields = result.task?.projectTemplate?.baseTemplate?.baseFields as Record<string, any[]> | undefined;
const outcomeType = result.task?.projectTemplate?.outcomeType || 'survival';
// Build schema (filtered by outcomeType, same logic as TemplateService.assembleFullSchema)
let schema: Record<string, any[]> | undefined;
if (baseFields) {
schema = {};
for (const [mod, fields] of Object.entries(baseFields)) {
if (mod.startsWith('outcomes_') && mod !== `outcomes_${outcomeType}`) continue;
schema[mod] = fields;
}
}
return reply.send({
success: true,
data: {
id: result.id,
pkbDocumentId: result.pkbDocumentId,
snapshotFilename: result.snapshotFilename,
snapshotStorageKey: result.snapshotStorageKey,
status: result.status,
reviewStatus: result.reviewStatus,
extractedData: result.extractedData,
quoteVerification: result.quoteVerification,
errorMessage: result.errorMessage,
processedAt: result.processedAt,
createdAt: result.createdAt,
schema,
outcomeType,
},
});
}
export async function reviewResult(
request: FastifyRequest<{
Params: { resultId: string };
Body: { reviewStatus: 'approved' | 'rejected' };
}>,
reply: FastifyReply,
) {
const { reviewStatus } = request.body;
const updated = await prisma.aslExtractionResult.update({
where: { id: request.params.resultId },
data: {
reviewStatus,
reviewedAt: new Date(),
},
});
return reply.send({ success: true, data: updated });
}
// ═══════════════════════════════════════════
// SSE 日志流端点M2 新增)
// ═══════════════════════════════════════════
export async function streamTaskLogs(
request: FastifyRequest<{ Params: { taskId: string }; Querystring: { token?: string } }>,
reply: FastifyReply,
) {
const { taskId } = request.params;
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
});
// 首帧同步历史日志
const recentLogs = extractionEventBus.getRecentLogs(taskId);
reply.raw.write(`event: sync\ndata: ${JSON.stringify({ logs: recentLogs })}\n\n`);
// 订阅实时日志
const unsubscribe = extractionEventBus.subscribe(taskId, (entry) => {
reply.raw.write(`event: log\ndata: ${JSON.stringify(entry)}\n\n`);
});
// 心跳防断(每 15 秒)
const heartbeat = setInterval(() => {
reply.raw.write(':heartbeat\n\n');
}, 15_000);
// 客户端断开清理
request.raw.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
}
// ═══════════════════════════════════════════
// Excel 导出端点M2 新增)
// ═══════════════════════════════════════════
export async function exportTaskResults(
request: FastifyRequest<{ Params: { taskId: string } }>,
reply: FastifyReply,
) {
const { taskId } = request.params;
const buffer = await extractionExcelExporter.exportToExcel(taskId);
reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
reply.header('Content-Disposition', `attachment; filename="extraction-${taskId}.xlsx"`);
return reply.send(buffer);
}