feat(ssa): Implement dual-channel architecture Phase 1-3 (QPER + LLM Agent pipeline)
Completed: - Phase 1: DB schema (execution_mode + ssa_agent_executions), ModeToggle component, Session PATCH API - Phase 2: AgentPlannerService + AgentCoderService (streaming) + CodeRunnerService + R Docker /execute-code endpoint - Phase 3: AgentCodePanel (3-step confirmation UI), SSE event handling (7 agent events), streaming code display - Three-step confirmation pipeline: plan -> user confirm -> stream code -> user confirm -> execute R code -> results - R Docker sandbox /execute-code endpoint with 120s timeout + block_helpers preloaded - E2E dual-channel test script (8 tests) - Updated R engine architecture doc (v1.5) and SSA module status doc (v4.0) Technical details: - AgentCoderService uses LLM streaming (chatStream) for real-time code generation feedback - ReviewerAgent temporarily disabled, prioritizing Plan -> Code -> Execute flow - CodeRunnerService wraps user code with auto data loading (df variable injection) - Frontend handles agent_planning, agent_plan_ready, code_generating, code_generated, code_executing, code_result events - ask_user mechanism used for plan and code confirmation steps Files: 24 files (4 new services, 2 new components, 1 migration, 1 E2E test, 16 modified) Made-with: Cursor
This commit is contained in:
@@ -17,6 +17,10 @@ import { tokenTruncationService } from './TokenTruncationService.js';
|
||||
import { methodConsultService } from './MethodConsultService.js';
|
||||
import { askUserService, type AskUserResponse } from './AskUserService.js';
|
||||
import { toolOrchestratorService } from './ToolOrchestratorService.js';
|
||||
import { agentPlannerService } from './AgentPlannerService.js';
|
||||
import { agentCoderService } from './AgentCoderService.js';
|
||||
import { codeRunnerService } from './CodeRunnerService.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import type { IntentType } from './SystemPromptService.js';
|
||||
import type { IntentResult } from './IntentRouterService.js';
|
||||
|
||||
@@ -387,6 +391,377 @@ export class ChatHandlerService {
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Agent 通道入口(双通道架构 Phase 1 骨架)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Agent 模式入口 — 三步确认式管线
|
||||
*
|
||||
* 状态机:
|
||||
* 新请求 → agentGeneratePlan → plan_pending(等用户确认)
|
||||
* 用户确认计划 → agentStreamCode → code_pending(等用户确认)
|
||||
* 用户确认代码 → agentExecuteCode → completed
|
||||
*/
|
||||
async handleAgentMode(
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
userContent: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
): Promise<HandleResult> {
|
||||
try {
|
||||
// 1. 检查是否有等待确认的执行记录
|
||||
const activeExec = await (prisma as any).ssaAgentExecution.findFirst({
|
||||
where: { sessionId, status: { in: ['plan_pending', 'code_pending'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (activeExec) {
|
||||
const action = this.parseAgentAction(userContent);
|
||||
|
||||
if (activeExec.status === 'plan_pending') {
|
||||
if (action === 'confirm') {
|
||||
return await this.agentStreamCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (action === 'cancel') {
|
||||
return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeExec.status === 'code_pending') {
|
||||
if (action === 'confirm') {
|
||||
return await this.agentExecuteCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (action === 'cancel') {
|
||||
return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无挂起确认 — 检查是否是分析请求
|
||||
const blackboard = await sessionBlackboardService.get(sessionId);
|
||||
const hasData = !!blackboard?.dataOverview;
|
||||
|
||||
if (hasData && this.looksLikeAnalysisRequest(userContent)) {
|
||||
return await this.agentGeneratePlan(sessionId, conversationId, userContent, writer, placeholderMessageId);
|
||||
}
|
||||
|
||||
return await this.handleAgentChat(sessionId, conversationId, writer, placeholderMessageId, blackboard);
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:ChatHandler] Agent mode error', { sessionId, error: error.message });
|
||||
await conversationService.markAssistantError(placeholderMessageId, error.message);
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析用户回复中的确认/取消意图 */
|
||||
private parseAgentAction(content: string): 'confirm' | 'cancel' | 'other' {
|
||||
const lc = content.toLowerCase();
|
||||
if (lc.includes('agent_confirm') || lc.includes('确认') || lc.includes('执行代码') || lc.includes('开始生成')) return 'confirm';
|
||||
if (lc.includes('agent_cancel') || lc.includes('取消')) return 'cancel';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ── Agent Step 1: 生成分析计划 → 等用户确认 ──
|
||||
|
||||
private async agentGeneratePlan(
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
userContent: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
): Promise<HandleResult> {
|
||||
const sendEvent = (type: string, data: Record<string, any>) => {
|
||||
writer.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
const execution = await (prisma as any).ssaAgentExecution.create({
|
||||
data: { sessionId, query: userContent, status: 'planning' },
|
||||
});
|
||||
|
||||
sendEvent('agent_planning', { executionId: execution.id, message: '正在制定分析计划...' });
|
||||
|
||||
const conversationHistory = await conversationService.buildContext(sessionId, conversationId, 'analyze');
|
||||
const plan = await agentPlannerService.generatePlan(sessionId, userContent, conversationHistory);
|
||||
|
||||
// planText 存原始文本, reviewResult(JSON) 存结构化计划以便恢复
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: {
|
||||
planText: plan.rawText,
|
||||
reviewResult: JSON.parse(JSON.stringify(plan)),
|
||||
status: 'plan_pending',
|
||||
},
|
||||
});
|
||||
|
||||
sendEvent('agent_plan_ready', {
|
||||
executionId: execution.id,
|
||||
plan: { title: plan.title, designType: plan.designType, steps: plan.steps },
|
||||
planText: plan.rawText,
|
||||
});
|
||||
|
||||
// 流式解释计划
|
||||
const hint = [
|
||||
`[系统指令] 你刚刚制定了分析计划「${plan.title}」,包含 ${plan.steps.length} 个步骤。`,
|
||||
'请用简洁的自然语言解释这个计划,然后告知用户可以确认后开始生成 R 代码。',
|
||||
'【禁止】不要编造数值或结果。',
|
||||
].join('\n');
|
||||
|
||||
const msgs = await conversationService.buildContext(sessionId, conversationId, 'analyze', hint);
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.5, maxTokens: 800 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, sr.content, sr.thinking, sr.tokens);
|
||||
|
||||
sendEvent('ask_user', {
|
||||
questionId: `agent_plan_${execution.id}`,
|
||||
text: '请确认分析计划',
|
||||
options: [
|
||||
{ id: 'agent_confirm_plan', label: '确认方案,生成代码' },
|
||||
{ id: 'agent_cancel', label: '取消' },
|
||||
],
|
||||
});
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
// ── Agent Step 2: 流式生成代码 → 等用户确认 ──
|
||||
|
||||
private async agentStreamCode(
|
||||
execution: any,
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
): Promise<HandleResult> {
|
||||
const sendEvent = (type: string, data: Record<string, any>) => {
|
||||
writer.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'coding' },
|
||||
});
|
||||
|
||||
sendEvent('code_generating', { executionId: execution.id, partialCode: '', message: '正在生成 R 代码...' });
|
||||
|
||||
const plan = execution.reviewResult as any;
|
||||
|
||||
const generated = await agentCoderService.generateCodeStream(
|
||||
sessionId, plan,
|
||||
(accumulated: string) => {
|
||||
sendEvent('code_generating', { executionId: execution.id, partialCode: accumulated });
|
||||
},
|
||||
);
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { generatedCode: generated.code, status: 'code_pending' },
|
||||
});
|
||||
|
||||
sendEvent('code_generated', {
|
||||
executionId: execution.id,
|
||||
code: generated.code,
|
||||
explanation: generated.explanation,
|
||||
});
|
||||
|
||||
// 流式说明代码
|
||||
const hint = [
|
||||
`[系统指令] R 代码已生成(${generated.code.split('\n').length} 行),使用 ${generated.requiredPackages.join(', ') || '基础包'}。`,
|
||||
'请简要说明代码逻辑,告知用户可以确认执行。',
|
||||
'【禁止】不要在对话中贴代码(工作区已展示),不要编造结果。',
|
||||
].join('\n');
|
||||
|
||||
const msgs = await conversationService.buildContext(sessionId, conversationId, 'analyze', hint);
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.5, maxTokens: 600 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, sr.content, sr.thinking, sr.tokens);
|
||||
|
||||
sendEvent('ask_user', {
|
||||
questionId: `agent_code_${execution.id}`,
|
||||
text: '代码已生成,请确认执行',
|
||||
options: [
|
||||
{ id: 'agent_confirm_code', label: '执行代码' },
|
||||
{ id: 'agent_cancel', label: '取消' },
|
||||
],
|
||||
});
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
// ── Agent Step 3: 执行代码 + 重试循环 ──
|
||||
|
||||
private async agentExecuteCode(
|
||||
execution: any,
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
): Promise<HandleResult> {
|
||||
const sendEvent = (type: string, data: Record<string, any>) => {
|
||||
writer.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'executing' },
|
||||
});
|
||||
|
||||
const plan = execution.reviewResult as any;
|
||||
let currentCode = execution.generatedCode as string;
|
||||
let lastError: string | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= codeRunnerService.maxRetries; attempt++) {
|
||||
sendEvent('code_executing', {
|
||||
executionId: execution.id,
|
||||
attempt: attempt + 1,
|
||||
message: attempt === 0 ? '正在执行 R 代码...' : `第 ${attempt + 1} 次重试执行...`,
|
||||
});
|
||||
|
||||
const execResult = await codeRunnerService.executeCode(sessionId, currentCode);
|
||||
|
||||
if (execResult.success) {
|
||||
const durationMs = execResult.durationMs || 0;
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: {
|
||||
executionResult: execResult as any,
|
||||
reportBlocks: execResult.reportBlocks as any,
|
||||
generatedCode: currentCode,
|
||||
status: 'completed',
|
||||
retryCount: attempt,
|
||||
durationMs,
|
||||
},
|
||||
});
|
||||
|
||||
sendEvent('code_result', {
|
||||
executionId: execution.id,
|
||||
reportBlocks: execResult.reportBlocks,
|
||||
code: currentCode,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
// 流式总结结果
|
||||
const hint = [
|
||||
`[系统指令] R 代码执行完成(${durationMs}ms),生成 ${(execResult.reportBlocks || []).length} 个报告模块。`,
|
||||
'请简要解释结果的统计学意义。',
|
||||
'【禁止】不要编造数值(工作区已展示完整结果)。',
|
||||
].join('\n');
|
||||
|
||||
const msgs = await conversationService.buildContext(sessionId, conversationId, 'analyze', hint);
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.5, maxTokens: 800 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, sr.content, sr.thinking, sr.tokens);
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
lastError = execResult.error || '执行失败';
|
||||
|
||||
if (attempt < codeRunnerService.maxRetries) {
|
||||
sendEvent('code_error', { executionId: execution.id, message: lastError, willRetry: true });
|
||||
sendEvent('code_retry', { executionId: execution.id, retryCount: attempt + 1, message: `错误: ${lastError},正在修复代码...` });
|
||||
|
||||
const retry = await agentCoderService.generateCode(sessionId, plan, lastError);
|
||||
currentCode = retry.code;
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { generatedCode: currentCode, retryCount: attempt + 1 },
|
||||
});
|
||||
|
||||
sendEvent('code_generated', { executionId: execution.id, code: currentCode });
|
||||
}
|
||||
}
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'error', errorMessage: lastError, retryCount: codeRunnerService.maxRetries },
|
||||
});
|
||||
|
||||
sendEvent('code_error', {
|
||||
executionId: execution.id,
|
||||
message: `经过 ${codeRunnerService.maxRetries + 1} 次尝试仍然失败: ${lastError}`,
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
// ── Agent 取消 ──
|
||||
|
||||
private async agentCancel(
|
||||
execution: any,
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
): Promise<HandleResult> {
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'error', errorMessage: '用户取消' },
|
||||
});
|
||||
|
||||
const msgs = await conversationService.buildContext(
|
||||
sessionId, conversationId, 'analyze', '[系统指令] 用户取消了当前分析流程。请简短回复确认取消。',
|
||||
);
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.5, maxTokens: 200 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, sr.content, sr.thinking, sr.tokens);
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
// ── Agent 自由对话 ──
|
||||
|
||||
private async handleAgentChat(
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
blackboard: any,
|
||||
): Promise<HandleResult> {
|
||||
let toolOutputs = '';
|
||||
|
||||
if (blackboard) {
|
||||
const truncated = tokenTruncationService.truncate(blackboard, { maxTokens: 2000, strategy: 'balanced' });
|
||||
const parts: string[] = [];
|
||||
if (truncated.overview) parts.push(`数据概览:\n${truncated.overview}`);
|
||||
if (truncated.variables) parts.push(`变量列表:\n${truncated.variables}`);
|
||||
if (truncated.pico) parts.push(`PICO 推断:\n${truncated.pico}`);
|
||||
if (parts.length > 0) toolOutputs = parts.join('\n\n');
|
||||
}
|
||||
|
||||
const agentHint = [
|
||||
'[当前模式: Agent 代码生成通道]',
|
||||
'你是一位高级统计分析 Agent。你可以:',
|
||||
'1. 与临床研究者自由讨论统计分析需求',
|
||||
'2. 帮助理解数据特征、研究设计、统计方法选择',
|
||||
'3. 当用户明确分析需求后,自动制定计划并生成 R 代码执行',
|
||||
'',
|
||||
'注意:禁止编造或模拟任何分析结果和数值。',
|
||||
blackboard?.dataOverview
|
||||
? '用户已上传数据,你可以结合数据概览回答问题。当用户提出分析需求时,会自动触发分析流程。'
|
||||
: '用户尚未上传数据。请引导用户先上传研究数据文件。',
|
||||
].join('\n');
|
||||
|
||||
const fullToolOutputs = toolOutputs ? `${toolOutputs}\n\n${agentHint}` : agentHint;
|
||||
const messages = await conversationService.buildContext(sessionId, conversationId, 'analyze', fullToolOutputs);
|
||||
const result = await conversationService.streamToSSE(messages, writer, { temperature: 0.7, maxTokens: 2000 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, result.content, result.thinking, result.tokens);
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
/** 简单启发式:判断用户消息是否像分析请求 */
|
||||
private looksLikeAnalysisRequest(content: string): boolean {
|
||||
const kw = [
|
||||
'分析', '比较', '检验', '回归', '相关', '差异', '统计',
|
||||
'生存', '卡方', 'logistic', 't检验', 'anova',
|
||||
'基线', '特征表', '描述性', '预测', '影响因素',
|
||||
'帮我做', '帮我跑', '开始分析', '执行分析',
|
||||
];
|
||||
const lc = content.toLowerCase();
|
||||
return kw.some(k => lc.includes(k));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// ask_user 响应处理(Phase III)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user