feat(aia): Protocol Agent MVP complete with one-click generation and Word export
- Add one-click research protocol generation with streaming output - Implement Word document export via Pandoc integration - Add dynamic dual-panel layout with resizable split pane - Implement collapsible content for StatePanel stages - Add conversation history management with title auto-update - Fix scroll behavior, markdown rendering, and UI layout issues - Simplify conversation creation logic for reliability
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user