Major Changes: - Database: Install pg_bigm/pgvector plugins, create test database - Python service: v1.0 -> v1.1, add pymupdf4llm/openpyxl/pypandoc - Node.js backend: v1.3 -> v1.7, fix pino-pretty and ES Module imports - Frontend: v1.2 -> v1.3, skip TypeScript check for deployment - Code recovery: Restore empty files from local backup Technical Fixes: - Fix pino-pretty error in production (conditional loading) - Fix ES Module import paths (add .js extensions) - Fix OSSAdapter TypeScript errors - Update Prisma Schema (63 models, 16 schemas) - Update environment variables (DATABASE_URL, EXTRACTION_SERVICE_URL, OSS) - Remove deprecated variables (REDIS_URL, DIFY_API_URL, DIFY_API_KEY) Documentation: - Create 0126 deployment folder with 8 documents - Update database development standards v2.0 - Update SAE deployment status records Deployment Status: - PostgreSQL: ai_clinical_research_test with plugins - Python: v1.1 @ 172.17.173.84:8000 - Backend: v1.7 @ 172.17.173.89:3001 - Frontend: v1.3 @ 172.17.173.90:80 Tested: All services running successfully on SAE
300 lines
9.1 KiB
TypeScript
300 lines
9.1 KiB
TypeScript
/**
|
||
* Protocol Export Service
|
||
* 处理研究方案的生成和导出
|
||
*
|
||
* @module agent/protocol/services/ProtocolExportService
|
||
*/
|
||
|
||
import axios from 'axios';
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { logger } from '../../../../common/logging/index.js';
|
||
|
||
// Python 微服务地址
|
||
const EXTRACTION_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000';
|
||
|
||
// 方案章节配置
|
||
const PROTOCOL_SECTIONS = [
|
||
{ key: 'title', name: '研究题目' },
|
||
{ key: 'background', name: '研究背景与立题依据' },
|
||
{ key: 'objectives', name: '研究目的' },
|
||
{ key: 'design', name: '研究设计' },
|
||
{ key: 'subjects', name: '研究对象(纳入/排除标准)' },
|
||
{ key: 'sample_size', name: '样本量估算' },
|
||
{ key: 'implementation', name: '研究实施步骤与技术路线' },
|
||
{ key: 'endpoints', name: '观察指标' },
|
||
{ key: 'data_management', name: '数据管理与质量控制' },
|
||
{ key: 'safety', name: '安全性评价' },
|
||
{ key: 'statistics', name: '统计分析计划' },
|
||
{ key: 'ethics', name: '伦理与知情同意' },
|
||
{ key: 'timeline', name: '研究时间表' },
|
||
{ key: 'references', name: '参考文献' },
|
||
];
|
||
|
||
interface ContextData {
|
||
scientificQuestion?: {
|
||
content?: string;
|
||
summary?: string;
|
||
original?: string;
|
||
};
|
||
pico?: {
|
||
population?: string;
|
||
intervention?: string;
|
||
comparison?: string;
|
||
outcome?: string;
|
||
};
|
||
studyDesign?: {
|
||
studyType?: string;
|
||
design?: string[];
|
||
};
|
||
sampleSize?: {
|
||
sampleSize?: number;
|
||
calculation?: {
|
||
alpha?: number;
|
||
power?: number;
|
||
effectSize?: string;
|
||
};
|
||
};
|
||
endpoints?: {
|
||
outcomes?: {
|
||
primary?: string[];
|
||
secondary?: string[];
|
||
safety?: string[];
|
||
};
|
||
confounders?: string[];
|
||
};
|
||
}
|
||
|
||
export class ProtocolExportService {
|
||
private prisma: PrismaClient;
|
||
|
||
constructor(prisma: PrismaClient) {
|
||
this.prisma = prisma;
|
||
}
|
||
|
||
/**
|
||
* 检查 Pandoc 服务可用性
|
||
*/
|
||
async checkPandocStatus(): Promise<{
|
||
available: boolean;
|
||
version: string | null;
|
||
message: string;
|
||
}> {
|
||
try {
|
||
const response = await axios.get(`${EXTRACTION_SERVICE_URL}/api/pandoc/status`, {
|
||
timeout: 5000,
|
||
});
|
||
return response.data;
|
||
} catch (error) {
|
||
logger.error('[ProtocolExportService] Pandoc 状态检查失败:', error);
|
||
return {
|
||
available: false,
|
||
version: null,
|
||
message: `无法连接到文档服务: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据上下文数据生成 Markdown 格式的研究方案
|
||
*/
|
||
generateProtocolMarkdown(context: ContextData, title?: string): string {
|
||
const parts: string[] = [];
|
||
|
||
// 标题
|
||
const protocolTitle = title || this.generateTitle(context);
|
||
parts.push(`# 临床研究方案\n\n`);
|
||
|
||
// 1. 研究题目
|
||
parts.push(`## 1. 研究题目\n\n${protocolTitle}\n\n`);
|
||
|
||
// 2. 研究背景(占位,可由 LLM 生成)
|
||
parts.push(`## 2. 研究背景与立题依据\n\n`);
|
||
parts.push(`(待 LLM 根据科学问题生成)\n\n`);
|
||
|
||
// 3. 研究目的
|
||
parts.push(`## 3. 研究目的\n\n`);
|
||
if (context.scientificQuestion?.content) {
|
||
parts.push(`**主要目的**:${context.scientificQuestion.content}\n\n`);
|
||
}
|
||
|
||
// 4. 研究设计
|
||
parts.push(`## 4. 研究设计\n\n`);
|
||
if (context.studyDesign) {
|
||
if (context.studyDesign.studyType) {
|
||
parts.push(`**研究类型**:${context.studyDesign.studyType}\n\n`);
|
||
}
|
||
if (context.studyDesign.design && context.studyDesign.design.length > 0) {
|
||
parts.push(`**设计特征**:\n`);
|
||
context.studyDesign.design.forEach(d => {
|
||
parts.push(`- ${d}\n`);
|
||
});
|
||
parts.push('\n');
|
||
}
|
||
}
|
||
|
||
// 5. 研究对象
|
||
parts.push(`## 5. 研究对象(纳入/排除标准)\n\n`);
|
||
if (context.pico?.population) {
|
||
parts.push(`**目标人群**:${context.pico.population}\n\n`);
|
||
}
|
||
parts.push(`### 纳入标准\n\n(待补充)\n\n`);
|
||
parts.push(`### 排除标准\n\n(待补充)\n\n`);
|
||
|
||
// 6. 样本量估算
|
||
parts.push(`## 6. 样本量估算\n\n`);
|
||
if (context.sampleSize) {
|
||
if (context.sampleSize.sampleSize) {
|
||
parts.push(`**计划样本量**:${context.sampleSize.sampleSize} 例\n\n`);
|
||
}
|
||
if (context.sampleSize.calculation) {
|
||
const calc = context.sampleSize.calculation;
|
||
parts.push(`**计算依据**:\n`);
|
||
if (calc.alpha) parts.push(`- α = ${calc.alpha}\n`);
|
||
if (calc.power) parts.push(`- 1-β = ${calc.power}\n`);
|
||
if (calc.effectSize) parts.push(`- 效应量 = ${calc.effectSize}\n`);
|
||
parts.push('\n');
|
||
}
|
||
}
|
||
|
||
// 7. 研究实施步骤
|
||
parts.push(`## 7. 研究实施步骤与技术路线\n\n`);
|
||
if (context.pico) {
|
||
if (context.pico.intervention) {
|
||
parts.push(`**干预措施**:${context.pico.intervention}\n\n`);
|
||
}
|
||
if (context.pico.comparison) {
|
||
parts.push(`**对照措施**:${context.pico.comparison}\n\n`);
|
||
}
|
||
}
|
||
parts.push(`(技术路线图待补充)\n\n`);
|
||
|
||
// 8. 观察指标
|
||
parts.push(`## 8. 观察指标\n\n`);
|
||
if (context.endpoints?.outcomes) {
|
||
const outcomes = context.endpoints.outcomes;
|
||
if (outcomes.primary && outcomes.primary.length > 0) {
|
||
parts.push(`### 主要结局指标\n\n`);
|
||
outcomes.primary.forEach(o => parts.push(`- ${o}\n`));
|
||
parts.push('\n');
|
||
}
|
||
if (outcomes.secondary && outcomes.secondary.length > 0) {
|
||
parts.push(`### 次要结局指标\n\n`);
|
||
outcomes.secondary.forEach(o => parts.push(`- ${o}\n`));
|
||
parts.push('\n');
|
||
}
|
||
if (outcomes.safety && outcomes.safety.length > 0) {
|
||
parts.push(`### 安全性指标\n\n`);
|
||
outcomes.safety.forEach(o => parts.push(`- ${o}\n`));
|
||
parts.push('\n');
|
||
}
|
||
}
|
||
if (context.endpoints?.confounders && context.endpoints.confounders.length > 0) {
|
||
parts.push(`### 潜在混杂因素\n\n`);
|
||
context.endpoints.confounders.forEach(c => parts.push(`- ${c}\n`));
|
||
parts.push('\n');
|
||
}
|
||
|
||
// 9-14. 其他章节(占位)
|
||
parts.push(`## 9. 数据管理与质量控制\n\n(待补充)\n\n`);
|
||
parts.push(`## 10. 安全性评价\n\n(待补充)\n\n`);
|
||
parts.push(`## 11. 统计分析计划\n\n(待补充)\n\n`);
|
||
parts.push(`## 12. 伦理与知情同意\n\n本研究将遵循赫尔辛基宣言的伦理原则,并提交机构伦理委员会审批。\n\n`);
|
||
parts.push(`## 13. 研究时间表\n\n(待补充)\n\n`);
|
||
parts.push(`## 14. 参考文献\n\n(待补充)\n\n`);
|
||
|
||
return parts.join('');
|
||
}
|
||
|
||
/**
|
||
* 根据上下文生成研究题目
|
||
*/
|
||
private generateTitle(context: ContextData): string {
|
||
const parts: string[] = [];
|
||
|
||
// PICO 要素
|
||
if (context.pico) {
|
||
if (context.pico.population) parts.push(context.pico.population);
|
||
if (context.pico.intervention) parts.push(`使用${context.pico.intervention}`);
|
||
if (context.pico.comparison) parts.push(`与${context.pico.comparison}对比`);
|
||
if (context.pico.outcome) parts.push(`对${context.pico.outcome}影响`);
|
||
}
|
||
|
||
if (parts.length > 0) {
|
||
// 研究类型后缀
|
||
if (context.studyDesign?.studyType) {
|
||
parts.push(`的${context.studyDesign.studyType}`);
|
||
} else {
|
||
parts.push('的临床研究');
|
||
}
|
||
return parts.join('');
|
||
}
|
||
|
||
// 使用科学问题作为标题
|
||
if (context.scientificQuestion?.content) {
|
||
return context.scientificQuestion.content;
|
||
}
|
||
|
||
return '临床研究方案';
|
||
}
|
||
|
||
/**
|
||
* 将 Markdown 转换为 Word 文档
|
||
*/
|
||
async convertToDocx(markdown: string): Promise<Buffer> {
|
||
try {
|
||
logger.info('[ProtocolExportService] 开始转换 Markdown → Word');
|
||
|
||
const response = await axios.post(
|
||
`${EXTRACTION_SERVICE_URL}/api/convert/docx`,
|
||
{
|
||
content: markdown,
|
||
use_template: true,
|
||
title: '临床研究方案',
|
||
},
|
||
{
|
||
responseType: 'arraybuffer',
|
||
timeout: 30000, // 30秒超时
|
||
}
|
||
);
|
||
|
||
logger.info(`[ProtocolExportService] Word 转换成功, 大小: ${response.data.length} bytes`);
|
||
return Buffer.from(response.data);
|
||
} catch (error) {
|
||
logger.error('[ProtocolExportService] Word 转换失败:', error);
|
||
throw new Error(`Word 转换失败: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 导出研究方案为 Word
|
||
*/
|
||
async exportProtocol(
|
||
conversationId: string,
|
||
context: ContextData,
|
||
title?: string
|
||
): Promise<{
|
||
buffer: Buffer;
|
||
filename: string;
|
||
contentType: string;
|
||
}> {
|
||
// 1. 生成 Markdown
|
||
const markdown = this.generateProtocolMarkdown(context, title);
|
||
|
||
// 2. 转换为 Word
|
||
const buffer = await this.convertToDocx(markdown);
|
||
|
||
// 3. 生成文件名
|
||
const timestamp = new Date().toISOString().slice(0, 10);
|
||
const filename = `研究方案_${timestamp}.docx`;
|
||
|
||
return {
|
||
buffer,
|
||
filename,
|
||
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||
};
|
||
}
|
||
}
|
||
|
||
|
||
|