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

@@ -93,4 +93,5 @@ export async function moduleRoutes(fastify: FastifyInstance) {

View File

@@ -123,4 +123,5 @@ export interface PaginatedResponse<T> {

View File

@@ -171,3 +171,4 @@ export const ROLE_DISPLAY_NAMES: Record<UserRole, string> = {

View File

@@ -8,11 +8,27 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { ProtocolOrchestrator } from '../services/ProtocolOrchestrator.js';
import { ProtocolExportService } from '../services/ProtocolExportService.js';
import { LLMServiceInterface } from '../../services/BaseAgentOrchestrator.js';
import { ProtocolStageCode } from '../../types/index.js';
import { createStreamingService } from '../../../../common/streaming/index.js';
import type { OpenAIMessage } from '../../../../common/streaming/index.js';
import { logger } from '../../../../common/logging/index.js';
import {
FULL_PROTOCOL_GENERATION_PROMPT,
TITLE_GENERATION_PROMPT,
fillPromptTemplate,
buildPromptVariables,
} from '../prompts/protocolGenerationPrompts.js';
// 阶段顺序
const STAGE_ORDER: ProtocolStageCode[] = [
'scientific_question',
'pico',
'study_design',
'sample_size',
'endpoints',
];
// 请求类型定义
interface SendMessageBody {
@@ -41,11 +57,13 @@ interface GetContextParams {
export class ProtocolAgentController {
private orchestrator: ProtocolOrchestrator;
private exportService: ProtocolExportService;
private prisma: PrismaClient;
constructor(prisma: PrismaClient, llmService: LLMServiceInterface) {
this.prisma = prisma;
this.orchestrator = new ProtocolOrchestrator({ prisma, llmService });
this.exportService = new ProtocolExportService(prisma);
}
/**
@@ -213,30 +231,42 @@ export class ProtocolAgentController {
// 获取当前阶段需要输出的字段格式
const stageOutputFormat = this.getStageOutputFormat(context.currentStage);
// 获取阶段任务描述
const stageInstructions = this.getStageInstructions(context.currentStage);
return `你是一位资深的临床研究方法学专家,正在帮助医生设计临床研究方案。
## ⚠️ 重要:当前阶段
**🎯 你现在正在处理「${currentStageName}」阶段(第 ${STAGE_ORDER.indexOf(context.currentStage as ProtocolStageCode) + 1}/5 阶段)**
${stageInstructions}
## 当前状态
- **当前阶段**: ${currentStageName}
- **已完成阶段**: ${completedStages.map((s: string) => stageNames[s]).join(', ') || '无'}
- **进度**: ${progress}%
## 已收集的数据${completedDataSummary || '\n暂无已确认的数据'}
## 已收集的数据(仅供参考,不要重复讨论)${completedDataSummary || '\n暂无已确认的数据'}
## 你的任务
1. **只围绕「${currentStageName}」阶段与用户对话**,不要跨阶段讨论
2. 引导用户提供当前阶段所需的完整信息
3. 当信息收集完整时,先用文字总结,然后**必须**在回复末尾输出结构化数据
## ❌ 禁止事项
1. **不要讨论已完成的阶段**(如${completedStages.map((s: string) => stageNames[s]).join('、') || '无'}
2. **不要提前讨论未来的阶段**
3. **不要重复已经确认的数据**
## 当前阶段「${currentStageName}」的输出格式
当信息完整时,**必须**在回复末尾添加以下格式的数据提取标签:
## ✅ 你的任务
1. **只围绕「${currentStageName}」阶段与用户对话**
2. 引导用户提供当前阶段所需的信息
3. 当信息收集完整时,先用文字总结,然后在回复末尾输出结构化数据
## 当前阶段「${currentStageName}」的数据格式
当信息完整时,**必须**在回复末尾添加:
${stageOutputFormat}
## 重要提示
- 只有当用户提供了足够的信息后才输出 <extracted_data> 标签
- 输出的 JSON 必须是有效格式
- 每次对话只关注当前阶段${currentStageName}
- 回复使用 Markdown 格式,简洁专业`;
## 输出要求
- 只有当用户提供了足够的${currentStageName}信息后才输出 <extracted_data> 标签
- 回复使用 Markdown 格式,简洁专业
- **再次强调:你只处理${currentStageName}**`;
}
/**
@@ -296,6 +326,50 @@ ${stageOutputFormat}
</extracted_data>`;
}
/**
* 获取阶段任务说明(帮助模型理解当前阶段应该做什么)
*/
private getStageInstructions(stageCode: string): string {
const instructions: Record<string, string> = {
scientific_question: `**当前任务:科学问题梳理**
你需要帮助用户明确研究的核心科学问题。
- 询问用户想研究什么问题
- 了解研究背景和临床意义
- 引导用户将问题凝练成一句话`,
pico: `**当前任务PICO要素确定**
你需要帮助用户确定PICO四要素
- P (Population): 研究人群是谁?
- I (Intervention): 干预/暴露因素是什么?
- C (Comparison): 对照组是什么?
- O (Outcome): 主要结局指标是什么?`,
study_design: `**当前任务:研究设计选择**
你需要帮助用户选择合适的研究设计类型:
- 根据研究问题推荐研究类型RCT/队列/病例对照等)
- 讨论设计特征(随机/盲法/多中心等)
- 确定分组方法`,
sample_size: `**当前任务:样本量计算**
你需要帮助用户估算所需样本量:
- 讨论主要结局指标的预期效应量
- 确定统计学参数(α, β/Power
- 考虑脱落率
- 计算最终样本量`,
endpoints: `**当前任务:观察指标设计**
你需要帮助用户定义研究的观察指标体系:
- 主要结局指标Primary Outcome
- 次要结局指标Secondary Outcomes
- 安全性指标
- 潜在混杂因素
⚠️ 注意:样本量计算已在上一阶段完成,本阶段只讨论观察指标的定义和测量方法。`,
};
return instructions[stageCode] || '';
}
/**
* 同步阶段数据
* POST /api/aia/protocol-agent/sync
@@ -375,28 +449,91 @@ ${stageOutputFormat}
}
/**
* 一键生成研究方案
* POST /api/aia/protocol-agent/generate
* 获取对话的历史消息
* GET /api/aia/protocol-agent/messages/:conversationId
*/
async generateProtocol(
request: FastifyRequest<{ Body: GenerateProtocolBody }>,
async getMessages(
request: FastifyRequest<{ Params: GetContextParams }>,
reply: FastifyReply
): Promise<void> {
try {
const { conversationId, options } = request.body;
const userId = (request as any).user?.userId;
if (!userId) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
const { conversationId } = request.params;
if (!conversationId) {
reply.code(400).send({ error: 'Missing conversationId' });
return;
}
// 获取上下文
// 验证对话是否存在
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
});
if (!conversation) {
reply.code(404).send({ error: 'Conversation not found' });
return;
}
// 获取历史消息
const messages = await this.prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
select: {
id: true,
role: true,
content: true,
thinkingContent: true,
createdAt: true,
},
});
reply.send({
success: true,
data: {
messages: messages.map(m => ({
id: m.id,
role: m.role,
content: m.content,
thinkingContent: m.thinkingContent,
createdAt: m.createdAt,
})),
},
});
} catch (error) {
console.error('[ProtocolAgentController] getMessages error:', error);
reply.code(500).send({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}
/**
* 一键生成研究方案(流式输出)
* POST /api/aia/protocol-agent/generate
*
* 必填要素科学问题、PICO、研究设计、观察指标4/5
* 可选要素:样本量
*/
async generateProtocol(
request: FastifyRequest<{ Body: GenerateProtocolBody }>,
reply: FastifyReply
): Promise<void> {
const { conversationId, options } = request.body;
const userId = (request as any).user?.userId;
if (!userId) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
if (!conversationId) {
reply.code(400).send({ error: 'Missing conversationId' });
return;
}
try {
// 1. 获取上下文
const contextService = this.orchestrator.getContextService();
const context = await contextService.getContext(conversationId);
@@ -405,27 +542,98 @@ ${stageOutputFormat}
return;
}
// 检查是否所有阶段都已完成
if (!contextService.isAllStagesCompleted(context)) {
// 2. 校验必填要素4/5科学问题、PICO、研究设计、观察指标
const requiredStages = ['scientific_question', 'pico', 'study_design', 'endpoints'];
const completedStages = context.completedStages || [];
const missingStages = requiredStages.filter(s => !completedStages.includes(s));
if (missingStages.length > 0) {
const stageNames: Record<string, string> = {
scientific_question: '科学问题',
pico: 'PICO要素',
study_design: '研究设计',
endpoints: '观察指标',
};
const missingNames = missingStages.map(s => stageNames[s]).join('、');
reply.code(400).send({
error: '请先完成所有5个阶段科学问题、PICO、研究设计、样本量、观察指标'
error: `请先完成必填要素:${missingNames}`,
missingStages,
});
return;
}
// TODO: 实现方案生成逻辑
// 这里先返回占位响应实际应该调用LLM生成完整方案
reply.send({
success: true,
data: {
generationId: 'placeholder',
status: 'generating',
message: '研究方案生成中...',
estimatedTime: 30,
logger.info('[ProtocolAgentController] 开始生成研究方案', {
conversationId,
completedStages,
style: options?.style || 'academic',
});
// 3. 构建 Prompt 变量
const promptVariables = buildPromptVariables({
scientificQuestion: context.scientificQuestion as any,
pico: context.pico as any,
studyDesign: context.studyDesign as any,
sampleSize: context.sampleSize as any,
endpoints: context.endpoints as any,
});
// 4. 先生成标题
const titlePrompt = fillPromptTemplate(TITLE_GENERATION_PROMPT, promptVariables);
// 5. 生成完整方案(流式)
const fullPromptWithTitle = fillPromptTemplate(FULL_PROTOCOL_GENERATION_PROMPT, {
...promptVariables,
title: '(标题将根据内容自动生成)', // 占位,实际会在内容中生成
});
// 6. 构建消息
const messages: OpenAIMessage[] = [
{ role: 'system', content: fullPromptWithTitle },
{ role: 'user', content: '请根据以上关键要素,生成完整的临床研究方案。使用 Markdown 格式输出按照12个章节结构撰写。' },
];
// 7. 使用 StreamingService 流式输出
const streamingService = createStreamingService(reply, {
model: 'deepseek-v3',
temperature: 0.7,
maxTokens: 8192, // 长文档需要更多 tokens
enableDeepThinking: false,
userId,
conversationId,
});
await streamingService.streamGenerate(messages, {
onComplete: async (fullContent, thinkingContent) => {
// 保存生成的方案到数据库
await this.prisma.message.create({
data: {
conversationId,
role: 'assistant',
content: fullContent,
thinkingContent: thinkingContent || null,
model: 'deepseek-v3',
metadata: {
type: 'protocol_generation',
style: options?.style || 'academic',
},
},
});
logger.info('[ProtocolAgentController] 研究方案生成完成', {
conversationId,
contentLength: fullContent.length,
});
},
onError: (error) => {
logger.error('[ProtocolAgentController] 研究方案生成失败', {
error,
conversationId
});
},
});
} catch (error) {
console.error('[ProtocolAgentController] generateProtocol error:', error);
logger.error('[ProtocolAgentController] generateProtocol error:', error);
reply.code(500).send({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
@@ -465,29 +673,100 @@ ${stageOutputFormat}
/**
* 导出Word文档
* POST /api/aia/protocol-agent/generation/:generationId/export
* POST /api/aia/protocol-agent/export/docx
*
* 支持两种模式:
* 1. 提供 content: 直接将前端生成的内容转换为 Word
* 2. 不提供 content: 从上下文数据生成模板(旧模式)
*/
async exportWord(
request: FastifyRequest<{
Params: { generationId: string };
Body: { format: 'docx' | 'pdf' };
Body: { conversationId?: string; content?: string };
}>,
reply: FastifyReply
): Promise<void> {
try {
const { generationId } = request.params;
const { format } = request.body;
const { conversationId, content } = request.body;
const userId = (request as any).user?.userId;
// TODO: 实现导出逻辑
if (!userId) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
let markdown: string;
// 模式1使用前端提供的内容推荐
if (content && content.trim()) {
logger.info('[ProtocolAgentController] 导出 Word: 使用前端生成的内容', {
contentLength: content.length,
});
markdown = content;
}
// 模式2从上下文生成模板兼容旧逻辑
else if (conversationId) {
logger.info('[ProtocolAgentController] 导出 Word: 从上下文生成模板', {
conversationId,
});
const contextService = this.orchestrator.getContextService();
const context = await contextService.getContext(conversationId);
if (!context) {
reply.code(404).send({ error: 'Context not found' });
return;
}
markdown = this.exportService.generateProtocolMarkdown({
scientificQuestion: context.scientificQuestion as any,
pico: context.pico as any,
studyDesign: context.studyDesign as any,
sampleSize: context.sampleSize as any,
endpoints: context.endpoints as any,
});
} else {
reply.code(400).send({ error: 'Missing content or conversationId' });
return;
}
// 调用导出服务转换为 Word
const buffer = await this.exportService.convertToDocx(markdown);
// 生成文件名
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `研究方案_${timestamp}.docx`;
// 返回文件
reply
.header('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
.send(buffer);
} catch (error) {
logger.error('[ProtocolAgentController] exportWord error:', error);
reply.code(500).send({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}
/**
* 检查 Pandoc 服务状态
* GET /api/aia/protocol-agent/export/status
*/
async checkExportStatus(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
try {
const status = await this.exportService.checkPandocStatus();
reply.send({
success: true,
data: {
downloadUrl: `/api/aia/protocol-agent/download/${generationId}.${format}`,
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
},
data: status,
});
} catch (error) {
console.error('[ProtocolAgentController] exportWord error:', error);
logger.error('[ProtocolAgentController] checkExportStatus error:', error);
reply.code(500).send({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',

View File

@@ -0,0 +1,246 @@
/**
* Protocol Generation Prompts
* 研究方案生成的 Prompt 模板
*
* @module agent/protocol/prompts
*/
/**
* 方案标题生成 Prompt
*/
export const TITLE_GENERATION_PROMPT = `你是一位资深的临床研究方案撰写专家。请根据以下关键要素,生成一个专业、准确、符合学术规范的研究方案标题。
## 关键要素
**科学问题**: {{scientificQuestion}}
**PICO 框架**:
- P (研究人群): {{population}}
- I (干预措施): {{intervention}}
- C (对照措施): {{comparison}}
- O (结局指标): {{outcome}}
**研究设计**: {{studyType}}
## 标题要求
1. 标题应包含:研究人群、干预措施、对照措施、主要结局、研究类型
2. 格式参考:「[干预措施]对[研究人群][主要结局]的[研究类型]」
3. 标题长度20-50字
4. 使用专业医学术语
5. 不要包含标点符号(除必要的顿号)
## 输出格式
只输出标题文本,不要包含任何解释或前缀。`;
/**
* 完整方案生成 Prompt流式输出
*/
export const FULL_PROTOCOL_GENERATION_PROMPT = `你是一位资深的临床研究方案撰写专家,拥有丰富的 RCT、队列研究、病例对照研究方案撰写经验。请根据以下关键要素生成一份完整的临床研究方案。
## 关键要素
**研究标题**: {{title}}
**科学问题**: {{scientificQuestion}}
**PICO 框架**:
- P (研究人群): {{population}}
- I (干预措施): {{intervention}}
- C (对照措施): {{comparison}}
- O (结局指标): {{outcome}}
**研究设计**:
- 研究类型: {{studyType}}
- 设计特征: {{designFeatures}}
**样本量**: {{sampleSize}}
- 计算依据: α={{alpha}}, Power={{power}}, 效应量={{effectSize}}
**观察指标**:
- 主要结局: {{primaryOutcomes}}
- 次要结局: {{secondaryOutcomes}}
- 安全性指标: {{safetyOutcomes}}
## 输出要求
请按以下 12 个章节结构输出,使用 Markdown 格式:
# {{title}}
## 1. 研究背景 (Background)
- 疾病/问题的流行病学数据
- 当前治疗现状与挑战
- 研究的科学依据和立题依据
- 为什么需要本研究(知识缺口)
## 2. 研究目的 (Objectives)
- **主要目的**: 基于科学问题,明确主要研究假设
- **次要目的**: 列出 2-3 个次要目的
## 3. 研究设计 (Study Design)
- 研究类型RCT/队列/病例对照等)
- 设计特征(随机/盲法/多中心等)
- 分组方法和随机化方案
## 4. 研究对象 (Subjects)
### 4.1 纳入标准
- 基于 P研究人群制定 5-8 条纳入标准
### 4.2 排除标准
- 制定 5-8 条排除标准(安全性考虑、混杂控制)
## 5. 样本量估算 (Sample Size)
- 主要结局指标
- 预期效应量
- 统计学参数(α, β, Power
- 计算公式和结果
- 考虑脱落率后的最终样本量
## 6. 研究实施步骤 (Implementation)
- 研究流程图
- 各阶段时间节点
- 干预措施详细说明
- 对照措施详细说明
- 访视计划
## 7. 观察指标 (Endpoints)
### 7.1 主要结局指标
- 指标定义、测量方法、测量时点
### 7.2 次要结局指标
- 各指标的定义和测量方法
### 7.3 安全性指标
- 不良事件定义和报告流程
## 8. 数据管理与质量控制 (Data Management)
- 数据收集方法
- 数据录入和核查
- 质量控制措施
- 数据安全和保密
## 9. 统计分析计划 (Statistical Analysis)
- 分析人群定义ITT/PP
- 描述性统计方法
- 主要结局分析方法
- 次要结局分析方法
- 缺失数据处理
- 亚组分析计划
## 10. 伦理与知情同意 (Ethics)
- 伦理审批要求
- 知情同意流程
- 受试者权益保护
- 数据隐私保护
## 11. 研究时间表 (Timeline)
- 研究各阶段时间安排
- 里程碑节点
## 12. 参考文献 (References)
- 列出 3-5 篇支持本研究的核心文献
## 撰写规范
1. **语言**: 使用专业、严谨的学术语言
2. **格式**: 严格按照上述 Markdown 格式输出
3. **内容**: 每个章节都要有实质性内容,不要使用占位符
4. **逻辑**: 各章节内容要前后呼应,逻辑一致
5. **篇幅**: 每个章节 200-500 字,总计约 4000-6000 字
## 重要提示
- **直接输出方案内容**,不要包含任何开场白、问候语或解释性文字
- **禁止**输出类似"好的,作为..."、"我将为您..."等引导语
- **第一行必须是**# {{title}}(方案标题)
- 立即开始输出方案正文内容`;
/**
* 单章节生成 Prompt用于"讨论与优化"后重新生成)
*/
export const SECTION_REGENERATION_PROMPT = `你是一位资深的临床研究方案撰写专家。请根据用户的反馈,重新撰写研究方案的「{{sectionTitle}}」章节。
## 研究基本信息
**研究标题**: {{title}}
**科学问题**: {{scientificQuestion}}
**PICO**: P={{population}}, I={{intervention}}, C={{comparison}}, O={{outcome}}
**研究设计**: {{studyType}}
## 当前章节内容
{{currentContent}}
## 用户反馈
{{userFeedback}}
## 输出要求
1. 根据用户反馈修改和优化当前章节
2. 保持与其他章节的一致性和连贯性
3. 使用 Markdown 格式
4. 只输出该章节内容,以「## {{sectionNumber}}. {{sectionTitle}}」开头
请直接输出优化后的章节内容:`;
/**
* 章节配置
*/
export const PROTOCOL_SECTIONS = [
{ id: 'background', number: 1, title: '研究背景 (Background)' },
{ id: 'objectives', number: 2, title: '研究目的 (Objectives)' },
{ id: 'design', number: 3, title: '研究设计 (Study Design)' },
{ id: 'subjects', number: 4, title: '研究对象 (Subjects)' },
{ id: 'sample_size', number: 5, title: '样本量估算 (Sample Size)' },
{ id: 'implementation', number: 6, title: '研究实施步骤 (Implementation)' },
{ id: 'endpoints', number: 7, title: '观察指标 (Endpoints)' },
{ id: 'data_management', number: 8, title: '数据管理与质量控制 (Data Management)' },
{ id: 'statistics', number: 9, title: '统计分析计划 (Statistical Analysis)' },
{ id: 'ethics', number: 10, title: '伦理与知情同意 (Ethics)' },
{ id: 'timeline', number: 11, title: '研究时间表 (Timeline)' },
{ id: 'references', number: 12, title: '参考文献 (References)' },
] as const;
/**
* 填充 Prompt 模板
*/
export function fillPromptTemplate(
template: string,
variables: Record<string, string | number | undefined>
): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
result = result.replace(new RegExp(placeholder, 'g'), String(value ?? '未提供'));
}
return result;
}
/**
* 从 Context 数据构建 Prompt 变量
*/
export function buildPromptVariables(context: {
scientificQuestion?: { content?: 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[] } };
}): Record<string, string> {
return {
scientificQuestion: context.scientificQuestion?.content || '',
population: context.pico?.population || '',
intervention: context.pico?.intervention || '',
comparison: context.pico?.comparison || '',
outcome: context.pico?.outcome || '',
studyType: context.studyDesign?.studyType || '',
designFeatures: context.studyDesign?.design?.join('、') || '',
sampleSize: context.sampleSize?.sampleSize?.toString() || '待计算',
alpha: context.sampleSize?.calculation?.alpha?.toString() || '0.05',
power: context.sampleSize?.calculation?.power?.toString() || '0.8',
effectSize: context.sampleSize?.calculation?.effectSize || '',
primaryOutcomes: context.endpoints?.outcomes?.primary?.join('、') || '',
secondaryOutcomes: context.endpoints?.outcomes?.secondary?.join('、') || '',
safetyOutcomes: context.endpoints?.outcomes?.safety?.join('、') || '',
};
}

View File

@@ -81,6 +81,22 @@ export async function protocolAgentRoutes(
},
}, (request, reply) => controller.getContext(request, reply));
// 获取历史消息
fastify.get<{
Params: { conversationId: string };
}>('/messages/:conversationId', {
preHandler: [authenticate],
schema: {
params: {
type: 'object',
required: ['conversationId'],
properties: {
conversationId: { type: 'string' },
},
},
},
}, (request, reply) => controller.getMessages(request, reply));
// 一键生成研究方案
fastify.post<{
Body: {
@@ -127,27 +143,27 @@ export async function protocolAgentRoutes(
}, (request, reply) => controller.getGeneration(request, reply));
// 导出Word文档
// 支持两种方式:
// 1. 提供 content: 直接转换前端生成的内容(推荐)
// 2. 提供 conversationId: 从上下文数据生成模板
fastify.post<{
Params: { generationId: string };
Body: { format: 'docx' | 'pdf' };
}>('/generation/:generationId/export', {
Body: { conversationId?: string; content?: string };
}>('/export/docx', {
preHandler: [authenticate],
schema: {
params: {
type: 'object',
required: ['generationId'],
properties: {
generationId: { type: 'string' },
},
},
body: {
type: 'object',
required: ['format'],
properties: {
format: { type: 'string', enum: ['docx', 'pdf'] },
conversationId: { type: 'string' },
content: { type: 'string', description: '前端生成的 Markdown 内容' },
},
},
},
}, (request, reply) => controller.exportWord(request, reply));
// 检查导出服务状态
fastify.get('/export/status', {
preHandler: [authenticate],
}, (request, reply) => controller.checkExportStatus(request, reply));
}

View File

@@ -182,3 +182,4 @@ export function createLLMServiceAdapter(): LLMServiceInterface {
}

View File

@@ -284,3 +284,4 @@ export class PromptBuilder {
}

View File

@@ -189,6 +189,36 @@ export class ProtocolContextService {
return requiredStages.every(stage => context.completedStages.includes(stage));
}
/**
* 检查是否可以生成研究方案4/5 必填项)
* 必填科学问题、PICO、研究设计、观察指标
* 可选:样本量
*/
canGenerateProtocol(context: ProtocolContextData): boolean {
const requiredStages: ProtocolStageCode[] = [
'scientific_question',
'pico',
'study_design',
'endpoints',
];
return requiredStages.every(stage => context.completedStages.includes(stage));
}
/**
* 获取缺失的必填阶段
*/
getMissingRequiredStages(context: ProtocolContextData): ProtocolStageCode[] {
const requiredStages: ProtocolStageCode[] = [
'scientific_question',
'pico',
'study_design',
'endpoints',
];
return requiredStages.filter(stage => !context.completedStages.includes(stage));
}
/**
* 获取进度百分比
*/

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

View File

@@ -212,51 +212,90 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator {
): Promise<Record<string, unknown>> {
// 构建凝练 Prompt
const condensePrompts: Record<ProtocolStageCode, string> = {
scientific_question: `以下科学问题内容凝练成一句话不超过50字保留核心要点
scientific_question: `提取并整理以下科学问题的完整内容:
原始内容:
${JSON.stringify(data, null, 2)}
要求:
- 输出格式:{ "content": "一句话科学问题" }
- 保留完整的科学问题描述100-200字
- 包含研究背景、研究对象、研究目的
- 输出格式:{ "content": "完整科学问题描述" }
- 只输出 JSON不要其他内容`,
pico: `以下 PICO 要素凝练成简短描述
pico: `提取并整理以下 PICO 要素的详细内容
原始内容:
${JSON.stringify(data, null, 2)}
要求:
- 每个要素不超过20字
- Population研究人群50-100字包含纳入排除标准要点
- Intervention干预措施50-100字包含具体干预方法
- Comparison对照50-100字包含对照组设置
- Outcome结局指标50-100字包含主要和次要结局
- 输出格式:{ "population": "...", "intervention": "...", "comparison": "...", "outcome": "..." }
- 只输出 JSON不要其他内容`,
study_design: `以下研究设计凝练成关键标签
study_design: `提取并整理以下研究设计的详细内容
原始内容:
${JSON.stringify(data, null, 2)}
要求:
- 输出格式:{ "studyType": "研究类型", "design": ["特征1", "特征2"] }
- 包含研究类型、设计特征、分组方法、盲法等
- 输出格式:{ "studyType": "研究类型(如:前瞻性队列研究)", "design": ["设计特征1", "设计特征2", "..."], "details": "其他设计细节说明" }
- 只输出 JSON不要其他内容`,
sample_size: `请提取样本量关键数据
sample_size: `请提取并整理以下样本量计算的完整内容
原始内容:
${JSON.stringify(data, null, 2)}
要求:
- 输出格式:{ "sampleSize": 数字, "calculation": { "alpha": 数字, "power": 数字 } }
- 保留计算过程和参数依据
- 输出格式:
{
"sampleSize": 总样本量数字,
"calculation": {
"alpha": α值,
"power": 检验效能,
"effectSize": "效应值及来源说明"
},
"rationale": "参数设定依据(如文献来源、预实验数据等)",
"groups": [
{ "name": "组名", "size": 人数 }
],
"dropoutRate": "预计失访率及调整说明"
}
- 只输出 JSON不要其他内容`,
endpoints: `以下观察指标凝练成简短列表
endpoints: `提取并整理以下观察指标的详细内容
原始内容:
${JSON.stringify(data, null, 2)}
要求:
- 每个指标不超过10字
- 输出格式:{ "baseline": {...}, "exposure": {...}, "outcomes": {...}, "confounders": [...] }
- 保留完整的指标定义、测量方法和时间点
- 输出格式:
{
"baseline": {
"demographics": ["人口学特征1", "人口学特征2", "..."],
"clinicalHistory": ["临床病史1", "临床病史2", "..."]
},
"exposure": {
"name": "暴露因素名称",
"definition": "暴露的具体定义",
"measurement": "测量方法",
"timing": "测量时间点"
},
"outcomes": {
"primary": ["主要结局指标1含定义", "主要结局指标2含定义"],
"secondary": ["次要结局指标1含定义", "次要结局指标2含定义"],
"safety": ["安全性指标1", "安全性指标2"]
},
"confounders": ["混杂因素1", "混杂因素2", "..."],
"followUp": "随访方案和时间节点"
}
- 只输出 JSON不要其他内容`,
};
@@ -428,7 +467,7 @@ ${JSON.stringify(data, null, 2)}
stageName: STAGE_NAMES[context.currentStage] || context.currentStage,
progress: this.contextService.getProgress(context),
stages: this.contextService.getStagesStatus(context),
canGenerate: this.contextService.isAllStagesCompleted(context),
canGenerate: this.contextService.canGenerateProtocol(context),
};
}
}

View File

@@ -380,3 +380,4 @@ export interface PromptRenderContext {
}

View File

@@ -246,3 +246,4 @@ async function matchIntent(query: string): Promise<{

View File

@@ -100,3 +100,4 @@ export async function uploadAttachment(

View File

@@ -29,3 +29,4 @@ export { aiaRoutes };

View File

@@ -371,5 +371,6 @@ runTests().catch((error) => {

View File

@@ -350,5 +350,6 @@ Content-Type: application/json

View File

@@ -286,5 +286,6 @@ export const conflictDetectionService = new ConflictDetectionService();

View File

@@ -236,5 +236,6 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \

View File

@@ -290,5 +290,6 @@ export const streamAIController = new StreamAIController();

View File

@@ -199,5 +199,6 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', {

View File

@@ -133,5 +133,6 @@ checkTableStructure();

View File

@@ -120,5 +120,6 @@ checkProjectConfig().catch(console.error);

View File

@@ -102,5 +102,6 @@ main();

View File

@@ -559,5 +559,6 @@ URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback

View File

@@ -194,5 +194,6 @@ console.log('');

View File

@@ -511,5 +511,6 @@ export const patientWechatService = new PatientWechatService();

View File

@@ -156,5 +156,6 @@ testDifyIntegration().catch(error => {

View File

@@ -185,5 +185,6 @@ testIitDatabase()

View File

@@ -171,5 +171,6 @@ if (hasError) {

View File

@@ -197,5 +197,6 @@ async function testUrlVerification() {

View File

@@ -278,5 +278,6 @@ main().catch((error) => {

View File

@@ -162,5 +162,6 @@ Write-Host ""

View File

@@ -255,5 +255,6 @@ export interface CachedProtocolRules {

View File

@@ -68,5 +68,6 @@ export default async function healthRoutes(fastify: FastifyInstance) {

View File

@@ -146,5 +146,6 @@ Content-Type: application/json

View File

@@ -131,5 +131,6 @@ Write-Host " - 删除任务: DELETE $BaseUrl/api/v1/rvw/tasks/{taskId}" -Foregr

View File

@@ -45,5 +45,6 @@ export * from './services/utils.js';

View File

@@ -136,5 +136,6 @@ export function validateAgentSelection(agents: string[]): void {