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:
2026-01-25 19:16:36 +08:00
parent 4d7d97ca19
commit 303dd78c54
332 changed files with 6204 additions and 617 deletions

View File

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