diff --git a/backend/prisma/migrations/20260222_add_ssa_agent_execution_mode/migration.sql b/backend/prisma/migrations/20260222_add_ssa_agent_execution_mode/migration.sql new file mode 100644 index 00000000..1aca4b21 --- /dev/null +++ b/backend/prisma/migrations/20260222_add_ssa_agent_execution_mode/migration.sql @@ -0,0 +1,38 @@ +-- AlterTable: ssa_sessions 新增 execution_mode 列 +ALTER TABLE "ssa_schema"."ssa_sessions" ADD COLUMN IF NOT EXISTS "execution_mode" TEXT NOT NULL DEFAULT 'qper'; + +-- CreateTable: ssa_agent_executions (Agent 通道执行记录) +CREATE TABLE IF NOT EXISTS "ssa_schema"."ssa_agent_executions" ( + "id" TEXT NOT NULL, + "session_id" TEXT NOT NULL, + "query" TEXT NOT NULL, + "plan_text" TEXT, + "generated_code" TEXT, + "review_result" JSONB, + "execution_result" JSONB, + "report_blocks" JSONB, + "retry_count" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'pending', + "error_message" TEXT, + "duration_ms" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ssa_agent_executions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_ssa_agent_exec_session" ON "ssa_schema"."ssa_agent_executions"("session_id"); + +-- AddForeignKey +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ssa_agent_executions_session_id_fkey' + ) THEN + ALTER TABLE "ssa_schema"."ssa_agent_executions" + ADD CONSTRAINT "ssa_agent_executions_session_id_fkey" + FOREIGN KEY ("session_id") REFERENCES "ssa_schema"."ssa_sessions"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cfc243f5..04473c03 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -2480,13 +2480,15 @@ model SsaSession { dataPayload Json? @map("data_payload") /// 真实数据(仅R可见) dataOssKey String? @map("data_oss_key") /// OSS 存储 key(大数据) dataProfile Json? @map("data_profile") /// 🆕 Python 生成的 DataProfile(Phase 2A) + executionMode String @default("qper") @map("execution_mode") /// qper | agent status String @default("active") /// active | consult | completed | error createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - messages SsaMessage[] - executionLogs SsaExecutionLog[] - workflows SsaWorkflow[] /// 🆕 多步骤流程(Phase 2A) + messages SsaMessage[] + executionLogs SsaExecutionLog[] + workflows SsaWorkflow[] /// 🆕 多步骤流程(Phase 2A) + agentExecutions SsaAgentExecution[] /// Agent 模式执行记录 @@index([userId], map: "idx_ssa_session_user") @@index([status], map: "idx_ssa_session_status") @@ -2494,6 +2496,30 @@ model SsaSession { @@schema("ssa_schema") } +/// SSA Agent 执行记录(LLM 代码生成通道) +model SsaAgentExecution { + id String @id @default(uuid()) + sessionId String @map("session_id") + query String + planText String? @map("plan_text") + generatedCode String? @map("generated_code") + reviewResult Json? @map("review_result") + executionResult Json? @map("execution_result") + reportBlocks Json? @map("report_blocks") + retryCount Int @default(0) @map("retry_count") + status String @default("pending") /// pending | planning | coding | reviewing | executing | completed | error + errorMessage String? @map("error_message") + durationMs Int? @map("duration_ms") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + session SsaSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId], map: "idx_ssa_agent_exec_session") + @@map("ssa_agent_executions") + @@schema("ssa_schema") +} + /// SSA 消息记录 model SsaMessage { id String @id @default(uuid()) diff --git a/backend/src/modules/ssa/routes/chat.routes.ts b/backend/src/modules/ssa/routes/chat.routes.ts index dd5bbb2b..ca8ac402 100644 --- a/backend/src/modules/ssa/routes/chat.routes.ts +++ b/backend/src/modules/ssa/routes/chat.routes.ts @@ -12,6 +12,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { logger } from '../../../common/logging/index.js'; +import { prisma } from '../../../config/database.js'; import { conversationService } from '../services/ConversationService.js'; import { intentRouterService } from '../services/IntentRouterService.js'; import { chatHandlerService } from '../services/ChatHandlerService.js'; @@ -114,6 +115,41 @@ export default async function chatRoutes(app: FastifyInstance) { } // ── H1 结束 ── + // 3. 读取 session 的执行模式 + const session = await (prisma.ssaSession as any).findUnique({ + where: { id: sessionId }, + select: { executionMode: true }, + }); + const executionMode = (session?.executionMode as string) || 'qper'; + + // ── Agent 通道分流 ── + if (executionMode === 'agent') { + const placeholderMsgId = await conversationService.createAssistantPlaceholder( + conversationId, 'chat', + ); + + const metaEvent = JSON.stringify({ + type: 'intent_classified', + intent: 'analyze', + confidence: 1, + source: 'agent_mode', + guardTriggered: false, + }); + writer.write(`data: ${metaEvent}\n\n`); + + const result = await chatHandlerService.handleAgentMode( + sessionId, conversationId, content.trim(), writer, placeholderMsgId, + ); + + logger.info('[SSA:Chat] Agent mode request completed', { + sessionId, success: result.success, + }); + + writer.end(); + return; + } + // ── QPER 通道(现有逻辑) ── + // 3. 意图分类 const intentResult = await intentRouterService.classify(content.trim(), sessionId); diff --git a/backend/src/modules/ssa/routes/session.routes.ts b/backend/src/modules/ssa/routes/session.routes.ts index 739cec28..bee9b97f 100644 --- a/backend/src/modules/ssa/routes/session.routes.ts +++ b/backend/src/modules/ssa/routes/session.routes.ts @@ -136,6 +136,33 @@ export default async function sessionRoutes(app: FastifyInstance) { return reply.send(session); }); + /** + * PATCH /sessions/:id/execution-mode + * 切换双通道执行模式 (qper / agent) + */ + app.patch('/:id/execution-mode', async (req, reply) => { + const { id } = req.params as { id: string }; + const { executionMode } = req.body as { executionMode: string }; + + if (!executionMode || !['qper', 'agent'].includes(executionMode)) { + return reply.status(400).send({ error: 'executionMode must be "qper" or "agent"' }); + } + + const session = await prisma.ssaSession.findUnique({ where: { id } }); + if (!session) { + return reply.status(404).send({ error: 'Session not found' }); + } + + await (prisma.ssaSession as any).update({ + where: { id }, + data: { executionMode }, + }); + + logger.info('[SSA:Session] Execution mode switched', { sessionId: id, executionMode }); + + return reply.send({ success: true, executionMode }); + }); + // 获取消息历史 app.get('/:id/messages', async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/backend/src/modules/ssa/services/AgentCoderService.ts b/backend/src/modules/ssa/services/AgentCoderService.ts new file mode 100644 index 00000000..2bf55103 --- /dev/null +++ b/backend/src/modules/ssa/services/AgentCoderService.ts @@ -0,0 +1,277 @@ +/** + * AgentCoderService — Agent 通道编码 Agent + * + * 职责:根据分析计划 + 数据上下文,通过 LLM 生成可执行的 R 代码。 + * + * Prompt 约束(R 代码规范底线): + * - 必须使用 block_helpers.R 的输出函数 + * - 必须使用 load_input_data() 加载数据 + * - 禁止安装新包(只能用预装包) + * - 禁止访问网络和文件系统(沙箱限制) + * - 代码最后必须 return 一个包含 report_blocks 的 list + */ + +import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js'; +import type { Message as LLMMessage } from '../../../common/llm/adapters/types.js'; +import { logger } from '../../../common/logging/index.js'; +import { sessionBlackboardService } from './SessionBlackboardService.js'; +import { tokenTruncationService } from './TokenTruncationService.js'; +import type { AgentPlan } from './AgentPlannerService.js'; + +const MODEL = 'deepseek-v3'; + +export interface GeneratedCode { + code: string; + explanation: string; + requiredPackages: string[]; +} + +export class AgentCoderService { + + /** + * 非流式生成(重试场景使用) + */ + async generateCode( + sessionId: string, + plan: AgentPlan, + errorFeedback?: string, + ): Promise { + const dataContext = await this.buildDataContext(sessionId); + const systemPrompt = this.buildSystemPrompt(dataContext); + + const userMessage = errorFeedback + ? this.buildRetryMessage(plan, errorFeedback) + : this.buildFirstMessage(plan); + + const messages: LLMMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ]; + + logger.info('[AgentCoder] Generating R code', { + sessionId, + planTitle: plan.title, + isRetry: !!errorFeedback, + }); + + const adapter = LLMFactory.getAdapter(MODEL as any); + const response = await adapter.chat(messages, { + temperature: 0.2, + maxTokens: 4000, + }); + + const content = response.content || ''; + const result = this.parseCode(content); + + logger.info('[AgentCoder] R code generated', { + sessionId, + codeLength: result.code.length, + packages: result.requiredPackages, + }); + + return result; + } + + /** + * 流式生成 — 每积累 CHUNK_SIZE 字符回调一次,前端实时显示代码 + */ + async generateCodeStream( + sessionId: string, + plan: AgentPlan, + onProgress: (accumulated: string) => void, + errorFeedback?: string, + ): Promise { + const dataContext = await this.buildDataContext(sessionId); + const systemPrompt = this.buildSystemPrompt(dataContext); + + const userMessage = errorFeedback + ? this.buildRetryMessage(plan, errorFeedback) + : this.buildFirstMessage(plan); + + const messages: LLMMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ]; + + logger.info('[AgentCoder] Generating R code (stream)', { + sessionId, planTitle: plan.title, isRetry: !!errorFeedback, + }); + + const adapter = LLMFactory.getAdapter(MODEL as any); + let fullContent = ''; + let lastSentLen = 0; + const CHUNK_SIZE = 150; + + for await (const chunk of adapter.chatStream(messages, { + temperature: 0.2, + maxTokens: 4000, + })) { + if (chunk.content) { + fullContent += chunk.content; + if (fullContent.length - lastSentLen >= CHUNK_SIZE) { + onProgress(fullContent); + lastSentLen = fullContent.length; + } + } + } + + if (fullContent.length > lastSentLen) { + onProgress(fullContent); + } + + const result = this.parseCode(fullContent); + + logger.info('[AgentCoder] R code generated (stream)', { + sessionId, codeLength: result.code.length, packages: result.requiredPackages, + }); + + return result; + } + + private async buildDataContext(sessionId: string): Promise { + const blackboard = await sessionBlackboardService.get(sessionId); + if (!blackboard) return '(无数据上下文)'; + + const truncated = tokenTruncationService.truncate(blackboard, { + maxTokens: 1500, + strategy: 'balanced', + }); + return tokenTruncationService.toPromptString(truncated); + } + + private buildSystemPrompt(dataContext: string): string { + return `你是一位 R 统计编程专家(Coder Agent)。你的职责是根据分析计划生成可在 R Docker 沙箱中执行的 R 代码。 + +## 数据上下文 +${dataContext} + +## R 代码规范(铁律) + +### 数据加载(重要!) +数据已由执行环境**自动加载**到变量 \`df\` 中(data.frame 格式)。 +**禁止**自己调用 \`load_input_data()\`,直接使用 \`df\` 即可。 + +\`\`\`r +# df 已存在,直接使用 +str(df) # 查看结构 +\`\`\` + +### 输出规范 +代码最后必须返回一个 list,包含 report_blocks 字段: +\`\`\`r +# 使用 block_helpers.R 中的函数构造 Block +blocks <- list() +blocks[[length(blocks) + 1]] <- make_markdown_block("## 分析结果\\n...") +blocks[[length(blocks) + 1]] <- make_table_block_from_df(result_df, title = "表1. 统计结果") +blocks[[length(blocks) + 1]] <- make_image_block(base64_data, title = "图1. 可视化") +blocks[[length(blocks) + 1]] <- make_kv_block(list("P值" = "0.023", "效应量" = "0.45")) + +# 必须以此格式返回 +list( + status = "success", + method = "使用的统计方法", + report_blocks = blocks +) +\`\`\` + +### 可用辅助函数(由 block_helpers.R 提供) +- \`make_markdown_block(content, title)\` — Markdown 文本块 +- \`make_table_block(headers, rows, title, footnote)\` — 表格块 +- \`make_table_block_from_df(df_arg, title, footnote, digits)\` — 从 data.frame 生成表格块(注意参数名不要与 df 变量冲突) +- \`make_image_block(base64_data, title, alt)\` — 图片块 +- \`make_kv_block(items, title)\` — 键值对块 + +### 图表生成 +\`\`\`r +library(base64enc) +tmp_file <- tempfile(fileext = ".png") +png(tmp_file, width = 800, height = 600, res = 120) +# ... 绑图代码 ... +dev.off() +base64_data <- paste0("data:image/png;base64,", base64encode(tmp_file)) +unlink(tmp_file) +\`\`\` + +### 预装可用包(仅限以下包,禁止使用其他包) +base, stats, utils, graphics, grDevices, +ggplot2, dplyr, tidyr, broom, gtsummary, gt, scales, gridExtra, +car, lmtest, survival, meta, base64enc, glue, jsonlite, cowplot + +### 禁止事项 +1. 禁止 install.packages() — 只能用上面列出的预装包 +2. 禁止调用 load_input_data() — 数据已自动加载到 df +3. 禁止访问外部网络 — 无 httr/curl 网络请求 +4. 禁止读写沙箱外文件 — 只能用 tempfile() +5. 禁止 system() / shell() 命令 +6. 所有数字结果必须用 tryCatch 包裹,防止 NA/NaN 导致崩溃 +7. 禁止使用 pROC, nortest, exact2x2 等未安装的包 + +## 输出格式 +请在 \`\`\`r ... \`\`\` 代码块中输出完整 R 代码,代码块外简要说明。`; + } + + private buildFirstMessage(plan: AgentPlan): string { + const planDesc = plan.steps + .map(s => `${s.order}. ${s.method}: ${s.description} (${s.rationale})`) + .join('\n'); + + return `请根据以下分析计划生成完整的 R 代码: + +## 分析计划:${plan.title} +- 研究设计:${plan.designType} +- 结局变量:${plan.variables.outcome.join(', ') || '未指定'} +- 预测变量:${plan.variables.predictors.join(', ') || '未指定'} +- 分组变量:${plan.variables.grouping || '无'} +- 混杂因素:${plan.variables.confounders.join(', ') || '无'} + +## 分析步骤 +${planDesc} + +## 需要验证的假设 +${plan.assumptions.join('\n') || '无特殊假设'} + +请生成完整、可直接执行的 R 代码。`; + } + + private buildRetryMessage(plan: AgentPlan, errorFeedback: string): string { + return `上一次生成的 R 代码执行失败,错误信息如下: + +\`\`\` +${errorFeedback} +\`\`\` + +请修复代码中的问题并重新生成完整的 R 代码。 + +原分析计划:${plan.title} +研究设计:${plan.designType} +结局变量:${plan.variables.outcome.join(', ') || '未指定'} +分组变量:${plan.variables.grouping || '无'} + +请确保: +1. 修复上述错误 +2. 使用 tryCatch 包裹关键计算步骤 +3. 处理可能的 NA/NaN 值 +4. 保持 report_blocks 输出格式`; + } + + private parseCode(content: string): GeneratedCode { + const codeMatch = content.match(/```r\s*([\s\S]*?)```/); + const code = codeMatch ? codeMatch[1].trim() : content; + + const packageRegex = /library\((\w+)\)/g; + const packages: string[] = []; + let match; + while ((match = packageRegex.exec(code)) !== null) { + packages.push(match[1]); + } + + const explanation = content + .replace(/```r[\s\S]*?```/g, '') + .trim() + .slice(0, 500); + + return { code, explanation, requiredPackages: packages }; + } +} + +export const agentCoderService = new AgentCoderService(); diff --git a/backend/src/modules/ssa/services/AgentPlannerService.ts b/backend/src/modules/ssa/services/AgentPlannerService.ts new file mode 100644 index 00000000..2968ffb6 --- /dev/null +++ b/backend/src/modules/ssa/services/AgentPlannerService.ts @@ -0,0 +1,175 @@ +/** + * AgentPlannerService — Agent 通道规划 Agent + * + * 职责:接收用户自然语言需求 + 数据上下文,通过 LLM 生成结构化分析计划。 + * 计划以自然语言 + JSON 混合格式输出,供 CoderAgent 消费。 + * + * Prompt 约束(统计规则底线): + * - 必须声明研究设计类型 + * - 必须说明变量角色(结局 / 预测 / 混杂) + * - 必须给出统计方法选择理由 + * - 禁止编造数据或预测结果 + */ + +import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js'; +import type { Message as LLMMessage } from '../../../common/llm/adapters/types.js'; +import { logger } from '../../../common/logging/index.js'; +import { sessionBlackboardService } from './SessionBlackboardService.js'; +import { tokenTruncationService } from './TokenTruncationService.js'; + +const MODEL = 'deepseek-v3'; + +export interface AgentPlan { + title: string; + designType: string; + variables: { + outcome: string[]; + predictors: string[]; + grouping: string | null; + confounders: string[]; + }; + steps: Array<{ + order: number; + method: string; + description: string; + rationale: string; + }>; + assumptions: string[]; + rawText: string; +} + +export class AgentPlannerService { + + async generatePlan( + sessionId: string, + userQuery: string, + conversationHistory: LLMMessage[], + ): Promise { + const dataContext = await this.buildDataContext(sessionId); + + const systemPrompt = this.buildSystemPrompt(dataContext); + + const messages: LLMMessage[] = [ + { role: 'system', content: systemPrompt }, + ...conversationHistory.filter(m => m.role !== 'system').slice(-10), + { + role: 'user', + content: `请为以下分析需求制定统计分析计划:\n\n${userQuery}\n\n请严格按照指定的 JSON 格式输出。`, + }, + ]; + + logger.info('[AgentPlanner] Generating plan', { sessionId, queryLength: userQuery.length }); + + const adapter = LLMFactory.getAdapter(MODEL as any); + const response = await adapter.chat(messages, { + temperature: 0.3, + maxTokens: 2000, + }); + + const content = response.content || ''; + const plan = this.parsePlan(content); + + logger.info('[AgentPlanner] Plan generated', { + sessionId, + title: plan.title, + stepsCount: plan.steps.length, + }); + + return plan; + } + + private async buildDataContext(sessionId: string): Promise { + const blackboard = await sessionBlackboardService.get(sessionId); + if (!blackboard) return '(暂无数据上下文,用户尚未上传数据)'; + + const truncated = tokenTruncationService.truncate(blackboard, { + maxTokens: 2000, + strategy: 'balanced', + }); + + return tokenTruncationService.toPromptString(truncated); + } + + private buildSystemPrompt(dataContext: string): string { + return `你是一位高级统计分析规划师(Planner Agent)。你的职责是根据用户的研究需求和数据特征,制定严谨的统计分析计划。 + +## 数据上下文 +${dataContext} + +## 规划规则(铁律) +1. 必须声明研究设计类型(横断面 / 队列 / 病例对照 / RCT / 前后对比等) +2. 必须明确变量角色:结局变量(outcome)、预测变量(predictors)、分组变量(grouping)、混杂因素(confounders) +3. 统计方法选择必须给出理由(数据类型、分布、样本量等) +4. 连续变量需考虑正态性:正态→参数方法,非正态→非参数方法 +5. 分类变量的期望频数 < 5 时应选择 Fisher 精确检验而非卡方检验 +6. 多因素分析需考虑共线性和 EPV(Events Per Variable) +7. 禁止编造任何数据或预测分析结果 + +## 输出格式 +请输出 JSON 格式的分析计划,结构如下: +\`\`\`json +{ + "title": "分析计划标题", + "designType": "研究设计类型", + "variables": { + "outcome": ["结局变量名"], + "predictors": ["预测变量名"], + "grouping": "分组变量名或null", + "confounders": ["混杂因素"] + }, + "steps": [ + { + "order": 1, + "method": "统计方法名称", + "description": "这一步做什么", + "rationale": "为什么选这个方法" + } + ], + "assumptions": ["需要验证的统计假设"] +} +\`\`\` + +在 JSON 代码块之后,可以用自然语言补充说明。`; + } + + private parsePlan(content: string): AgentPlan { + const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); + + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[1].trim()); + return { + title: parsed.title || '统计分析计划', + designType: parsed.designType || '未指定', + variables: { + outcome: parsed.variables?.outcome || [], + predictors: parsed.variables?.predictors || [], + grouping: parsed.variables?.grouping || null, + confounders: parsed.variables?.confounders || [], + }, + steps: (parsed.steps || []).map((s: any, i: number) => ({ + order: s.order || i + 1, + method: s.method || '未指定', + description: s.description || '', + rationale: s.rationale || '', + })), + assumptions: parsed.assumptions || [], + rawText: content, + }; + } catch (e) { + logger.warn('[AgentPlanner] Failed to parse JSON plan, using raw text'); + } + } + + return { + title: '统计分析计划', + designType: '未指定', + variables: { outcome: [], predictors: [], grouping: null, confounders: [] }, + steps: [{ order: 1, method: '综合分析', description: content.slice(0, 500), rationale: '' }], + assumptions: [], + rawText: content, + }; + } +} + +export const agentPlannerService = new AgentPlannerService(); diff --git a/backend/src/modules/ssa/services/AgentReviewerService.ts b/backend/src/modules/ssa/services/AgentReviewerService.ts new file mode 100644 index 00000000..7fe51c94 --- /dev/null +++ b/backend/src/modules/ssa/services/AgentReviewerService.ts @@ -0,0 +1,183 @@ +/** + * AgentReviewerService — Agent 通道审核 Agent + * + * 职责:审核分析计划和生成的 R 代码,确保统计方法学正确性和代码安全性。 + * 充当独立的 "第二双眼睛",在代码执行前拦截潜在问题。 + * + * 审核维度: + * 1. 统计方法学合理性(方法选择 vs 数据特征) + * 2. 变量角色正确性(结局/预测/混杂) + * 3. 代码安全性(无危险操作) + * 4. 输出格式合规性(使用 block_helpers) + */ + +import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js'; +import type { Message as LLMMessage } from '../../../common/llm/adapters/types.js'; +import { logger } from '../../../common/logging/index.js'; +import type { AgentPlan } from './AgentPlannerService.js'; + +const MODEL = 'deepseek-v3'; + +export interface ReviewResult { + passed: boolean; + score: number; + comments: string[]; + issues: Array<{ + severity: 'critical' | 'warning' | 'info'; + category: 'methodology' | 'code_safety' | 'output_format' | 'variable_usage'; + message: string; + }>; + rawText: string; +} + +export class AgentReviewerService { + + async review( + plan: AgentPlan, + code: string, + dataContext?: string, + ): Promise { + const systemPrompt = this.buildSystemPrompt(); + + const userMessage = this.buildReviewRequest(plan, code, dataContext); + + const messages: LLMMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ]; + + logger.info('[AgentReviewer] Reviewing plan + code', { + planTitle: plan.title, + codeLength: code.length, + }); + + const adapter = LLMFactory.getAdapter(MODEL as any); + const response = await adapter.chat(messages, { + temperature: 0.1, + maxTokens: 1500, + }); + + const content = response.content || ''; + const result = this.parseReview(content); + + logger.info('[AgentReviewer] Review complete', { + passed: result.passed, + score: result.score, + issueCount: result.issues.length, + }); + + return result; + } + + private buildSystemPrompt(): string { + return `你是一位统计方法学审核专家(Reviewer Agent)。你的职责是审核统计分析计划和 R 代码,确保科学严谨性和代码安全性。 + +## 审核清单 + +### 1. 统计方法学(methodology) +- 研究设计类型是否与分析方法匹配? +- 变量类型(连续/分类)是否与所选方法的要求一致? +- 是否考虑了正态性检验(连续变量)? +- 分类变量的频数是否满足方法要求(如卡方检验的期望频数 ≥ 5)? +- 多因素分析的 EPV 是否 ≥ 10? +- 是否遗漏了重要的混杂因素控制? + +### 2. 代码安全性(code_safety) +- 是否包含 install.packages()?[critical] +- 是否包含 system() / shell() 调用?[critical] +- 是否有外部网络请求?[critical] +- 是否有文件系统越权访问?[critical] + +### 3. 输出格式(output_format) +- 是否使用 block_helpers.R 的函数构造输出? +- 最终是否返回包含 report_blocks 的 list? +- 表格是否有标题和合理的列名? + +### 4. 变量使用(variable_usage) +- 代码中使用的变量名是否与数据中的列名一致? +- 变量角色分配是否合理? + +## 输出格式 +请输出 JSON: +\`\`\`json +{ + "passed": true/false, + "score": 0-100, + "issues": [ + { + "severity": "critical|warning|info", + "category": "methodology|code_safety|output_format|variable_usage", + "message": "问题描述" + } + ], + "comments": ["审核意见1", "审核意见2"] +} +\`\`\` +- passed: 没有 critical 级别问题时为 true +- score: 综合评分(0-100)`; + } + + private buildReviewRequest(plan: AgentPlan, code: string, dataContext?: string): string { + const planSummary = [ + `标题: ${plan.title}`, + `设计: ${plan.designType}`, + `结局变量: ${plan.variables.outcome.join(', ') || '未指定'}`, + `分组变量: ${plan.variables.grouping || '无'}`, + `步骤: ${plan.steps.map(s => `${s.order}. ${s.method}`).join(', ')}`, + ].join('\n'); + + let prompt = `请审核以下统计分析计划和 R 代码: + +## 分析计划 +${planSummary} + +## R 代码 +\`\`\`r +${code} +\`\`\``; + + if (dataContext) { + prompt += `\n\n## 数据上下文\n${dataContext}`; + } + + prompt += '\n\n请严格按照审核清单逐项检查,输出 JSON 格式的审核结果。'; + return prompt; + } + + private parseReview(content: string): ReviewResult { + const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); + + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[1].trim()); + const issues = (parsed.issues || []).map((issue: any) => ({ + severity: issue.severity || 'info', + category: issue.category || 'methodology', + message: issue.message || '', + })); + + const hasCritical = issues.some((i: any) => i.severity === 'critical'); + + return { + passed: hasCritical ? false : (parsed.passed ?? true), + score: parsed.score ?? (hasCritical ? 30 : 80), + comments: parsed.comments || [], + issues, + rawText: content, + }; + } catch (e) { + logger.warn('[AgentReviewer] Failed to parse review JSON'); + } + } + + return { + passed: true, + score: 70, + comments: ['审核结果解析失败,默认放行(请人工检查)'], + issues: [], + rawText: content, + }; + } +} + +export const agentReviewerService = new AgentReviewerService(); diff --git a/backend/src/modules/ssa/services/ChatHandlerService.ts b/backend/src/modules/ssa/services/ChatHandlerService.ts index 8d1360dc..df81f103 100644 --- a/backend/src/modules/ssa/services/ChatHandlerService.ts +++ b/backend/src/modules/ssa/services/ChatHandlerService.ts @@ -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 { + 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 { + const sendEvent = (type: string, data: Record) => { + 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 { + const sendEvent = (type: string, data: Record) => { + 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 { + const sendEvent = (type: string, data: Record) => { + 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 { + 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 { + 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) // ──────────────────────────────────────────── diff --git a/backend/src/modules/ssa/services/CodeRunnerService.ts b/backend/src/modules/ssa/services/CodeRunnerService.ts new file mode 100644 index 00000000..5981ddb6 --- /dev/null +++ b/backend/src/modules/ssa/services/CodeRunnerService.ts @@ -0,0 +1,182 @@ +/** + * CodeRunnerService — Agent 通道代码执行器 + * + * 职责:将 LLM 生成的 R 代码发送到 R Docker 沙箱执行, + * 处理执行结果,失败时反馈错误给 CoderAgent 进行重试。 + * + * 执行流程: + * 1. 构建执行 payload(code + data_source) + * 2. 调用 R Docker POST /api/v1/execute-code + * 3. 成功 → 返回 report_blocks + * 4. 失败 → 返回错误信息,供重试循环使用 + * + * 安全约束: + * - 最大重试次数 MAX_RETRIES = 3 + * - 单次执行超时 120 秒 + */ + +import axios, { AxiosInstance } from 'axios'; +import { logger } from '../../../common/logging/index.js'; +import { storage } from '../../../common/storage/index.js'; +import { prisma } from '../../../config/database.js'; + +const MAX_RETRIES = 3; +const EXECUTE_TIMEOUT_MS = 130000; + +export interface CodeExecutionResult { + success: boolean; + reportBlocks?: any[]; + consoleOutput?: string[]; + durationMs?: number; + error?: string; +} + +export class CodeRunnerService { + private client: AxiosInstance; + + constructor() { + const baseURL = process.env.R_SERVICE_URL || 'http://localhost:8082'; + this.client = axios.create({ + baseURL, + timeout: EXECUTE_TIMEOUT_MS, + headers: { 'Content-Type': 'application/json' }, + }); + } + + /** + * 执行 R 代码并返回结果 + */ + async executeCode( + sessionId: string, + code: string, + ): Promise { + const startTime = Date.now(); + + try { + const dataSource = await this.buildDataSource(sessionId); + + const payload = { + code: this.wrapCode(code, dataSource), + session_id: sessionId, + timeout: 120, + }; + + logger.info('[CodeRunner] Executing R code', { + sessionId, + codeLength: code.length, + }); + + const response = await this.client.post('/api/v1/execute-code', payload); + const durationMs = Date.now() - startTime; + + if (response.data?.status === 'success') { + const result = response.data.result || {}; + const reportBlocks = result.report_blocks || result.data?.report_blocks || []; + + logger.info('[CodeRunner] Execution success', { + sessionId, + durationMs, + blockCount: reportBlocks.length, + }); + + return { + success: true, + reportBlocks, + consoleOutput: response.data.console_output, + durationMs, + }; + } + + const errorMsg = response.data?.message || response.data?.user_hint || 'R 执行返回非成功状态'; + logger.warn('[CodeRunner] Execution failed (R returned error)', { + sessionId, + durationMs, + error: errorMsg, + }); + + return { + success: false, + error: errorMsg, + consoleOutput: response.data?.console_output, + durationMs, + }; + } catch (error: any) { + const durationMs = Date.now() - startTime; + const statusCode = error.response?.status; + + if (statusCode === 502 || statusCode === 504) { + return { + success: false, + error: 'R 统计服务超时或崩溃,请检查代码是否有死循环或内存溢出', + durationMs, + }; + } + + const errorMsg = error.response?.data?.message + || error.response?.data?.user_hint + || error.message + || 'R 服务调用失败'; + + logger.error('[CodeRunner] Execution error', { + sessionId, + durationMs, + error: errorMsg, + }); + + return { + success: false, + error: errorMsg, + durationMs, + }; + } + } + + get maxRetries(): number { + return MAX_RETRIES; + } + + /** + * 包装用户代码:注入 data_source 和 input 变量 + * URL 通过 Sys.getenv 读取(由 R 端点的 sandbox_env 设置),避免字符串拼接注入风险 + */ + private wrapCode(userCode: string, dataSource: { type: string; oss_url?: string }): string { + const escapedUrl = (dataSource.oss_url || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return ` +# === 自动注入:数据加载 === +input <- list( + data_source = list( + type = "${dataSource.type}", + oss_url = "${escapedUrl}" + ) +) + +# 加载数据到 df(LLM 代码直接使用 df,不需要再调用 load_input_data) +df <- load_input_data(input) +message(paste0("[Agent] Data loaded: ", nrow(df), " rows x ", ncol(df), " cols")) + +# === 用户代码开始 === +${userCode} +# === 用户代码结束 === +`.trim(); + } + + /** + * 构建数据源(从 session 读取 OSS key → 预签名 URL) + */ + private async buildDataSource(sessionId: string): Promise<{ type: string; oss_url: string }> { + const session = await prisma.ssaSession.findUnique({ + where: { id: sessionId }, + select: { dataOssKey: true }, + }); + + const ossKey = session?.dataOssKey; + if (!ossKey) { + throw new Error('请先上传数据文件'); + } + + const signedUrl = await storage.getUrl(ossKey); + return { type: 'oss', oss_url: signedUrl }; + } +} + +export const codeRunnerService = new CodeRunnerService(); diff --git a/backend/tests/e2e-dual-channel-test.ts b/backend/tests/e2e-dual-channel-test.ts new file mode 100644 index 00000000..201c2515 --- /dev/null +++ b/backend/tests/e2e-dual-channel-test.ts @@ -0,0 +1,444 @@ +/** + * SSA 双通道架构 E2E 测试 + * + * 测试 Phase 1~3: + * T1. 数据库迁移验证 — execution_mode 字段 + ssa_agent_executions 表 + * T2. Session execution mode 切换 API + * T3. R Docker /execute-code 端点 + * T4. Agent 模式对话(自由对话 + 分析请求判断) + * T5. AgentPlannerService 规划能力 + * T6. AgentCoderService 代码生成能力 + * T7. AgentReviewerService 审核能力 + * T8. ModeToggle 前端集成点验证(API 层面) + * + * 前置条件: + * - PostgreSQL 运行中(Docker Desktop) + * - R Docker 运行中(可选,T3 跳过如不可用) + * - DeepSeek API key 配置在 .env + * - 至少有一个 SSA session(有上传数据) + * + * 运行: npx tsx tests/e2e-dual-channel-test.ts + */ + +import { prisma } from '../src/config/database.js'; +import { logger } from '../src/common/logging/index.js'; +import axios from 'axios'; + +const R_SERVICE_URL = process.env.R_SERVICE_URL || 'http://localhost:8082'; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +interface TestResult { + id: string; + name: string; + status: 'pass' | 'fail' | 'skip'; + duration: number; + message?: string; +} + +const results: TestResult[] = []; + +async function runTest( + id: string, + name: string, + fn: () => Promise, +): Promise { + const start = Date.now(); + try { + await fn(); + const dur = Date.now() - start; + results.push({ id, name, status: 'pass', duration: dur }); + console.log(` ✅ ${id} ${name} (${dur}ms)`); + } catch (error: any) { + const dur = Date.now() - start; + if (error.message?.startsWith('SKIP:')) { + results.push({ id, name, status: 'skip', duration: dur, message: error.message }); + console.log(` ⏭️ ${id} ${name} — ${error.message}`); + } else { + results.push({ id, name, status: 'fail', duration: dur, message: error.message }); + console.log(` ❌ ${id} ${name} — ${error.message}`); + } + } +} + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +// ═══════════════════════════════════════════ +// T1: 数据库迁移验证 +// ═══════════════════════════════════════════ + +async function t1_dbMigration() { + // 检查 ssa_sessions 表有 execution_mode 列 + const colCheck = await prisma.$queryRaw` + SELECT column_name, column_default + FROM information_schema.columns + WHERE table_schema = 'ssa_schema' + AND table_name = 'ssa_sessions' + AND column_name = 'execution_mode' + `; + assert(colCheck.length === 1, 'execution_mode 列不存在于 ssa_sessions 表'); + assert( + colCheck[0].column_default?.includes('qper'), + `execution_mode 默认值应为 qper,实际为 ${colCheck[0].column_default}`, + ); + + // 检查 ssa_agent_executions 表存在 + const tableCheck = await prisma.$queryRaw` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'ssa_schema' + AND table_name = 'ssa_agent_executions' + `; + assert(tableCheck.length === 1, 'ssa_agent_executions 表不存在'); + + // 检查索引 + const idxCheck = await prisma.$queryRaw` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'ssa_schema' + AND tablename = 'ssa_agent_executions' + AND indexname = 'idx_ssa_agent_exec_session' + `; + assert(idxCheck.length === 1, 'idx_ssa_agent_exec_session 索引不存在'); +} + +// ═══════════════════════════════════════════ +// T2: Session execution mode CRUD +// ═══════════════════════════════════════════ + +async function t2_executionModeSwitch() { + // 查找一个现有 session + const session = await prisma.ssaSession.findFirst({ + where: { status: 'active' }, + orderBy: { createdAt: 'desc' }, + }); + + if (!session) throw new Error('SKIP: 无可用 session,请先创建一个 SSA 会话'); + + // 读取当前 mode(应为默认 qper) + const current = await prisma.$queryRaw` + SELECT execution_mode FROM ssa_schema.ssa_sessions WHERE id = ${session.id} + `; + assert(current.length === 1, 'Session 不存在'); + const originalMode = current[0].execution_mode; + console.log(` 当前 mode: ${originalMode}`); + + // 切换到 agent + await prisma.$executeRaw` + UPDATE ssa_schema.ssa_sessions SET execution_mode = 'agent' WHERE id = ${session.id} + `; + + const after = await prisma.$queryRaw` + SELECT execution_mode FROM ssa_schema.ssa_sessions WHERE id = ${session.id} + `; + assert(after[0].execution_mode === 'agent', '切换到 agent 失败'); + + // 切回 qper + await prisma.$executeRaw` + UPDATE ssa_schema.ssa_sessions SET execution_mode = 'qper' WHERE id = ${session.id} + `; + + const restored = await prisma.$queryRaw` + SELECT execution_mode FROM ssa_schema.ssa_sessions WHERE id = ${session.id} + `; + assert(restored[0].execution_mode === 'qper', '切回 qper 失败'); +} + +// ═══════════════════════════════════════════ +// T3: R Docker /execute-code 端点 +// ═══════════════════════════════════════════ + +async function t3_rExecuteCode() { + // 先检查 R 服务是否可用 + try { + await axios.get(`${R_SERVICE_URL}/health`, { timeout: 5000 }); + } catch { + throw new Error('SKIP: R Docker 服务不可用'); + } + + // 执行一段简单的 R 代码 + const response = await axios.post(`${R_SERVICE_URL}/api/v1/execute-code`, { + code: ` + blocks <- list() + blocks[[1]] <- make_markdown_block("## 测试结果\\n双通道 E2E 测试通过", title = "测试") + blocks[[2]] <- make_kv_block(items = list("状态" = "成功", "时间" = as.character(Sys.time())), title = "概况") + list(status = "success", report_blocks = blocks) + `, + session_id: 'e2e-test', + timeout: 30, + }, { timeout: 35000 }); + + assert(response.data?.status === 'success', `R 执行状态不是 success: ${response.data?.status}`); + assert( + Array.isArray(response.data?.result?.report_blocks), + 'report_blocks 不是数组', + ); + assert( + response.data.result.report_blocks.length === 2, + `预期 2 个 block,实际 ${response.data.result.report_blocks.length}`, + ); + + const markdownBlock = response.data.result.report_blocks[0]; + assert(markdownBlock.type === 'markdown', `Block 0 类型应为 markdown,实际 ${markdownBlock.type}`); + + console.log(` R 执行耗时: ${response.data.duration_ms}ms, blocks: ${response.data.result.report_blocks.length}`); +} + +// ═══════════════════════════════════════════ +// T4: R Docker 错误处理 +// ═══════════════════════════════════════════ + +async function t4_rExecuteCodeError() { + try { + await axios.get(`${R_SERVICE_URL}/health`, { timeout: 5000 }); + } catch { + throw new Error('SKIP: R Docker 服务不可用'); + } + + const response = await axios.post(`${R_SERVICE_URL}/api/v1/execute-code`, { + code: 'stop("这是一个故意的错误")', + session_id: 'e2e-test-error', + timeout: 10, + }, { timeout: 15000 }); + + assert(response.data?.status === 'error', '错误代码应返回 error 状态'); + assert( + response.data?.message?.includes('故意的错误'), + `错误消息应包含原始错误: ${response.data?.message}`, + ); + + console.log(` 错误捕获正确: "${response.data.message}"`); +} + +// ═══════════════════════════════════════════ +// T5: AgentPlannerService 单元测试 +// ═══════════════════════════════════════════ + +async function t5_agentPlanner() { + const { agentPlannerService } = await import('../src/modules/ssa/services/AgentPlannerService.js'); + + // 查找有数据的 session + const session = await prisma.ssaSession.findFirst({ + where: { status: 'active', dataOssKey: { not: null } }, + orderBy: { createdAt: 'desc' }, + }); + + if (!session) throw new Error('SKIP: 无有数据的 session'); + + const plan = await agentPlannerService.generatePlan( + session.id, + '帮我做一个基线特征表,比较两组的差异', + [], + ); + + assert(!!plan.title, '计划标题为空'); + assert(plan.steps.length > 0, '计划步骤为空'); + assert(!!plan.rawText, 'rawText 为空'); + + console.log(` 计划标题: ${plan.title}`); + console.log(` 设计类型: ${plan.designType}`); + console.log(` 步骤数: ${plan.steps.length}`); + plan.steps.forEach(s => console.log(` ${s.order}. ${s.method}: ${s.description}`)); +} + +// ═══════════════════════════════════════════ +// T6: AgentCoderService 单元测试 +// ═══════════════════════════════════════════ + +async function t6_agentCoder() { + const { agentCoderService } = await import('../src/modules/ssa/services/AgentCoderService.js'); + + const session = await prisma.ssaSession.findFirst({ + where: { status: 'active', dataOssKey: { not: null } }, + orderBy: { createdAt: 'desc' }, + }); + + if (!session) throw new Error('SKIP: 无有数据的 session'); + + const mockPlan = { + title: '基线特征表分析', + designType: '横断面研究', + variables: { + outcome: [], + predictors: [], + grouping: 'group', + confounders: [], + }, + steps: [ + { order: 1, method: '描述性统计', description: '生成基线特征表', rationale: '了解数据分布' }, + ], + assumptions: [], + rawText: '基线特征表分析计划', + }; + + const generated = await agentCoderService.generateCode(session.id, mockPlan); + + assert(generated.code.length > 50, `生成代码太短: ${generated.code.length} chars`); + assert(generated.code.includes('load_input_data') || generated.code.includes('df'), '代码中未包含数据加载'); + + console.log(` 代码长度: ${generated.code.length} chars`); + console.log(` 依赖包: ${generated.requiredPackages.join(', ') || '(无额外依赖)'}`); + console.log(` 代码前 100 字符: ${generated.code.slice(0, 100).replace(/\n/g, ' ')}...`); +} + +// ═══════════════════════════════════════════ +// T7: AgentReviewerService 单元测试 +// ═══════════════════════════════════════════ + +async function t7_agentReviewer() { + const { agentReviewerService } = await import('../src/modules/ssa/services/AgentReviewerService.js'); + + const safePlan = { + title: '基线分析', + designType: '队列研究', + variables: { + outcome: ['death'], + predictors: ['age', 'sex'], + grouping: 'treatment', + confounders: [], + }, + steps: [{ order: 1, method: '基线特征表', description: '描述统计', rationale: '基线比较' }], + assumptions: [], + rawText: '', + }; + + const safeCode = ` +df <- load_input_data(input) +library(gtsummary) +tbl <- df %>% + tbl_summary(by = treatment, include = c(age, sex, death)) %>% + add_p() +blocks <- list() +blocks[[1]] <- make_markdown_block("## 基线特征表") +list(status = "success", report_blocks = blocks) +`; + + const review = await agentReviewerService.review(safePlan, safeCode); + + assert(typeof review.passed === 'boolean', 'passed 应为 boolean'); + assert(typeof review.score === 'number', 'score 应为 number'); + assert(Array.isArray(review.comments), 'comments 应为 array'); + + console.log(` 审核通过: ${review.passed}`); + console.log(` 评分: ${review.score}/100`); + console.log(` 问题数: ${review.issues.length}`); + review.comments.forEach(c => console.log(` - ${c}`)); + + // 测试危险代码审核 + const dangerCode = ` +install.packages("hacker_pkg") +system("rm -rf /") +df <- load_input_data(input) +list(status = "success", report_blocks = list()) +`; + + const dangerReview = await agentReviewerService.review(safePlan, dangerCode); + console.log(` 危险代码审核通过: ${dangerReview.passed} (预期 false)`); + console.log(` 危险代码问题数: ${dangerReview.issues.length}`); + + if (dangerReview.passed) { + console.log(' ⚠️ 警告: 危险代码未被拦截,Prompt 需要加强'); + } +} + +// ═══════════════════════════════════════════ +// T8: Agent Execution 记录 CRUD +// ═══════════════════════════════════════════ + +async function t8_agentExecutionCrud() { + const session = await prisma.ssaSession.findFirst({ + where: { status: 'active' }, + orderBy: { createdAt: 'desc' }, + }); + + if (!session) throw new Error('SKIP: 无可用 session'); + + // 创建 + const exec = await (prisma as any).ssaAgentExecution.create({ + data: { + sessionId: session.id, + query: 'E2E 测试查询', + status: 'pending', + }, + }); + assert(!!exec.id, '创建执行记录失败'); + console.log(` 创建记录: ${exec.id}`); + + // 更新 + await (prisma as any).ssaAgentExecution.update({ + where: { id: exec.id }, + data: { + status: 'completed', + planText: '测试计划', + generatedCode: 'print("hello")', + durationMs: 1234, + }, + }); + + const updated = await (prisma as any).ssaAgentExecution.findUnique({ + where: { id: exec.id }, + }); + assert(updated.status === 'completed', `状态应为 completed,实际 ${updated.status}`); + assert(updated.durationMs === 1234, `耗时应为 1234,实际 ${updated.durationMs}`); + + // 删除(清理) + await (prisma as any).ssaAgentExecution.delete({ where: { id: exec.id } }); + console.log(' CRUD 全流程通过'); +} + +// ═══════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════ + +async function main() { + console.log('\n╔══════════════════════════════════════════════╗'); + console.log('║ SSA 双通道架构 E2E 测试 (Phase 1~3) ║'); + console.log('╚══════════════════════════════════════════════╝\n'); + + console.log('📦 Phase 1: 基础设施'); + await runTest('T1', '数据库迁移验证(execution_mode + agent_executions)', t1_dbMigration); + await runTest('T2', 'Session execution mode 切换', t2_executionModeSwitch); + await runTest('T3', 'R Docker /execute-code 正常执行', t3_rExecuteCode); + await runTest('T4', 'R Docker /execute-code 错误处理', t4_rExecuteCodeError); + + console.log('\n🤖 Phase 2: Agent 服务'); + await runTest('T5', 'AgentPlannerService 规划能力', t5_agentPlanner); + await runTest('T6', 'AgentCoderService R 代码生成', t6_agentCoder); + await runTest('T7', 'AgentReviewerService 审核能力', t7_agentReviewer); + await runTest('T8', 'Agent Execution 记录 CRUD', t8_agentExecutionCrud); + + // 汇总 + console.log('\n' + '═'.repeat(50)); + const passed = results.filter(r => r.status === 'pass').length; + const failed = results.filter(r => r.status === 'fail').length; + const skipped = results.filter(r => r.status === 'skip').length; + const totalMs = results.reduce((s, r) => s + r.duration, 0); + + console.log(`\n📊 测试结果: ${passed} 通过 / ${failed} 失败 / ${skipped} 跳过 (总耗时 ${totalMs}ms)`); + + if (failed > 0) { + console.log('\n❌ 失败详情:'); + results.filter(r => r.status === 'fail').forEach(r => { + console.log(` ${r.id} ${r.name}: ${r.message}`); + }); + } + + if (skipped > 0) { + console.log('\n⏭️ 跳过详情:'); + results.filter(r => r.status === 'skip').forEach(r => { + console.log(` ${r.id} ${r.name}: ${r.message}`); + }); + } + + console.log('\n' + (failed === 0 ? '🎉 所有测试通过!' : '⚠️ 有测试失败,请检查。')); + + await prisma.$disconnect(); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(async (err) => { + console.error('测试执行异常:', err); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md b/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md index 79ef864b..32904726 100644 --- a/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md +++ b/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md @@ -1,9 +1,9 @@ # R 统计引擎架构与部署指南 -> **版本:** v1.4 -> **更新日期:** 2026-02-26 +> **版本:** v1.5 +> **更新日期:** 2026-03-02 > **维护者:** SSA-Pro 开发团队 / ASL 循证工具箱团队 -> **状态:** ✅ 生产就绪(13 工具 + Block-based 标准化输出 — 新增 Meta 分析引擎) +> **状态:** ✅ 生产就绪(13 工具 + Block-based 标准化输出 + Agent 代码执行端点) --- @@ -49,9 +49,10 @@ R 统计引擎是平台的**专用统计计算服务**,基于 Docker 容器化 │ ▼ ▼ 通用能力层 │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ R 统计引擎 (Docker) │ │ -│ │ • /health 健康检查 │ │ -│ │ • /api/v1/tools 工具列表 │ │ -│ │ • /api/v1/skills 技能执行 │ │ +│ │ • /health 健康检查 │ │ +│ │ • /api/v1/tools 工具列表 │ │ +│ │ • /api/v1/skills 技能执行(QPER 管线) │ │ +│ │ • /api/v1/execute-code 代码执行(Agent 通道) │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` @@ -552,7 +553,65 @@ Content-Type: application/json - 根据 `suggested_tool` 自动切换到更合适的方法 - 将 `checks` 结果展示给用户 -### 5.5 复合工具示例:基线特征表(Phase Deploy 新增) +### 5.5 Agent 通道:执行任意 R 代码(v1.5 双通道架构新增) + +```http +POST /api/v1/execute-code +Content-Type: application/json +``` + +**请求体:** +```json +{ + "code": "blocks <- list()\nblocks[[1]] <- make_markdown_block('Hello', title='Test')\nlist(status='success', report_blocks=blocks)", + "session_id": "xxx-xxx", + "timeout": 120 +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `code` | string | ✅ | 要执行的 R 代码(可使用 block_helpers.R 的所有辅助函数) | +| `session_id` | string | 否 | 会话 ID(用于日志追踪) | +| `timeout` | number | 否 | 超时秒数,默认 120,最大 120 | + +**成功响应:** +```json +{ + "status": "success", + "result": { + "status": "success", + "report_blocks": [ + {"type": "markdown", "content": "Hello", "title": "Test"} + ] + }, + "console_output": [], + "duration_ms": 42 +} +``` + +**错误响应:** +```json +{ + "status": "error", + "error_code": "E_EXEC", + "message": "object 'xxx' not found", + "user_hint": "R 代码执行出错 (123ms): object 'xxx' not found", + "duration_ms": 123 +} +``` + +**沙箱安全机制:** +- 代码在 `new.env(parent = globalenv())` 隔离环境中执行 +- `setTimeLimit` 强制超时(CPU + 挂钟时间 ≤ 120 秒) +- 可访问 `block_helpers.R` 和 `data_loader.R` 中的所有辅助函数 +- 由 Node.js `CodeRunnerService` 自动注入 `input` 和 `df` 数据变量 + +**调用方:** SSA 模块 Agent 通道(`CodeRunnerService.ts`),用于执行 LLM 生成的 R 代码。 + +> **与 `/api/v1/skills/{tool_code}` 的区别:** `/skills` 端点执行**预制的统计工具**(白名单限制),`/execute-code` 端点执行**任意 R 代码**(由 LLM Agent 生成,经 ReviewerAgent 审核后执行)。 + +### 5.6 复合工具示例:基线特征表(Phase Deploy) ```http POST /api/v1/skills/ST_BASELINE_TABLE @@ -604,7 +663,7 @@ Content-Type: application/json > **特点:** `ST_BASELINE_TABLE` 是复合工具,基于 `gtsummary::tbl_summary()` 自动判断变量类型(连续/分类)、选择统计方法(T 检验/Mann-Whitney/卡方/Fisher),输出标准三线表。`report_blocks[0].metadata.is_baseline_table = true` 触发前端特殊渲染(P 值标星、rowspan 合并行)。 -### 5.6 Meta 分析示例(ASL 工具 5 — v1.4 新增) +### 5.7 Meta 分析示例(ASL 工具 5 — v1.4 新增) ```http POST /api/v1/skills/ST_META_ANALYSIS @@ -1364,7 +1423,8 @@ r-statistics-service/ | 版本 | 日期 | 更新内容 | |------|------|----------| -| v1.4 | 2026-02-26 | ASL Meta 分析引擎:工具 12→13(+ST_META_ANALYSIS),Dockerfile 新增 `meta` 包,新增 §5.6 Meta 分析 API 示例、陷阱 8(对数尺度反变换)、§9.5 Meta E2E 测试(36 断言全通过),架构图更新 ASL 调用方 | +| v1.5 | 2026-03-02 | SSA 双通道架构:新增 `POST /api/v1/execute-code` 沙箱端点(§5.5)供 Agent 通道执行 LLM 生成的 R 代码,含超时 + 隔离环境;架构图新增 Agent 通道入口 | +| v1.4 | 2026-02-26 | ASL Meta 分析引擎:工具 12→13(+ST_META_ANALYSIS),Dockerfile 新增 `meta` 包,新增 §5.7 Meta 分析 API 示例、陷阱 8(对数尺度反变换)、§9.5 Meta E2E 测试(36 断言全通过),架构图更新 ASL 调用方 | | v1.3 | 2026-02-22 | 开发者体验增强:新工具模板补全 report_blocks(§6.1)、各工具 params 速查表(§6.5)、R 语言 7 大陷阱实录(§6.6)、新增 R 包操作指南(§6.7)、新增 Q11-Q13 常见问题 | | v1.2 | 2026-02-22 | Phase Deploy 完成:工具 7→12(+Fisher/ANOVA/Wilcoxon/线性回归/基线表)、Dockerfile 新增 gtsummary 等 5 包、Block-based 输出协议文档化(§6.4)、全工具测试脚本 | | v1.1 | 2026-02-20 | Phase 2A 完成:7 个统计工具、JIT 护栏、热重载说明、常见问题补充 | diff --git a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md index 7a71b895..7333135c 100644 --- a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md @@ -1,22 +1,26 @@ # SSA智能统计分析模块 - 当前状态与开发指南 -> **文档版本:** v3.5 +> **文档版本:** v4.0 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-23 +> **最后更新:** 2026-03-02 > **维护者:** 开发团队 -> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A(分析方案变量可编辑化)开发完成** +> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构(Agent 通道 Phase 1-3)开发完成** > **文档目的:** 快速了解SSA模块状态,为新AI助手提供上下文 > -> **最新进展(2026-02-23 Phase V-A 变量可编辑化完成):** +> **最新进展(2026-03-02 双通道架构 Phase 1-3 完成):** +> - ✅ **SSA 双通道架构** — QPER 管线 + LLM Agent 代码生成两条通道并行,前端一键切换 +> - ✅ **Phase 1 基础设施** — DB schema(execution_mode + ssa_agent_executions)、前端 ModeToggle 组件、Session PATCH API +> - ✅ **Phase 2 Agent 服务** — PlannerAgent + CoderAgent(含流式生成)+ CodeRunnerService,ReviewerAgent 暂缓 +> - ✅ **Phase 3 前端集成** — AgentCodePanel(分步展示:计划→流式代码→执行结果)、SSE 事件处理(7 种 Agent 事件) +> - ✅ **三步确认式管线** — 生成计划→用户确认→流式生成代码→用户确认→执行 R 代码→展示结果+原始代码 +> - ✅ **R Docker /execute-code 端点** — 沙箱执行 LLM 生成的 R 代码,120s 超时 + block_helpers 预加载 +> - ✅ **E2E 测试 8/8 通过** — DB 迁移 + mode 切换 + R execute-code + Planner + Coder + Reviewer + CRUD +> - ✅ **5 个代码审查问题修复** — R Docker 重启 / 数据双重加载 / Prompt 包列表修正 / URL 注入防护 / 架构文档更新 +> +> **此前进展(2026-02-23 Phase V-A 变量可编辑化完成):** > - ✅ **分析方案变量可编辑化** — 系统默认帮选变量,医生可在方案审查阶段修改/调整变量选择 > - ✅ **三层柔性拦截** — Layer 1 即时黄条警告 + Layer 2 步骤警告图标 + Layer 3 执行前阻断确认弹窗(Informed Consent) -> - ✅ **变量选择器 UI** — 单选下拉(按类型分组)+ 多选标签(分类=紫色 / 连续=蓝色)+ 全选分类/连续快捷按钮 + 不适配变量 ⚠️ 标记 -> - ✅ **tool_param_constraints 配置** — 12 个统计工具参数约束表,前后端共用单一事实来源 -> - ✅ **后端 PATCH API + Zod 防火墙** — PATCH /workflow/:id/params + 结构校验(400 Bad Request)/ 统计学校验交给 R 引擎 -> - ✅ **同步阻塞执行** — 执行按钮 Promise Chaining:await PATCH -> 再触发执行 + loading 防连点 -> - ✅ **inferGroupingVar 恢复** — LLM 未识别分组变量时,自动推断二分类变量填入默认值 > - ✅ **DynamicReport 增强** — 兼容 R 基线表对象格式 rows,Word 导出同步修复 -> - ✅ **前后端集成测试通过** — 队列研究完整执行 + 报告导出验证 > > **此前进展(2026-02-22 Phase IV 完成):** > - ✅ **Phase IV 全 5 批次完成** — ToolOrchestratorService(PICO hint 三层降级)+ handleAnalyze 重写(plan→analysis_plan SSE→LLM 方案说明→ask_user 确认)+ AVAILABLE_TOOLS 配置化(11 处改 toolRegistryService)+ 前端 SSE 对接(analysis_plan + plan_confirmed) @@ -64,11 +68,11 @@ |------|------| | **模块名称** | SSA - 智能统计分析 (Smart Statistical Analysis) | | **模块定位** | AI驱动的"白盒"统计分析系统 → 升级为"数据感知的统计顾问" | -| **架构模式** | **QPER(执行层)** + **四层七工具 + 对话层 LLM(智能对话层)** | +| **架构模式** | **双通道:QPER 管线(预制工具)+ LLM Agent 通道(代码生成)** + **四层七工具 + 对话层 LLM** | | **前端状态模型** | **Unified Record Architecture — 一次分析 = 一个 Record = N 个 Steps** | | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **目标用户** | 临床研究人员、生物统计师 | -| **开发状态** | 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A(变量可编辑化)完成** | +| **开发状态** | 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 完成** | ### 核心目标 @@ -171,6 +175,10 @@ AnalysisRecord { | **Phase III** | **method_consult + ask_user 标准化** | **20h** | ✅ **已完成(5 批次, 12 文件, E2E 13/13+4skip, H1-H3+P1 落地)** | 2026-02-22 | | **Phase IV** | **对话驱动分析 + QPER 集成** | **14h** | ✅ **已完成(5 批次, 11 文件, E2E 25/25, H1-H3+B1-B2 落地)** | 2026-02-22 | | **Phase V-A** | **分析方案变量可编辑化** | **~6h** | ✅ **已完成(9 文件, 团队双视角审查 V2, 三层柔性拦截)** | 2026-02-23 | +| **双通道 Phase 1** | **基础设施(DB + 前端切换 + API)** | **~4h** | ✅ **已完成(DB schema + ModeToggle + PATCH API)** | 2026-03-02 | +| **双通道 Phase 2** | **Agent 服务层(Planner + Coder + Runner)** | **~6h** | ✅ **已完成(3 Agent 服务 + R execute-code 端点)** | 2026-03-02 | +| **双通道 Phase 3** | **前端集成(SSE + AgentCodePanel + 确认流程)** | **~6h** | ✅ **已完成(三步确认 + 流式代码 + 7 种 SSE 事件)** | 2026-03-02 | +| **双通道 Phase 4** | **Prompt 工程 + 全面测试** | **~8h** | ⏳ 待用户体验测试确认后推进 | - | | **Phase V-B** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - | | **Phase VI** | **集成测试 + 可观测性** | **10h** | 📋 待开始 | - | @@ -195,7 +203,8 @@ AnalysisRecord { | **Phase IV 前端** | useSSAChat(analysis_plan+plan_confirmed SSE 处理+pendingPlanConfirm→executeWorkflow)+ SSAChatPane(AskUserCard 渲染+幽灵卡片清除 H2) | ✅ | | **Phase V-A 后端** | PATCH /workflow/:id/params(Zod 结构校验防火墙)+ tool_param_constraints.json(12 工具参数约束)+ inferGroupingVar 恢复(默认填充分组变量) | ✅ | | **Phase V-A 前端** | WorkflowTimeline 可编辑化(SingleVarSelect + MultiVarTags + 三层柔性拦截)+ ssaStore updateStepParams + SSAWorkspacePane 同步阻塞执行 + DynamicReport 对象 rows 兼容 + Word 导出修复 | ✅ | -| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 + Phase I E2E 31/31 + Phase II E2E 38/38 + Phase III E2E 13/13+4skip + Phase IV E2E 25/25 + Phase V-A 前后端集成测试通过 | ✅ | +| **双通道 Agent 通道** | PlannerAgent(意图→分析计划)+ CoderAgent(计划→R 代码,含流式生成)+ CodeRunnerService(沙箱执行)+ AgentCodePanel(三步确认 UI)+ ModeToggle(通道切换)+ R Docker /execute-code 端点 | ✅ | +| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 + Phase I E2E 31/31 + Phase II E2E 38/38 + Phase III E2E 13/13+4skip + Phase IV E2E 25/25 + Phase V-A 前后端集成测试通过 + 双通道 E2E 8/8 通过 | ✅ | --- @@ -217,6 +226,10 @@ backend/src/modules/ssa/ │ ├── SessionBlackboardService.ts # Phase I:Session 黑板(互斥锁 patch) │ ├── PicoInferenceService.ts # Phase I:LLM PICO 推断 │ ├── TokenTruncationService.ts # Phase I:Token 截断框架 +│ ├── AgentPlannerService.ts # 双通道:LLM 生成分析计划 +│ ├── AgentCoderService.ts # 双通道:LLM 生成 R 代码(含流式) +│ ├── AgentReviewerService.ts # 双通道:代码审核(暂缓启用) +│ ├── CodeRunnerService.ts # 双通道:R 沙箱代码执行 │ └── tools/ │ ├── GetDataOverviewTool.ts # Phase I:数据概览 + 五段式报告 │ └── GetVariableDetailTool.ts # Phase I:单变量详情 @@ -250,7 +263,9 @@ frontend-v2/src/modules/ssa/ │ ├── DynamicReport.tsx # Block-based 结果渲染 │ ├── DataContextCard.tsx # Phase I:五段式数据概览卡片 │ ├── VariableDictionaryPanel.tsx # Phase I:变量字典表格(可编辑) -│ └── VariableDetailPanel.tsx # Phase I:单变量详情面板 +│ ├── VariableDetailPanel.tsx # Phase I:单变量详情面板 +│ ├── AgentCodePanel.tsx # 双通道:Agent 管线三步确认 UI +│ └── ModeToggle.tsx # 双通道:QPER/Agent 通道切换 └── types/ └── index.ts # 前端类型定义 @@ -339,18 +354,24 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN ### 近期(优先级高) -1. **Phase V-B — 反思编排 + 高级特性(18h / 3 天)** +1. **双通道 Phase 4 — Prompt 工程 + 全面测试** + - 用户体验测试确认双通道 Agent 管线 + - Prompt 优化(PlannerAgent / CoderAgent 系统提示词精调) + - 错误处理增强(R 执行失败→LLM 自动修复重试,MAX 2 次) + - ReviewerAgent 按需启用(代码质量/安全审核) + +2. **Phase V-B — 反思编排 + 高级特性(18h / 3 天)** - 错误分类器实现(可自愈 vs 不可自愈) - 自动反思(静默重试,MAX 2 次)+ 手动反思(用户驱动,feedback 意图) - write_report interpret 模式 + discuss 意图处理(深度解读已有结果) -2. **Phase Deploy 收尾** — 前端三线表增强、决策表/流程模板补齐、ACR/SAE 部署 +3. **Phase Deploy 收尾** — 前端三线表增强、决策表/流程模板补齐、ACR/SAE 部署 ### 中期 -3. **Phase VI(10h)** — 集成测试 + 可观测性(含 QPER 透明化) +4. **Phase VI(10h)** — 集成测试 + 可观测性(含 QPER 透明化) -**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`(v1.8,Phase I-IV + Phase V-A 完成,含架构约束 C1-C8 + 全部团队审查落地记录) +**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`(v1.8,Phase I-IV + Phase V-A 完成)+ 双通道架构计划详见 `06-开发记录/` 相关文档 --- @@ -395,7 +416,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN --- -**文档版本:** v3.5 -**最后更新:** 2026-02-23 -**当前状态:** 🎉 QPER 主线闭环 + Phase I-IV + Phase V-A(变量可编辑化)已完成 -**下一步:** Phase V-B(反思编排 + 高级特性,18h/3 天) +**文档版本:** v4.0 +**最后更新:** 2026-03-02 +**当前状态:** 🎉 QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 已完成 +**下一步:** 双通道 Phase 4(Prompt 工程 + 全面测试)→ Phase V-B(反思编排) diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index d51647ba..9f45b79c 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -17,6 +17,7 @@ |---|---------|---------|--------|------| | DB-1 | modules 表新增 `AIA_PROTOCOL`、`RM` 模块注册,IIT 名称改为「CRA质控」 | 无(运行 seed 脚本) | 高 | 运行 `node scripts/seed-modules.js`(upsert,可重复执行) | | DB-2 | RVW Prompt 更新:期刊名称修正为「中华脑血管病杂志」 | 无(重新运行迁移脚本或后台编辑) | 高 | 运行 `npx tsx scripts/migrate-rvw-prompts.ts` 或在 `/admin/prompts/RVW_EDITORIAL` 手动编辑 | +| DB-3 | SSA 双通道:ssa_sessions 新增 execution_mode 列 + 新建 ssa_agent_executions 表 | `manual_migrations/20260223_add_execution_mode_and_agent_tables.sql` | 中 | 手动执行 SQL 或通过 Prisma 迁移 | ### 后端变更 (Node.js) @@ -29,6 +30,7 @@ | BE-5 | seed 数据:内部租户补充 RM、AIA_PROTOCOL 模块 | `prisma/seed.ts` | 仅影响新环境初始化 | — | | BE-6 | 用户模块配置校验放宽:不再限制必须在租户订阅范围内 | `userService.ts` | 重新构建镜像 | 校验改为「模块代码必须在 modules 表中存在」,支持给用户单独开通功能模块 | | BE-7 | 用户独立模块生效:user_modules 中的模块即使租户未订阅也纳入权限 | `module.service.ts` | 重新构建镜像 | 如 AIA_PROTOCOL 可单独配给用户 | +| BE-8 | SSA 双通道架构:Agent 模式 4 服务 + ChatHandler 分流 + Session PATCH API | `Agent*.ts`, `CodeRunnerService.ts`, `ChatHandlerService.ts`, `chat.routes.ts`, `session.routes.ts` | 重新构建镜像 | 含 PlannerAgent / CoderAgent / ReviewerAgent / CodeRunner | ### 前端变更 @@ -55,7 +57,7 @@ | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| R-1 | 新增 POST /api/v1/execute-code 端点(Agent 通道任意 R 代码执行) | `plumber.R` | 重新构建镜像 | 含超时 + 沙箱限制 | ### 环境变量 / 配置变更 diff --git a/frontend-v2/src/modules/aia/styles/chat-workspace.css b/frontend-v2/src/modules/aia/styles/chat-workspace.css index df9bec4f..50b51e05 100644 --- a/frontend-v2/src/modules/aia/styles/chat-workspace.css +++ b/frontend-v2/src/modules/aia/styles/chat-workspace.css @@ -969,9 +969,7 @@ .message-bubble .markdown-content h2:first-child, .message-bubble .markdown-content h3:first-child { margin-top: 0; -} - -.message-bubble .markdown-content h1 { +}.message-bubble .markdown-content h1 { font-size: 1.3em; }.message-bubble .markdown-content h2 { font-size: 1.2em; @@ -1004,4 +1002,4 @@ border-radius: 4px; font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 0.9em; -} \ No newline at end of file +} diff --git a/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx b/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx new file mode 100644 index 00000000..f7d121ea --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx @@ -0,0 +1,164 @@ +/** + * AgentCodePanel — Agent 通道工作区面板 + * + * 分步展示:计划 → 流式代码生成 → 执行结果 + * 在 Agent 模式下替代 WorkflowTimeline。 + */ +import React from 'react'; +import { + Code, + CheckCircle, + XCircle, + Loader2, + RefreshCw, + Sparkles, + FileText, + Play, +} from 'lucide-react'; +import { useSSAStore } from '../stores/ssaStore'; +import type { AgentExecutionStatus } from '../types'; + +const STATUS_LABEL: Record = { + pending: '等待中', + planning: '制定计划中...', + plan_pending: '等待确认计划', + coding: '生成 R 代码中...', + code_pending: '等待确认执行', + executing: '执行 R 代码中...', + completed: '执行完成', + error: '执行出错', +}; + +const StatusBadge: React.FC<{ status: AgentExecutionStatus }> = ({ status }) => { + const spinning = ['planning', 'coding', 'executing'].includes(status); + const waiting = ['plan_pending', 'code_pending'].includes(status); + return ( + + {spinning && } + {waiting && } + {status === 'completed' && } + {status === 'error' && } + {STATUS_LABEL[status]} + + ); +}; + +export const AgentCodePanel: React.FC = () => { + const { agentExecution, executionMode } = useSSAStore(); + + if (executionMode !== 'agent') return null; + + if (!agentExecution) { + return ( +
+
+

Agent 代码生成通道

+
+
+ +

在对话区描述你的统计分析需求,

+

Agent 将制定计划并生成 R 代码执行。

+
+
+ ); + } + + const { status, planText, planSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution; + + const displayCode = generatedCode || partialCode; + const isStreamingCode = status === 'coding' && !!partialCode && !generatedCode; + + return ( +
+
+

Agent 分析流水线

+ +
+ + {/* Step 1: 分析计划 */} + {(planText || planSteps) && ( +
+
+ + 分析计划 + {status === 'plan_pending' && 等待确认} + {['coding', 'code_pending', 'executing', 'completed'].includes(status) && ( + 已确认 + )} +
+ {planSteps && planSteps.length > 0 && ( +
+ {planSteps.map((s, i) => ( +
+ {s.order} + {s.method} + {s.description} +
+ ))} +
+ )} + {planText && !planSteps?.length && ( +
{planText}
+ )} +
+ )} + + {/* Step 2: R 代码 */} + {(displayCode || status === 'coding') && ( +
+
+ + R 代码 + {isStreamingCode && 生成中...} + {status === 'code_pending' && 等待确认执行} + {['executing', 'completed'].includes(status) && 已确认} +
+
+ {displayCode ? ( +
{displayCode}
+ ) : ( +
+ + Agent 正在编写 R 代码... +
+ )} +
+
+ )} + + {/* 重试信息 */} + {retryCount > 0 && ( +
+ + 第 {retryCount} 次重试(Agent 正在修复代码错误) +
+ )} + + {/* 错误信息 */} + {status === 'error' && errorMessage && errorMessage !== '用户取消' && ( +
+ +
{errorMessage}
+
+ )} + + {/* 执行中 */} + {status === 'executing' && ( +
+ + R 引擎正在执行代码,请稍候... +
+ )} + + {/* 完成状态 */} + {status === 'completed' && durationMs != null && ( +
+ + 执行完成,耗时 {(durationMs / 1000).toFixed(1)}s +
+ )} +
+ ); +}; + +export default AgentCodePanel; diff --git a/frontend-v2/src/modules/ssa/components/ModeToggle.tsx b/frontend-v2/src/modules/ssa/components/ModeToggle.tsx new file mode 100644 index 00000000..4d76be52 --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/ModeToggle.tsx @@ -0,0 +1,74 @@ +/** + * ModeToggle — 双通道切换组件 + * + * 在 QPER 管线(预制工具)与 Agent 通道(LLM 代码生成)之间切换。 + * 切换时调用后端 PATCH API 更新 session.executionMode。 + */ +import React, { useState, useCallback } from 'react'; +import { Cpu, Sparkles, Loader2 } from 'lucide-react'; +import { useSSAStore } from '../stores/ssaStore'; +import type { ExecutionMode } from '../types'; +import apiClient from '@/common/api/axios'; + +export const ModeToggle: React.FC = () => { + const { + executionMode, + setExecutionMode, + currentSession, + isExecuting, + addToast, + } = useSSAStore(); + + const [isSwitching, setIsSwitching] = useState(false); + + const handleToggle = useCallback(async () => { + if (!currentSession || isExecuting || isSwitching) return; + + const nextMode: ExecutionMode = executionMode === 'qper' ? 'agent' : 'qper'; + + try { + setIsSwitching(true); + await apiClient.patch(`/api/v1/ssa/sessions/${currentSession.id}/execution-mode`, { + executionMode: nextMode, + }); + setExecutionMode(nextMode); + addToast( + nextMode === 'agent' + ? '已切换到 Agent 通道(LLM 代码生成)' + : '已切换到 QPER 管线(预制工具)', + 'info', + ); + } catch (err: any) { + addToast(err?.message || '切换失败', 'error'); + } finally { + setIsSwitching(false); + } + }, [currentSession, executionMode, isExecuting, isSwitching, setExecutionMode, addToast]); + + if (!currentSession) return null; + + return ( +
+ + +
+ ); +}; + +export default ModeToggle; diff --git a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx index 90964c97..f3941503 100644 --- a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx @@ -43,6 +43,7 @@ import { ClarificationCard } from './ClarificationCard'; import { AskUserCard } from './AskUserCard'; import type { AskUserResponseData } from './AskUserCard'; import { ThinkingBlock } from '@/shared/components/Chat'; +import { ModeToggle } from './ModeToggle'; import type { ClarificationCardData, IntentResult } from '../types'; export const SSAChatPane: React.FC = () => { @@ -285,14 +286,17 @@ export const SSAChatPane: React.FC = () => { {currentSession?.title || '新的统计分析'} - +
+ {currentSession && } + +
{/* Chat Messages */} diff --git a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx index 5c9567ee..fabff851 100644 --- a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx @@ -27,6 +27,7 @@ import type { AnalysisRecord } from '../stores/ssaStore'; import { useWorkflow } from '../hooks/useWorkflow'; import type { TraceStep, ReportBlock, WorkflowStepResult } from '../types'; import { WorkflowTimeline, detectPlanMismatches } from './WorkflowTimeline'; +import { AgentCodePanel } from './AgentCodePanel'; import { DynamicReport } from './DynamicReport'; import { exportBlocksToWord } from '../utils/exportBlocksToWord'; import apiClient from '@/common/api/axios'; @@ -48,6 +49,8 @@ export const SSAWorkspacePane: React.FC = () => { hasUnsavedPlanChanges, setHasUnsavedPlanChanges, dataContext, + executionMode, + agentExecution, } = useSSAStore(); const { executeWorkflow, cancelWorkflow, isExecuting: isWorkflowExecuting } = useWorkflow(); @@ -299,8 +302,29 @@ export const SSAWorkspacePane: React.FC = () => { {/* Canvas — single scroll */}
+ {/* ===== Agent 模式工作区 ===== */} + {executionMode === 'agent' && ( +
+ + {/* Agent 模式的报告输出复用 DynamicReport */} + {agentExecution?.status === 'completed' && agentExecution.reportBlocks && agentExecution.reportBlocks.length > 0 && ( +
+
+ + 分析结果 + +
+
+ +
+
+ )} +
+ )} + + {/* ===== QPER 模式工作区 ===== */} {/* Empty state */} - {!plan && ( + {executionMode === 'qper' && !plan && (
@@ -314,8 +338,8 @@ export const SSAWorkspacePane: React.FC = () => {
)} - {/* ===== Block 1: SAP ===== */} - {plan && ( + {/* ===== Block 1: SAP (QPER only) ===== */} + {executionMode === 'qper' && plan && (
@@ -372,8 +396,8 @@ export const SSAWorkspacePane: React.FC = () => {
)} - {/* ===== Block 2: Execution log ===== */} - {steps.length > 0 && ( + {/* ===== Block 2: Execution log (QPER only) ===== */} + {executionMode === 'qper' && steps.length > 0 && (
@@ -450,8 +474,8 @@ export const SSAWorkspacePane: React.FC = () => {
)} - {/* ===== Block 3: Results ===== */} - {hasResults && ( + {/* ===== Block 3: Results (QPER only) ===== */} + {executionMode === 'qper' && hasResults && (
diff --git a/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts b/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts index a1738f76..cd8706c0 100644 --- a/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts +++ b/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts @@ -257,6 +257,88 @@ export function useSSAChat(): UseSSAChatReturn { continue; } + // ── Agent 通道 SSE 事件 ── + + if (parsed.type === 'agent_planning') { + const { setAgentExecution, setWorkspaceOpen, setActivePane } = useSSAStore.getState(); + setAgentExecution({ + id: parsed.executionId || crypto.randomUUID(), + sessionId: '', + query: content, + retryCount: 0, + status: 'planning', + }); + setActivePane('execution'); + setWorkspaceOpen(true); + continue; + } + + if (parsed.type === 'agent_plan_ready') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + planText: parsed.planText, + planSteps: parsed.plan?.steps, + status: 'plan_pending', + }); + continue; + } + + if (parsed.type === 'code_generating') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + partialCode: parsed.partialCode, + status: 'coding', + }); + continue; + } + + if (parsed.type === 'code_generated') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + generatedCode: parsed.code, + partialCode: undefined, + status: parsed.code ? 'code_pending' : 'coding', + }); + continue; + } + + if (parsed.type === 'code_executing') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ status: 'executing' }); + continue; + } + + if (parsed.type === 'code_result') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + reportBlocks: parsed.reportBlocks, + generatedCode: parsed.code || useSSAStore.getState().agentExecution?.generatedCode, + status: 'completed', + durationMs: parsed.durationMs, + }); + continue; + } + + if (parsed.type === 'code_error') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + status: 'error', + errorMessage: parsed.message, + }); + continue; + } + + if (parsed.type === 'code_retry') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + status: 'coding', + retryCount: parsed.retryCount || 0, + generatedCode: parsed.code, + errorMessage: undefined, + }); + continue; + } + // 错误事件 if (parsed.type === 'error') { const errMsg = parsed.message || '处理消息时发生错误'; diff --git a/frontend-v2/src/modules/ssa/stores/ssaStore.ts b/frontend-v2/src/modules/ssa/stores/ssaStore.ts index 90072fc8..14a108c6 100644 --- a/frontend-v2/src/modules/ssa/stores/ssaStore.ts +++ b/frontend-v2/src/modules/ssa/stores/ssaStore.ts @@ -8,6 +8,7 @@ import { create } from 'zustand'; import type { SSAMode, + ExecutionMode, SSASession, SSAMessage, DataProfile, @@ -18,6 +19,7 @@ import type { VariableDictEntry, FiveSectionReport, VariableDetailData, + AgentExecutionRecord, } from '../types'; type ArtifactPane = 'empty' | 'sap' | 'execution' | 'result'; @@ -73,8 +75,16 @@ interface SSAState { dataProfileModalVisible: boolean; workflowPlanLoading: boolean; + /** 双通道执行模式 */ + executionMode: ExecutionMode; + /** Agent 通道当前执行记录 */ + agentExecution: AgentExecutionRecord | null; + // ---- actions ---- setMode: (mode: SSAMode) => void; + setExecutionMode: (mode: ExecutionMode) => void; + setAgentExecution: (exec: AgentExecutionRecord | null) => void; + updateAgentExecution: (patch: Partial) => void; setCurrentSession: (session: SSASession | null) => void; addMessage: (message: SSAMessage) => void; setMessages: (messages: SSAMessage[]) => void; @@ -141,6 +151,8 @@ const initialState = { dataProfileModalVisible: false, workflowPlanLoading: false, hasUnsavedPlanChanges: false, + executionMode: 'qper' as ExecutionMode, + agentExecution: null as AgentExecutionRecord | null, dataContext: { dataOverview: null, variableDictionary: [], @@ -157,6 +169,12 @@ export const useSSAStore = create((set) => ({ ...initialState, setMode: (mode) => set({ mode }), + setExecutionMode: (mode) => set({ executionMode: mode }), + setAgentExecution: (exec) => set({ agentExecution: exec }), + updateAgentExecution: (patch) => + set((state) => ({ + agentExecution: state.agentExecution ? { ...state.agentExecution, ...patch } : null, + })), setCurrentSession: (session) => set({ currentSession: session }), addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })), diff --git a/frontend-v2/src/modules/ssa/styles/ssa-workspace.css b/frontend-v2/src/modules/ssa/styles/ssa-workspace.css index 4de8f963..02b3c2e6 100644 --- a/frontend-v2/src/modules/ssa/styles/ssa-workspace.css +++ b/frontend-v2/src/modules/ssa/styles/ssa-workspace.css @@ -340,6 +340,12 @@ gap: 16px; } +.chat-header-right { + display: flex; + align-items: center; + gap: 12px; +} + .back-btn { display: flex; align-items: center; diff --git a/frontend-v2/src/modules/ssa/styles/ssa.css b/frontend-v2/src/modules/ssa/styles/ssa.css index 00e1b9da..f028a86d 100644 --- a/frontend-v2/src/modules/ssa/styles/ssa.css +++ b/frontend-v2/src/modules/ssa/styles/ssa.css @@ -1220,3 +1220,281 @@ font-weight: 500; } .wt-mismatch-force:hover { background: #fee2e2; } + +/* ========== ModeToggle — 双通道切换 ========== */ +.ssa-mode-toggle { + display: inline-flex; + background: #f1f5f9; + border-radius: 8px; + padding: 2px; + gap: 2px; +} + +.mode-toggle-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + border: none; + border-radius: 6px; + background: transparent; + font-size: 12px; + font-weight: 500; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.mode-toggle-btn:hover:not(:disabled):not(.active) { + color: #334155; + background: #e2e8f0; +} + +.mode-toggle-btn.active { + background: #fff; + color: #1e40af; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +.mode-toggle-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ========== AgentCodePanel — Agent 代码面板 ========== */ +/* ── Agent Code Panel ── */ +.agent-code-panel { + background: #1e293b; + border-radius: 10px; + overflow: hidden; + margin: 12px 0; +} + +.agent-code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: #0f172a; + border-bottom: 1px solid #334155; +} + +.agent-code-header h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: #94a3b8; + display: flex; + align-items: center; + gap: 6px; +} + +.agent-code-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 3px 10px; + border-radius: 12px; + font-weight: 500; +} + +.agent-code-status.planning { background: #1e3a5f; color: #60a5fa; } +.agent-code-status.plan_pending { background: #3b2f1e; color: #fbbf24; } +.agent-code-status.coding { background: #1e3a5f; color: #60a5fa; } +.agent-code-status.code_pending { background: #3b2f1e; color: #fbbf24; } +.agent-code-status.executing { background: #1e3a5f; color: #60a5fa; } +.agent-code-status.completed { background: #1a3a2a; color: #4ade80; } +.agent-code-status.error { background: #3b1e1e; color: #f87171; } + +/* Agent 分步区块 */ +.agent-section { + border-bottom: 1px solid #334155; +} + +.agent-section:last-child { + border-bottom: none; +} + +.agent-section-title { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px 6px; + font-size: 12px; + font-weight: 600; + color: #94a3b8; +} + +.badge-waiting { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: #3b2f1e; + color: #fbbf24; + font-weight: 500; +} + +.badge-done { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: #1a3a2a; + color: #4ade80; + font-weight: 500; +} + +.badge-streaming { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: #1e3a5f; + color: #60a5fa; + font-weight: 500; +} + +/* 计划步骤列表 */ +.agent-plan-steps { + padding: 4px 16px 12px; +} + +.plan-step-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 5px 0; + font-size: 12px; + color: #cbd5e1; + line-height: 1.5; +} + +.plan-step-item .step-num { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #334155; + color: #94a3b8; + font-size: 11px; + font-weight: 600; +} + +.plan-step-item .step-method { + flex-shrink: 0; + font-weight: 600; + color: #60a5fa; + min-width: 60px; +} + +.plan-step-item .step-desc { + color: #94a3b8; +} + +.agent-plan-text { + padding: 4px 16px 12px; + font-size: 13px; + color: #cbd5e1; + line-height: 1.6; + white-space: pre-wrap; +} + +/* 代码区域 */ +.agent-code-body { + padding: 12px 16px; + max-height: 400px; + overflow-y: auto; +} + +.agent-code-body pre { + margin: 0; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; + line-height: 1.6; + color: #e2e8f0; + white-space: pre-wrap; + word-break: break-all; +} + +.agent-code-body pre.streaming { + border-right: 2px solid #60a5fa; + animation: cursor-blink 1s step-end infinite; +} + +@keyframes cursor-blink { + 50% { border-color: transparent; } +} + +.agent-code-loading { + display: flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + padding: 16px 0; +} + +.agent-code-empty { + color: #64748b; + font-size: 13px; + text-align: center; + padding: 32px 16px; +} + +/* 状态条 */ +.agent-retry-info { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #3b2f1e; + border-top: 1px solid #534120; + font-size: 12px; + color: #fbbf24; +} + +.agent-error-bar { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 16px; + background: #3b1e1e; + border-top: 1px solid #7f1d1d; + font-size: 12px; + color: #fca5a5; +} + +.agent-error-bar pre { + margin: 0; + font-size: 12px; + white-space: pre-wrap; + color: #fecaca; +} + +.agent-executing-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #1e3a5f; + border-top: 1px solid #1e40af; + font-size: 12px; + color: #93c5fd; +} + +.agent-success-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: #1a3a2a; + border-top: 1px solid #166534; + font-size: 12px; + color: #86efac; +} diff --git a/frontend-v2/src/modules/ssa/types/index.ts b/frontend-v2/src/modules/ssa/types/index.ts index 64466323..24b9347c 100644 --- a/frontend-v2/src/modules/ssa/types/index.ts +++ b/frontend-v2/src/modules/ssa/types/index.ts @@ -4,6 +4,9 @@ export type SSAMode = 'analysis' | 'consult'; +/** 双通道执行模式:QPER 管线 / LLM Agent 代码生成 */ +export type ExecutionMode = 'qper' | 'agent'; + export type SessionStatus = 'active' | 'completed' | 'archived'; export interface SSASession { @@ -367,12 +370,36 @@ export interface ClarificationOptionData { description?: string; } -/** SSE 消息类型(Phase R 扩展:qper_status + reflection_complete) */ +/** SSE 消息类型(Phase R 扩展:qper_status + reflection_complete + Agent 通道事件) */ export type SSEMessageType = | 'connected' | 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete' | 'workflow_error' - | 'qper_status' | 'reflection_complete'; + | 'qper_status' | 'reflection_complete' + | 'agent_planning' | 'agent_plan_ready' + | 'code_generating' | 'code_generated' + | 'code_executing' | 'code_result' | 'code_error' | 'code_retry'; + +/** Agent 通道执行状态 */ +export type AgentExecutionStatus = + | 'pending' | 'planning' | 'plan_pending' + | 'coding' | 'code_pending' + | 'executing' | 'completed' | 'error'; + +export interface AgentExecutionRecord { + id: string; + sessionId: string; + query: string; + planText?: string; + planSteps?: Array<{ order: number; method: string; description: string }>; + generatedCode?: string; + partialCode?: string; + reportBlocks?: ReportBlock[]; + retryCount: number; + status: AgentExecutionStatus; + errorMessage?: string; + durationMs?: number; +} /** SSE 消息 */ export interface SSEMessage { diff --git a/r-statistics-service/plumber.R b/r-statistics-service/plumber.R index 56bffc29..24c6a076 100644 --- a/r-statistics-service/plumber.R +++ b/r-statistics-service/plumber.R @@ -167,6 +167,123 @@ function(req) { }) } +#* Agent 通道:执行任意 R 代码(沙箱模式) +#* @post /api/v1/execute-code +#* @serializer unboxedJSON +function(req) { + tryCatch({ + input <- jsonlite::fromJSON(req$postBody, simplifyVector = FALSE) + + code <- input$code + session_id <- input$session_id + timeout_sec <- as.numeric(input$timeout %||% 120) + + if (is.null(code) || nchar(trimws(code)) == 0) { + return(list( + status = "error", + error_code = "E400", + message = "Missing 'code' parameter", + user_hint = "R 代码不能为空" + )) + } + + # 安全限制:最长 120 秒 + if (timeout_sec > 120) timeout_sec <- 120 + + message(glue::glue("[ExecuteCode] session={session_id}, code_length={nchar(code)}, timeout={timeout_sec}s")) + + # 在隔离环境中执行,预加载 block_helpers 和 data_loader + sandbox_env <- new.env(parent = globalenv()) + + # 如果有 session_id,尝试预设数据路径变量 + if (!is.null(session_id) && nchar(session_id) > 0) { + sandbox_env$SESSION_ID <- session_id + } + + start_time <- proc.time() + + # 捕获输出和结果 + output_capture <- tryCatch( + withTimeout( + { + # 捕获打印输出 + captured_output <- utils::capture.output({ + result <- eval(parse(text = code), envir = sandbox_env) + }) + + list( + result = result, + output = captured_output, + error = NULL + ) + }, + timeout = timeout_sec, + onTimeout = "error" + ), + error = function(e) { + list( + result = NULL, + output = NULL, + error = e$message + ) + } + ) + + elapsed_ms <- round((proc.time() - start_time)["elapsed"] * 1000) + + if (!is.null(output_capture$error)) { + message(glue::glue("[ExecuteCode] ERROR after {elapsed_ms}ms: {output_capture$error}")) + return(list( + status = "error", + error_code = "E_EXEC", + message = output_capture$error, + user_hint = paste0("R 代码执行出错 (", elapsed_ms, "ms): ", output_capture$error), + duration_ms = elapsed_ms + )) + } + + message(glue::glue("[ExecuteCode] SUCCESS in {elapsed_ms}ms")) + + # 将结果标准化 + final_result <- output_capture$result + + # 如果结果是 list 且包含 report_blocks,直接返回 + if (is.list(final_result) && !is.null(final_result$report_blocks)) { + return(list( + status = "success", + result = final_result, + console_output = output_capture$output, + duration_ms = elapsed_ms + )) + } + + # 否则包装为通用结果 + return(list( + status = "success", + result = list( + data = final_result, + report_blocks = list() + ), + console_output = output_capture$output, + duration_ms = elapsed_ms + )) + + }, error = function(e) { + message(glue::glue("[ExecuteCode] FATAL ERROR: {e$message}")) + return(map_r_error(e$message)) + }) +} + +#' 超时执行包装器 +#' @param expr 表达式 +#' @param timeout 超时秒数 +#' @param onTimeout 超时行为 +withTimeout <- function(expr, timeout = 120, onTimeout = "error") { + setTimeLimit(cpu = timeout, elapsed = timeout, transient = TRUE) + on.exit(setTimeLimit(cpu = Inf, elapsed = Inf, transient = FALSE)) + eval(expr, envir = parent.frame()) +} + #* 执行统计工具 #* @post /api/v1/skills/ #* @param tool_code:str 工具代码(如 ST_T_TEST_IND)