Files
AIclinicalresearch/backend/src/modules/agent/protocol/services/ProtocolExportService.ts
HaHafeng 2481b786d8 deploy: Complete 0126-27 deployment - database upgrade, services update, code recovery
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
2026-01-27 08:13:27 +08:00

300 lines
9.1 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.
/**
* 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',
};
}
}