From 6edfad032f5bd4c196a100e337f14f94f7e8597c Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Wed, 11 Mar 2026 22:49:05 +0800 Subject: [PATCH] feat(ssa): finalize strict stepwise agent execution flow Align Agent mode to strict stepwise generation and execution, add deterministic and safety hardening, and sync deployment/module documentation for Phase 5A.5/5B/5C rollout. - implement strict stepwise execution path and dependency short-circuiting - persist step-level errors/results and stream step_* progress events - add agent plan params patch route and schema/migration support - improve R sanitizer/security checks and step result rendering in workspace - update SSA module guide and deployment change checklist Made-with: Cursor --- .../migration.sql | 4 + backend/prisma/schema.prisma | 3 + backend/src/modules/ssa/index.ts | 3 + .../ssa/routes/agent-execution.routes.ts | 183 +++++ .../modules/ssa/services/AgentCoderService.ts | 142 +++- .../ssa/services/ChatHandlerService.ts | 616 ++++++++++++++--- .../modules/ssa/services/CodeRunnerService.ts | 70 +- .../SSA-智能统计分析/00-模块当前状态与开发指南.md | 65 +- .../12-Plan-and-Execute分步执行架构开发计划.md | 623 ++++++++++++++++++ .../06-开发记录/20260311-Agent-Phase5A5联调验证清单.md | 41 ++ docs/05-部署文档/03-待部署变更清单.md | 15 +- .../modules/ssa/components/AgentCodePanel.tsx | 259 +++++++- .../ssa/components/SSAWorkspacePane.tsx | 3 + .../ssa/components/WorkflowTimeline.tsx | 22 +- .../src/modules/ssa/hooks/useSSAChat.ts | 143 +++- .../src/modules/ssa/stores/ssaStore.ts | 12 +- frontend-v2/src/modules/ssa/types/index.ts | 26 +- frontend-v2/tsconfig.tsbuildinfo | 2 +- r-statistics-service/plumber.R | 31 +- 19 files changed, 2105 insertions(+), 158 deletions(-) create mode 100644 backend/prisma/migrations/20260311_add_ssa_agent_step_seed_fields/migration.sql create mode 100644 backend/src/modules/ssa/routes/agent-execution.routes.ts create mode 100644 docs/03-业务模块/SSA-智能统计分析/04-开发计划/12-Plan-and-Execute分步执行架构开发计划.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/20260311-Agent-Phase5A5联调验证清单.md diff --git a/backend/prisma/migrations/20260311_add_ssa_agent_step_seed_fields/migration.sql b/backend/prisma/migrations/20260311_add_ssa_agent_step_seed_fields/migration.sql new file mode 100644 index 00000000..d030108c --- /dev/null +++ b/backend/prisma/migrations/20260311_add_ssa_agent_step_seed_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "ssa_schema"."ssa_agent_executions" ADD COLUMN "current_step" INTEGER, +ADD COLUMN "seed_audit" JSONB, +ADD COLUMN "step_results" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f855f73a..f777643a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -2549,6 +2549,9 @@ model SsaAgentExecution { reviewResult Json? @map("review_result") executionResult Json? @map("execution_result") reportBlocks Json? @map("report_blocks") + stepResults Json? @map("step_results") + currentStep Int? @map("current_step") + seedAudit Json? @map("seed_audit") retryCount Int @default(0) @map("retry_count") status String @default("pending") /// pending | planning | coding | reviewing | executing | completed | error errorMessage String? @map("error_message") diff --git a/backend/src/modules/ssa/index.ts b/backend/src/modules/ssa/index.ts index da77a1c8..ae4da5c5 100644 --- a/backend/src/modules/ssa/index.ts +++ b/backend/src/modules/ssa/index.ts @@ -16,6 +16,7 @@ import configRoutes from './routes/config.routes.js'; import workflowRoutes from './routes/workflow.routes.js'; import blackboardRoutes from './routes/blackboard.routes.js'; import chatRoutes from './routes/chat.routes.js'; +import agentExecutionRoutes from './routes/agent-execution.routes.js'; export async function ssaRoutes(app: FastifyInstance) { // 注册认证中间件(遵循模块认证规范) @@ -32,6 +33,8 @@ export async function ssaRoutes(app: FastifyInstance) { app.register(blackboardRoutes, { prefix: '/sessions/:sessionId/blackboard' }); // Phase II: 统一对话入口 app.register(chatRoutes, { prefix: '/sessions' }); + // Agent 计划参数编辑接口(Phase 5A.5) + app.register(agentExecutionRoutes, { prefix: '/agent-executions' }); } export default ssaRoutes; diff --git a/backend/src/modules/ssa/routes/agent-execution.routes.ts b/backend/src/modules/ssa/routes/agent-execution.routes.ts new file mode 100644 index 00000000..e7a1169b --- /dev/null +++ b/backend/src/modules/ssa/routes/agent-execution.routes.ts @@ -0,0 +1,183 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { logger } from '../../../common/logging/index.js'; +import { prisma } from '../../../config/database.js'; +import toolParamConstraints from '../config/tool_param_constraints.json' with { type: 'json' }; + +type ParamRule = { + paramType: 'single' | 'multi'; + requiredType: 'any' | 'numeric' | 'categorical'; + hint?: string; +}; + +type ConstraintsMap = Record>; + +const Constraints = toolParamConstraints as ConstraintsMap; + +function getUserId(request: FastifyRequest): string { + const userId = (request as any).user?.userId; + if (!userId) throw new Error('User not authenticated'); + return userId; +} + +function normalizeColumnType(raw?: string): 'numeric' | 'categorical' | 'other' { + const t = (raw || '').toLowerCase(); + if (['numeric', 'number', 'float', 'double', 'integer', 'int'].includes(t)) return 'numeric'; + if (['categorical', 'category', 'factor', 'string', 'text'].includes(t)) return 'categorical'; + return 'other'; +} + +export default async function agentExecutionRoutes(app: FastifyInstance) { + app.patch<{ + Params: { executionId: string }; + Body: { steps: Array<{ stepOrder: number; params: Record }> }; + }>( + '/:executionId/plan-params', + async (request, reply) => { + const userId = getUserId(request); + const { executionId } = request.params; + const BodySchema = z.object({ + steps: z.array( + z.object({ + stepOrder: z.number().int().positive(), + params: z.record(z.string(), z.unknown()), + }), + ).min(1), + }); + const parsed = BodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, + error: 'Invalid request body', + details: parsed.error.flatten(), + }); + } + + const execution = await (prisma as any).ssaAgentExecution.findUnique({ + where: { id: executionId }, + select: { + id: true, + status: true, + reviewResult: true, + sessionId: true, + session: { select: { userId: true, dataSchema: true } }, + }, + }); + + if (!execution) { + return reply.status(404).send({ success: false, error: 'Agent execution not found' }); + } + if (execution.session?.userId !== userId) { + return reply.status(403).send({ success: false, error: 'No permission to modify this execution' }); + } + if (execution.status !== 'plan_pending') { + return reply.status(409).send({ + success: false, + error: `Cannot modify plan params when status is '${execution.status}'`, + }); + } + + const review = (execution.reviewResult || {}) as any; + const steps = Array.isArray(review.steps) ? review.steps : []; + if (steps.length === 0) { + return reply.status(400).send({ success: false, error: 'Execution has no structured plan steps' }); + } + + const schemaColumns = ((execution.session?.dataSchema as any)?.columns || []) as Array; + const columnTypeMap = new Map( + schemaColumns.map((c: any) => [c.name, normalizeColumnType(c.type || c.inferred_type)]), + ); + + for (const patch of parsed.data.steps) { + const target = steps.find((s: any) => Number(s.order) === patch.stepOrder); + if (!target) { + return reply.status(400).send({ + success: false, + error: `Step ${patch.stepOrder} does not exist in reviewResult.steps`, + }); + } + + const toolCode = String(target.toolCode || target.tool_code || '').trim(); + const ruleSet = toolCode ? Constraints[toolCode] : undefined; + + for (const [paramName, value] of Object.entries(patch.params)) { + if (typeof value === 'string' && value && columnTypeMap.size > 0 && !columnTypeMap.has(value)) { + return reply.status(400).send({ + success: false, + error: `Variable '${value}' in step ${patch.stepOrder}.${paramName} does not exist in dataset`, + }); + } + if (Array.isArray(value) && columnTypeMap.size > 0) { + for (const v of value) { + if (typeof v === 'string' && !columnTypeMap.has(v)) { + return reply.status(400).send({ + success: false, + error: `Variable '${v}' in step ${patch.stepOrder}.${paramName} does not exist in dataset`, + }); + } + } + } + + const rule = ruleSet?.[paramName]; + if (!rule || rule.requiredType === 'any') continue; + + if (rule.paramType === 'single' && typeof value === 'string') { + const t = columnTypeMap.get(value); + if ((rule.requiredType === 'numeric' && t !== 'numeric') + || (rule.requiredType === 'categorical' && t !== 'categorical')) { + return reply.status(400).send({ + success: false, + error: `Step ${patch.stepOrder}.${paramName} requires ${rule.requiredType} variable`, + hint: rule.hint, + }); + } + } + + if (rule.paramType === 'multi' && Array.isArray(value)) { + for (const v of value) { + if (typeof v !== 'string') continue; + const t = columnTypeMap.get(v); + if ((rule.requiredType === 'numeric' && t !== 'numeric') + || (rule.requiredType === 'categorical' && t !== 'categorical')) { + return reply.status(400).send({ + success: false, + error: `Step ${patch.stepOrder}.${paramName} requires ${rule.requiredType} variables`, + hint: rule.hint, + }); + } + } + } + } + } + + // 写回 reviewResult.steps[].params + for (const patch of parsed.data.steps) { + const idx = steps.findIndex((s: any) => Number(s.order) === patch.stepOrder); + if (idx >= 0) { + const currentParams = (steps[idx].params || {}) as Record; + steps[idx].params = { ...currentParams, ...patch.params }; + } + } + + review.steps = steps; + await (prisma as any).ssaAgentExecution.update({ + where: { id: executionId }, + data: { + reviewResult: review, + }, + }); + + logger.info('[SSA:AgentExecution] plan params updated', { + executionId, + stepsUpdated: parsed.data.steps.length, + }); + + return reply.send({ + success: true, + stepsUpdated: parsed.data.steps.length, + reviewResult: review, + }); + }, + ); +} + diff --git a/backend/src/modules/ssa/services/AgentCoderService.ts b/backend/src/modules/ssa/services/AgentCoderService.ts index 1a7e84f1..2a241b79 100644 --- a/backend/src/modules/ssa/services/AgentCoderService.ts +++ b/backend/src/modules/ssa/services/AgentCoderService.ts @@ -28,6 +28,12 @@ export interface GeneratedCode { requiredPackages: string[]; } +export interface StepResultSummary { + stepOrder: number; + method: string; + highlights: string; +} + export class AgentCoderService { /** @@ -132,6 +138,63 @@ export class AgentCoderService { return result; } + /** + * 按步骤流式生成(Phase 5B) + */ + async generateStepCodeStream( + sessionId: string, + plan: AgentPlan, + step: AgentPlan['steps'][number], + previousResults: StepResultSummary[], + onProgress: (accumulated: string) => void, + errorFeedback?: string, + previousCode?: string, + ): Promise { + const dataContext = await this.buildDataContext(sessionId); + const systemPrompt = await this.buildSystemPrompt(dataContext); + + const userMessage = errorFeedback + ? this.buildStepRetryMessage(plan, step, previousResults, errorFeedback, previousCode) + : this.buildStepFirstMessage(plan, step, previousResults); + + const messages: LLMMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ]; + + logger.info('[AgentCoder] Generating step R code (stream)', { + sessionId, + planTitle: plan.title, + stepOrder: step.order, + stepMethod: step.method, + 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: 8000, + })) { + if (chunk.content) { + fullContent += chunk.content; + if (fullContent.length - lastSentLen >= CHUNK_SIZE) { + onProgress(fullContent); + lastSentLen = fullContent.length; + } + } + } + + if (fullContent.length > lastSentLen) { + onProgress(fullContent); + } + + return this.parseCode(fullContent); + } + private async buildDataContext(sessionId: string): Promise { const blackboard = await sessionBlackboardService.get(sessionId); if (!blackboard) return '(无数据上下文)'; @@ -302,7 +365,84 @@ ${errorFeedback} 4. 用 safe_test 模式包裹统计检验,处理 NA/NaN/Inf 5. 检查所有 library() 调用是否在预装包列表内 6. 保持 report_blocks 输出格式不变 -7. **必须将修正后的完整代码放在 ... 标签中**`; +7. **严禁使用未预装包**(尤其是 pROC、nortest、exact2x2);如涉及 ROC/AUC,请使用基础 R 实现,不要写 pROC::* +8. 若报错包含 "unexpected 'if'" 或语法错误,必须检查并修复所有单行拼接错误:表达式后面的 if/else 必须换行并带完整代码块 +9. **必须将修正后的完整代码放在 ... 标签中**`; + } + + private buildStepFirstMessage( + plan: AgentPlan, + step: AgentPlan['steps'][number], + previousResults: StepResultSummary[], + ): string { + const previous = previousResults.length > 0 + ? previousResults.map(r => `- Step ${r.stepOrder} (${r.method}): ${r.highlights}`).join('\n') + : '(无)'; + + return `请仅为当前步骤生成 R 代码(不要输出其他步骤代码)。 + +## 分析计划 +- 标题:${plan.title} +- 研究设计:${plan.designType} + +## 当前步骤(仅此一步) +- Step ${step.order} +- 方法:${step.method} +- 目标:${step.description} +- 理由:${step.rationale} + +## 已完成步骤结果摘要 +${previous} + +## 关键约束 +1. 只能生成当前步骤所需代码 +2. 不要重复加载数据(df 已存在) +3. 可直接使用前序步骤已生成的对象 +4. 末尾仍需返回包含 report_blocks 的 list +5. 必须使用 ... 包裹完整代码`; + } + + private buildStepRetryMessage( + plan: AgentPlan, + step: AgentPlan['steps'][number], + previousResults: StepResultSummary[], + errorFeedback: string, + previousCode?: string, + ): string { + const codeSection = previousCode + ? `## 上次失败代码 + +${previousCode} +` + : ''; + + const previous = previousResults.length > 0 + ? previousResults.map(r => `- Step ${r.stepOrder} (${r.method}): ${r.highlights}`).join('\n') + : '(无)'; + + return `当前步骤代码执行失败,请仅修复当前步骤并输出完整新代码。 + +${codeSection} + +## 失败步骤 +- 计划:${plan.title} +- Step ${step.order} / ${step.method} +- 目标:${step.description} + +## 前序步骤结果摘要 +${previous} + +## 错误信息 + +${errorFeedback} + + +## 修复要求 +1. 只修复当前步骤代码,不生成其他步骤代码 +2. 检查并修复语法、对象存在性、类型转换、缺失值处理 +3. 禁止未安装包,禁止 system/eval/source 等高危调用 +4. 仍需返回包含 report_blocks 的 list +5. 必须用 ... 输出完整代码`; } private parseCode(content: string): GeneratedCode { diff --git a/backend/src/modules/ssa/services/ChatHandlerService.ts b/backend/src/modules/ssa/services/ChatHandlerService.ts index 7683e2dd..d76f6138 100644 --- a/backend/src/modules/ssa/services/ChatHandlerService.ts +++ b/backend/src/modules/ssa/services/ChatHandlerService.ts @@ -19,7 +19,7 @@ import { askUserService, type AskUserResponse } from './AskUserService.js'; import { toolOrchestratorService } from './ToolOrchestratorService.js'; import { executeGetDataOverview } from './tools/GetDataOverviewTool.js'; import { agentPlannerService } from './AgentPlannerService.js'; -import { agentCoderService } from './AgentCoderService.js'; +import { agentCoderService, type StepResultSummary } from './AgentCoderService.js'; import { codeRunnerService } from './CodeRunnerService.js'; import { prisma } from '../../../config/database.js'; import type { IntentType } from './SystemPromptService.js'; @@ -397,12 +397,12 @@ export class ChatHandlerService { // ──────────────────────────────────────────── /** - * Agent 模式入口 — 三步确认式管线 + * Agent 模式入口 — 三步确认式管线(严格分步模式) * * 状态机: * 新请求 → agentGeneratePlan → plan_pending(等用户确认) - * 用户确认计划 → agentStreamCode → code_pending(等用户确认) - * 用户确认代码 → agentExecuteCode → completed + * 用户确认计划 → agentStreamCode(不生成代码,仅进入执行确认)→ code_pending(等用户确认) + * 用户确认代码 → agentExecuteCode(分步生成+分步执行)→ completed/error */ async handleAgentMode( sessionId: string, @@ -423,7 +423,7 @@ export class ChatHandlerService { }); if (!activeExec) { - return await this.handleAgentChat(sessionId, conversationId, writer, placeholderMessageId, null); + return await this.handleAgentChat(sessionId, conversationId, writer, placeholderMessageId, null, false); } if (agentAction === 'confirm_plan' && activeExec.status === 'plan_pending') { @@ -445,6 +445,7 @@ export class ChatHandlerService { if (activeExec) { const action = this.parseAgentAction(userContent); + const isInlineInstruction = !!metadata?.agentInlineInstruction; if (activeExec.status === 'plan_pending' && action === 'confirm') { return await this.agentStreamCode(activeExec, sessionId, conversationId, writer, placeholderMessageId); } @@ -454,6 +455,14 @@ export class ChatHandlerService { if (action === 'cancel') { return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId); } + // 方案 B:在待确认阶段,左侧输入默认视为“右侧工作区内联修改指令” + if (isInlineInstruction && activeExec.status === 'plan_pending') { + return await this.agentGeneratePlan(sessionId, conversationId, userContent, writer, placeholderMessageId); + } + if (isInlineInstruction && activeExec.status === 'code_pending') { + // 严格分步模式:执行前不生成代码。若用户继续输入修改指令,回到计划阶段重新规划更符合心智。 + return await this.agentGeneratePlan(sessionId, conversationId, userContent, writer, placeholderMessageId); + } } // 3. 无挂起确认 — 检查是否是分析请求 @@ -559,7 +568,8 @@ export class ChatHandlerService { sendEvent('agent_planning', { executionId: execution.id, message: '正在制定分析计划...' }); const conversationHistory = await conversationService.buildContext(sessionId, conversationId, 'analyze'); - const plan = await agentPlannerService.generatePlan(sessionId, userContent, conversationHistory); + const rawPlan = await agentPlannerService.generatePlan(sessionId, userContent, conversationHistory); + const plan = this.normalizeAgentPlan(rawPlan); // planText 存原始文本, reviewResult(JSON) 存结构化计划以便恢复 await (prisma as any).ssaAgentExecution.update({ @@ -584,7 +594,154 @@ export class ChatHandlerService { return { messageId: placeholderMessageId, intent: 'analyze', success: true }; } - // ── Agent Step 2: 流式生成代码 → 等用户确认 ── + /** + * 将 Planner 输出归一化为可编辑计划: + * - 每步补齐 toolCode(用于约束匹配) + * - 每步补齐 params(用于 5A.5 变量编辑) + */ + private normalizeAgentPlan(plan: any): any { + const normalized = JSON.parse(JSON.stringify(plan || {})); + if (!Array.isArray(normalized.steps)) { + normalized.steps = []; + return normalized; + } + + normalized.steps = normalized.steps.map((step: any, idx: number) => { + const order = Number(step?.order) || (idx + 1); + const inferred = this.inferToolCodeAndParamsFromStep(normalized, step || {}); + return { + ...step, + order, + toolCode: step?.toolCode || step?.tool_code || inferred.toolCode, + params: { ...(inferred.params || {}), ...(step?.params || {}) }, + }; + }); + + return normalized; + } + + private inferToolCodeAndParamsFromStep( + plan: { + variables?: { + outcome?: string[]; + predictors?: string[]; + grouping?: string | null; + confounders?: string[]; + }; + }, + step: { method?: string; description?: string }, + ): { toolCode: string; params: Record } { + const method = (step.method || '').toLowerCase(); + const desc = (step.description || '').toLowerCase(); + const text = `${method} ${desc}`; + const vars = plan.variables || {}; + const outcome = Array.isArray(vars.outcome) ? vars.outcome[0] : undefined; + const predictors = Array.isArray(vars.predictors) ? vars.predictors : []; + const grouping = vars.grouping || undefined; + const confounders = Array.isArray(vars.confounders) ? vars.confounders : []; + + if (text.includes('logistic') || text.includes('逻辑回归') || text.includes('二元回归')) { + return { + toolCode: 'ST_LOGISTIC_BINARY', + params: { + outcome_var: outcome || null, + predictors, + confounders, + }, + }; + } + + if (text.includes('linear') || text.includes('线性回归')) { + return { + toolCode: 'ST_LINEAR_REG', + params: { + outcome_var: outcome || null, + predictors, + confounders, + }, + }; + } + + if (text.includes('anova') || text.includes('方差分析')) { + return { + toolCode: 'ST_ANOVA_ONE', + params: { + group_var: grouping || null, + value_var: outcome || null, + }, + }; + } + + if (text.includes('mann') || text.includes('wilcoxon秩和') || text.includes('秩和检验')) { + return { + toolCode: 'ST_MANN_WHITNEY', + params: { + group_var: grouping || null, + value_var: outcome || null, + }, + }; + } + + if (text.includes('t检验') || text.includes('t test') || text.includes('t-test')) { + return { + toolCode: 'ST_T_TEST_IND', + params: { + group_var: grouping || null, + value_var: outcome || null, + }, + }; + } + + if (text.includes('fisher')) { + return { + toolCode: 'ST_FISHER', + params: { + var1: grouping || null, + var2: outcome || null, + }, + }; + } + + if (text.includes('卡方') || text.includes('chi-square') || text.includes('chisq')) { + return { + toolCode: 'ST_CHI_SQUARE', + params: { + var1: grouping || null, + var2: outcome || null, + }, + }; + } + + if (text.includes('相关') || text.includes('correlation') || text.includes('pearson') || text.includes('spearman')) { + return { + toolCode: 'ST_CORRELATION', + params: { + var_x: predictors[0] || null, + var_y: outcome || null, + }, + }; + } + + if (text.includes('基线') || text.includes('baseline')) { + return { + toolCode: 'ST_BASELINE_TABLE', + params: { + group_var: grouping || null, + analyze_vars: [...predictors, ...confounders].filter(Boolean), + }, + }; + } + + return { + toolCode: 'ST_DESCRIPTIVE', + params: { + variables: [...predictors, ...confounders, ...(outcome ? [outcome] : [])].filter(Boolean), + group_var: grouping || null, + }, + }; + } + + // ── Agent Step 2: 进入执行确认(严格分步:执行前不生成代码) ── private async agentStreamCode( execution: any, @@ -602,30 +759,86 @@ export class ChatHandlerService { 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' }, + data: { generatedCode: null, status: 'code_pending' }, }); sendEvent('code_generated', { executionId: execution.id, - code: generated.code, - explanation: generated.explanation, + code: '', + explanation: '已进入执行确认。严格分步模式下,代码将在执行阶段逐步生成。', }); // 固定文本引导语 - const hintText = `R 代码已生成(${generated.code.split('\n').length} 行),👉 请在右侧工作区核对代码并点击「确认并执行」。`; + const hintText = '已进入执行确认。当前为严格分步模式:执行前不生成代码,点击「确认并执行代码」后将按步骤逐步生成并执行。'; + await this.sendFixedHint(writer, placeholderMessageId, hintText); + + return { messageId: placeholderMessageId, intent: 'analyze', success: true }; + } + + // ── Agent 补充分支:用户在 code_pending 阶段给出修改指令(兼容保留) ── + private async agentRegenerateCodeByInstruction( + execution: any, + sessionId: string, + _conversationId: string, + instruction: 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_retry', { + executionId: execution.id, + retryCount: (execution.retryCount || 0) + 1, + message: '收到修改指令,Agent 正在重新生成第 1 步代码预览...', + previousError: instruction, + }); + sendEvent('code_generating', { executionId: execution.id, partialCode: '' }); + + const plan = execution.reviewResult as any; + const steps = (plan?.steps || []).length > 0 + ? (plan.steps as Array<{ order: number; method: string; description: string; rationale: string }>) + : [{ order: 1, method: '综合分析', description: '执行完整分析', rationale: '默认单步' }]; + const firstStep = steps[0]; + const retry = await agentCoderService.generateStepCodeStream( + sessionId, + plan, + firstStep, + [], + (accumulated) => { + sendEvent('code_generating', { + executionId: execution.id, + partialCode: accumulated, + }); + }, + `用户修改要求:${instruction}`, + execution.generatedCode, + ); + + await (prisma as any).ssaAgentExecution.update({ + where: { id: execution.id }, + data: { + generatedCode: retry.code, + status: 'code_pending', + retryCount: (execution.retryCount || 0) + 1, + }, + }); + + sendEvent('code_generated', { + executionId: execution.id, + code: retry.code, + explanation: retry.explanation, + }); + + const hintText = '已根据你的修改指令重生成第 1 步代码预览,后续步骤将在执行阶段逐步生成。👉 请在右侧工作区确认后执行。'; await this.sendFixedHint(writer, placeholderMessageId, hintText); return { messageId: placeholderMessageId, intent: 'analyze', success: true }; @@ -636,7 +849,7 @@ export class ChatHandlerService { private async agentExecuteCode( execution: any, sessionId: string, - conversationId: string, + _conversationId: string, writer: StreamWriter, placeholderMessageId: string, ): Promise { @@ -650,111 +863,344 @@ export class ChatHandlerService { }); const plan = execution.reviewResult as any; - let currentCode = execution.generatedCode as string; + const steps = (plan?.steps || []).length > 0 + ? (plan.steps as Array<{ order: number; method: string; description: string; rationale: string }>) + : [{ order: 1, method: '综合分析', description: '执行完整分析', rationale: '默认单步' }]; let lastError: string | null = null; + const datasetHash = await this.getSessionDatasetHash(sessionId); + const baseSeed = this.deriveStableSeed(`${sessionId}:${execution.id}:${datasetHash}`); + const seedAudit = { baseSeed, datasetHash, steps: [] as Array<{ stepOrder: number; stepSeed: number }> }; + const stepResults: Array<{ + stepOrder: number; + method: string; + status: 'pending' | 'coding' | 'executing' | 'completed' | 'error' | 'skipped'; + code?: string; + reportBlocks?: any[]; + errorMessage?: string; + retryCount: number; + durationMs?: number; + }> = steps.map(s => ({ + stepOrder: s.order, + method: s.method, + status: 'pending', + retryCount: 0, + })); + const previousResults: StepResultSummary[] = []; + let accumulatedCode = ''; - for (let attempt = 0; attempt <= codeRunnerService.maxRetries; attempt++) { - sendEvent('code_executing', { - executionId: execution.id, - attempt: attempt + 1, - message: attempt === 0 ? '正在执行 R 代码...' : `第 ${attempt + 1} 次重试执行...`, + await (prisma as any).ssaAgentExecution.update({ + where: { id: execution.id }, + data: { seedAudit, currentStep: 1, stepResults: stepResults as any }, + }); + + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + const step = steps[stepIndex]; + const stepOrder = step.order || (stepIndex + 1); + const stepSeed = this.deriveStepSeed(baseSeed, stepOrder); + const deterministicHeader = this.buildDeterministicHeader(stepSeed); + seedAudit.steps.push({ stepOrder, stepSeed }); + let stepCode = ''; + let stepCompleted = false; + + await (prisma as any).ssaAgentExecution.update({ + where: { id: execution.id }, + data: { currentStep: stepOrder, seedAudit }, }); - const execResult = await codeRunnerService.executeCode(sessionId, currentCode); + for (let attempt = 0; attempt <= codeRunnerService.maxRetries; attempt++) { + stepResults[stepIndex].retryCount = attempt; + stepResults[stepIndex].status = 'coding'; + sendEvent('step_coding', { + executionId: execution.id, + stepOrder, + retryCount: attempt, + partialCode: '', + }); + // 兼容旧前端事件 + sendEvent('code_generating', { + executionId: execution.id, + partialCode: '', + }); - if (execResult.success) { - const durationMs = execResult.durationMs || 0; + const gen = await agentCoderService.generateStepCodeStream( + sessionId, + plan, + step, + previousResults, + (partialCode) => { + sendEvent('step_coding', { + executionId: execution.id, + stepOrder, + retryCount: attempt, + partialCode, + }); + // 兼容旧前端事件 + sendEvent('code_generating', { + executionId: execution.id, + partialCode, + }); + }, + attempt > 0 ? lastError || '执行失败' : undefined, + attempt > 0 ? stepCode : undefined, + ); + stepCode = gen.code; + sendEvent('step_code_ready', { + executionId: execution.id, + stepOrder, + code: stepCode, + }); + stepResults[stepIndex].status = 'executing'; + stepResults[stepIndex].code = stepCode; await (prisma as any).ssaAgentExecution.update({ where: { id: execution.id }, data: { - executionResult: execResult as any, - reportBlocks: execResult.reportBlocks as any, - generatedCode: currentCode, - status: 'completed', + stepResults: stepResults as any, + generatedCode: stepCode, retryCount: attempt, - durationMs, }, }); - sendEvent('code_result', { + sendEvent('step_executing', { executionId: execution.id, - reportBlocks: execResult.reportBlocks, - code: currentCode, - durationMs, + stepOrder, + retryCount: attempt, + }); + // 兼容旧前端事件 + sendEvent('code_executing', { + executionId: execution.id, + attempt: attempt + 1, + message: `Step ${stepOrder} 正在执行 R 代码...`, }); - // 固定文本引导语(结果解读应在右侧工作区,不在对话区) - const blockCount = (execResult.reportBlocks || []).length; - const seconds = (durationMs / 1000).toFixed(1); - const hintText = `✅ 分析完成(${seconds}s),共生成 ${blockCount} 个结果模块。👉 请在右侧工作区查看完整结果和图表。`; - await this.sendFixedHint(writer, placeholderMessageId, hintText); + const fullCode = `${deterministicHeader}\n${accumulatedCode}\n${stepCode}`; + const execResult = await codeRunnerService.executeCode(sessionId, fullCode, { + stepOrder, + stepSeed, + baseSeed, + datasetHash, + }); - return { messageId: placeholderMessageId, intent: 'analyze', success: true }; - } + if (execResult.success) { + const durationMs = execResult.durationMs || 0; + const reportBlocks = execResult.reportBlocks || []; + accumulatedCode = `${accumulatedCode}\n${stepCode}`.trim(); + stepResults[stepIndex] = { + ...stepResults[stepIndex], + status: 'completed', + reportBlocks, + durationMs, + }; + previousResults.push({ + stepOrder, + method: step.method, + highlights: this.summarizeStepHighlights(reportBlocks), + }); + sendEvent('step_result', { + executionId: execution.id, + stepOrder, + reportBlocks, + durationMs, + }); + stepCompleted = true; + break; + } - lastError = execResult.error || '执行失败'; - const rawConsole = execResult.consoleOutput; - const consoleArr = Array.isArray(rawConsole) ? rawConsole : (rawConsole ? [String(rawConsole)] : []); - const consoleSnippet = consoleArr.slice(-20).join('\n'); - - if (attempt < codeRunnerService.maxRetries) { - const errorDetail = consoleSnippet - ? `${lastError}\n\n--- R console output (last 20 lines) ---\n${consoleSnippet}` - : lastError; + lastError = execResult.error || '执行失败'; + const errorCode = execResult.errorCode; + const errorType = execResult.errorType; + const errorClass = this.classifyExecutionError(errorCode, lastError); + const isFatal = errorClass === 'fatal'; + const rawConsole = execResult.consoleOutput; + const consoleArr = Array.isArray(rawConsole) ? rawConsole : (rawConsole ? [String(rawConsole)] : []); + const consoleSnippet = consoleArr.slice(-20).join('\n'); + stepResults[stepIndex].status = 'error'; + stepResults[stepIndex].errorMessage = lastError; + await (prisma as any).ssaAgentExecution.update({ + where: { id: execution.id }, + data: { + stepResults: stepResults as any, + errorMessage: lastError, + }, + }); + sendEvent('step_error', { + executionId: execution.id, + stepOrder, + message: lastError, + errorCode, + errorType, + isFatal, + consoleOutput: consoleSnippet || undefined, + willRetry: attempt < codeRunnerService.maxRetries && !isFatal, + retryCount: attempt + 1, + }); + // 兼容旧前端事件 sendEvent('code_error', { executionId: execution.id, message: lastError, consoleOutput: consoleSnippet || undefined, - willRetry: true, + willRetry: attempt < codeRunnerService.maxRetries && !isFatal, retryCount: attempt + 1, }); - sendEvent('code_retry', { - executionId: execution.id, - retryCount: attempt + 1, - message: `第 ${attempt + 1} 次执行失败,Agent 正在重新生成代码...`, - previousError: lastError, - }); + if (isFatal) { + sendEvent('pipeline_aborted', { + executionId: execution.id, + stepOrder, + error: lastError, + errorCode, + }); + await (prisma as any).ssaAgentExecution.update({ + where: { id: execution.id }, + data: { + status: 'error', + errorMessage: lastError, + stepResults: stepResults as any, + seedAudit, + retryCount: attempt, + }, + }); + return { messageId: placeholderMessageId, intent: 'analyze', success: true }; + } - const retry = await agentCoderService.generateCodeStream( - sessionId, - plan, - (accumulated) => { - sendEvent('code_generating', { + if (attempt >= codeRunnerService.maxRetries) { + stepResults[stepIndex].status = 'skipped'; + sendEvent('step_skipped', { + executionId: execution.id, + stepOrder, + reason: `重试 ${codeRunnerService.maxRetries + 1} 次仍失败,跳过该步骤`, + }); + // 关键依赖短路:当前步骤失败后,后续步骤不再生成代码,直接标记跳过 + for (let j = stepIndex + 1; j < steps.length; j++) { + const nextStepOrder = steps[j].order || (j + 1); + stepResults[j].status = 'skipped'; + stepResults[j].errorMessage = `依赖步骤 ${stepOrder} 失败,自动跳过`; + sendEvent('step_skipped', { executionId: execution.id, - partialCode: accumulated, + stepOrder: nextStepOrder, + reason: `依赖步骤 ${stepOrder} 失败,自动跳过`, }); - }, - errorDetail, - currentCode, - ); - currentCode = retry.code; + } + sendEvent('pipeline_aborted', { + executionId: execution.id, + stepOrder, + error: `步骤 ${stepOrder} 重试失败,后续步骤已短路跳过`, + errorCode: errorCode || 'E_DEPENDENCY', + }); + await (prisma as any).ssaAgentExecution.update({ + where: { id: execution.id }, + data: { + stepResults: stepResults as any, + errorMessage: `步骤 ${stepOrder} 重试失败,后续步骤已短路跳过`, + }, + }); + stepCompleted = false; + break; + } + } - await (prisma as any).ssaAgentExecution.update({ - where: { id: execution.id }, - data: { generatedCode: currentCode, retryCount: attempt + 1 }, - }); - - sendEvent('code_generated', { executionId: execution.id, code: currentCode }); + if (!stepCompleted && stepResults[stepIndex].status === 'skipped') { + break; + } + if (!stepCompleted && stepResults[stepIndex].status !== 'skipped') { + break; } } + const allBlocks = stepResults + .filter(s => s.status === 'completed') + .flatMap(s => s.reportBlocks || []); + const totalDuration = stepResults.reduce((sum, s) => sum + (s.durationMs || 0), 0); + const hasCompletedStep = stepResults.some(s => s.status === 'completed'); + const finalStatus = hasCompletedStep ? 'completed' : 'error'; + await (prisma as any).ssaAgentExecution.update({ where: { id: execution.id }, - data: { status: 'error', errorMessage: lastError, retryCount: codeRunnerService.maxRetries }, + data: { + status: finalStatus, + errorMessage: finalStatus === 'error' ? (lastError || '执行失败') : null, + executionResult: { stepResults } as any, + stepResults: stepResults as any, + reportBlocks: allBlocks as any, + generatedCode: accumulatedCode, + currentStep: steps.length, + retryCount: stepResults.reduce((max, s) => Math.max(max, s.retryCount), 0), + durationMs: totalDuration, + seedAudit, + }, }); - sendEvent('code_error', { + sendEvent('code_result', { executionId: execution.id, - message: `经过 ${codeRunnerService.maxRetries + 1} 次尝试仍然失败: ${lastError}`, - willRetry: false, + reportBlocks: allBlocks, + code: accumulatedCode, + durationMs: totalDuration, + stepResults, }); + const blockCount = allBlocks.length; + const seconds = (totalDuration / 1000).toFixed(1); + const hintText = finalStatus === 'completed' + ? `✅ 分析完成(${seconds}s),共生成 ${blockCount} 个结果模块。👉 请在右侧工作区查看完整结果和图表。` + : `⚠️ 分析未完全完成,部分步骤失败或跳过。已输出 ${blockCount} 个可用结果模块,请在右侧工作区查看详情。`; + await this.sendFixedHint(writer, placeholderMessageId, hintText); + return { messageId: placeholderMessageId, intent: 'analyze', success: true }; } + private buildDeterministicHeader(stepSeed: number): string { + return [ + '# --- 系统强制注入:保证累加执行确定性 ---', + `set.seed(${stepSeed})`, + "RNGkind('Mersenne-Twister', 'Inversion', 'Rejection')", + 'options(warn = 1)', + '# --------------------------------------', + ].join('\n'); + } + + private deriveStepSeed(baseSeed: number, stepOrder: number): number { + return ((baseSeed + stepOrder * 9973) % 2147483647) || 42; + } + + private deriveStableSeed(input: string): number { + let hash = 2166136261; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return Math.abs(hash >>> 0) % 2147483647 || 42; + } + + private classifyExecutionError(errorCode?: string, errorMessage?: string): 'fatal' | 'retriable' { + const FATAL_CODES = new Set(['E_OOM', 'E_TIMEOUT', 'E005', 'E_SECURITY']); + if (errorCode && FATAL_CODES.has(errorCode)) return 'fatal'; + + const msg = (errorMessage || '').toLowerCase(); + const fatalKeywords = ['cannot allocate vector', 'out of memory', 'killed', 'security violation', 'timed out']; + if (fatalKeywords.some(k => msg.includes(k))) return 'fatal'; + return 'retriable'; + } + + private summarizeStepHighlights(reportBlocks: any[]): string { + if (!Array.isArray(reportBlocks) || reportBlocks.length === 0) { + return '无可用输出'; + } + const first = reportBlocks.find(b => b?.title || b?.content) || reportBlocks[0]; + const title = first?.title ? String(first.title) : ''; + const content = first?.content ? String(first.content).replace(/\s+/g, ' ').slice(0, 120) : ''; + return [title, content].filter(Boolean).join(' - ').slice(0, 180) || '步骤完成'; + } + + private async getSessionDatasetHash(sessionId: string): Promise { + const session = await prisma.ssaSession.findUnique({ + where: { id: sessionId }, + select: { dataOssKey: true }, + }); + const source = session?.dataOssKey || 'no_data_key'; + return this.deriveStableSeed(source).toString(); + } + // ── Agent 取消 ── private async agentCancel( diff --git a/backend/src/modules/ssa/services/CodeRunnerService.ts b/backend/src/modules/ssa/services/CodeRunnerService.ts index 5981ddb6..dedaa903 100644 --- a/backend/src/modules/ssa/services/CodeRunnerService.ts +++ b/backend/src/modules/ssa/services/CodeRunnerService.ts @@ -29,6 +29,8 @@ export interface CodeExecutionResult { consoleOutput?: string[]; durationMs?: number; error?: string; + errorCode?: string; + errorType?: string; } export class CodeRunnerService { @@ -49,6 +51,12 @@ export class CodeRunnerService { async executeCode( sessionId: string, code: string, + metadata?: { + stepOrder?: number; + stepSeed?: number; + baseSeed?: number; + datasetHash?: string; + }, ): Promise { const startTime = Date.now(); @@ -59,6 +67,7 @@ export class CodeRunnerService { code: this.wrapCode(code, dataSource), session_id: sessionId, timeout: 120, + metadata, }; logger.info('[CodeRunner] Executing R code', { @@ -88,15 +97,21 @@ export class CodeRunnerService { } const errorMsg = response.data?.message || response.data?.user_hint || 'R 执行返回非成功状态'; + const errorCode = response.data?.error_code; + const errorType = response.data?.error_type; logger.warn('[CodeRunner] Execution failed (R returned error)', { sessionId, durationMs, error: errorMsg, + errorCode, + errorType, }); return { success: false, error: errorMsg, + errorCode, + errorType, consoleOutput: response.data?.console_output, durationMs, }; @@ -108,6 +123,8 @@ export class CodeRunnerService { return { success: false, error: 'R 统计服务超时或崩溃,请检查代码是否有死循环或内存溢出', + errorCode: 'E_TIMEOUT', + errorType: 'runtime', durationMs, }; } @@ -126,6 +143,8 @@ export class CodeRunnerService { return { success: false, error: errorMsg, + errorCode: error.response?.data?.error_code, + errorType: error.response?.data?.error_type, durationMs, }; } @@ -141,6 +160,7 @@ export class CodeRunnerService { */ private wrapCode(userCode: string, dataSource: { type: string; oss_url?: string }): string { const escapedUrl = (dataSource.oss_url || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const sanitizedUserCode = this.sanitizeUserCode(userCode); return ` # === 自动注入:数据加载 === input <- list( @@ -154,12 +174,60 @@ input <- list( df <- load_input_data(input) message(paste0("[Agent] Data loaded: ", nrow(df), " rows x ", ncol(df), " cols")) +# === pROC 兼容兜底(运行环境未安装 pROC 时启用)=== +if (!requireNamespace("pROC", quietly = TRUE)) { + roc <- function(response, predictor, ...) { + resp <- as.numeric(response) + pred <- as.numeric(predictor) + keep <- is.finite(resp) & is.finite(pred) + resp <- resp[keep] + pred <- pred[keep] + if (length(resp) < 2 || length(unique(resp)) < 2) { + stop("ROC requires binary response with at least 2 classes") + } + positive <- max(resp, na.rm = TRUE) + y <- ifelse(resp == positive, 1, 0) + ord <- order(pred, decreasing = TRUE) + y <- y[ord] + tp <- cumsum(y == 1) + fp <- cumsum(y == 0) + P <- sum(y == 1) + N <- sum(y == 0) + tpr <- if (P > 0) tp / P else rep(0, length(tp)) + fpr <- if (N > 0) fp / N else rep(0, length(fp)) + x <- c(0, fpr, 1) + yv <- c(0, tpr, 1) + auc_val <- sum((x[-1] - x[-length(x)]) * (yv[-1] + yv[-length(yv)]) / 2, na.rm = TRUE) + structure(list(auc = auc_val, sensitivities = tpr, specificities = 1 - fpr), class = "simple_roc") + } + auc <- function(roc_obj, ...) { + if (!is.null(roc_obj$auc)) return(as.numeric(roc_obj$auc)) + return(NA_real_) + } +} + # === 用户代码开始 === -${userCode} +${sanitizedUserCode} # === 用户代码结束 === `.trim(); } + private sanitizeUserCode(userCode: string): string { + let code = userCode.replace(/\r\n/g, '\n'); + + // 1) 未安装包 pROC 的兼容处理(保留执行而不是直接失败) + code = code.replace(/^\s*library\(\s*pROC\s*\)\s*$/gmi, '# removed: library(pROC) (not installed in runtime)'); + code = code.replace(/\bpROC::roc\s*\(/g, 'roc('); + code = code.replace(/\bpROC::auc\s*\(/g, 'auc('); + + // 2) 常见语法断裂修复:"... ) if (...)" -> 换行 + code = code.replace(/\)\s+if\s*\(/g, ')\nif ('); + // R 语言要求 "} else" 通常同一行,换行会导致 unexpected 'else' + code = code.replace(/\}\s+else\b/g, '} else'); + + return code; + } + /** * 构建数据源(从 session 读取 OSS key → 预签名 URL) */ diff --git a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md index a71de2b6..7aad3047 100644 --- a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md @@ -1,13 +1,20 @@ # SSA智能统计分析模块 - 当前状态与开发指南 -> **文档版本:** v4.2 +> **文档版本:** v4.3 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-03-08 +> **最后更新:** 2026-03-11 > **维护者:** 开发团队 -> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 + Agent 通道体验优化 + Agent Prompt 运营管理化完成** +> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 + Agent Prompt 运营管理化 + Phase 5A/5A.5/5B/5C 联调完成(严格分步写+分步执行)** > **文档目的:** 快速了解SSA模块状态,为新AI助手提供上下文 > -> **最新进展(2026-03-08 Agent 核心 Prompt 接入运营管理端):** +> **最新进展(2026-03-11 Agent 分步执行主链落地):** +> - ✅ **严格分步模式切换** — `confirm_plan` 不再生成整段 R 代码,仅进入执行确认;`confirm_code` 后按步骤逐步生成与执行 +> - ✅ **依赖失败短路** — 当前步骤重试失败后,后续步骤直接标记 `skipped`,不再继续写代码与执行 +> - ✅ **步骤结果可视化增强** — 右侧工作区在分步状态可展开查看每步 `reportBlocks`,便于排障与审计 +> - ✅ **失败原因可追溯** — `stepResults.errorMessage` 落库并可回显,定位链路稳定 +> - ✅ **R 语法修复器纠偏** — 修正 `} else` 处理策略,降低 `unexpected 'else'` 误报 +> +> **此前进展(2026-03-08 Agent 核心 Prompt 接入运营管理端):** > - ✅ **PlannerAgent Prompt 动态化** — `AgentPlannerService.buildSystemPrompt()` 改为 `PromptService.get('SSA_AGENT_PLANNER', { dataContext })`,支持运营管理端在线编辑、灰度预览、版本管理 > - ✅ **CoderAgent Prompt 动态化** — `AgentCoderService.buildSystemPrompt()` 改为 `PromptService.get('SSA_AGENT_CODER', { dataContext })`,同上 > - ✅ **三级容灾** — 数据库 ACTIVE 版本 → 内存缓存(5 分钟) → 代码 fallback(`prompt.fallbacks.ts`),任何一层失败自动降级 @@ -88,12 +95,12 @@ | 项目 | 信息 | |------|------| | **模块名称** | SSA - 智能统计分析 (Smart Statistical Analysis) | -| **模块定位** | AI驱动的"白盒"统计分析系统 → 升级为"数据感知的统计顾问" | +| **模块定位** | AI驱动的"白盒"统计分析系统 → 升级为"数据感知的统计顾问"(Agent 严格分步执行) | | **架构模式** | **双通道:QPER 管线(预制工具)+ LLM Agent 通道(代码生成)** + **四层七工具 + 对话层 LLM** | | **前端状态模型** | **Unified Record Architecture — 一次分析 = 一个 Record = N 个 Steps** | | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **目标用户** | 临床研究人员、生物统计师 | -| **开发状态** | 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 + Agent 体验优化完成** | +| **开发状态** | 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 + Agent 体验优化 + Phase 5A/5A.5/5B/5C 联调完成** | ### 核心目标 @@ -203,8 +210,8 @@ AnalysisRecord { | **Plan-and-Execute 设计** | **分步执行架构设计(代码累加 + 工程护栏)** | **~4h** | ✅ **已完成(架构评审 + 三份评估报告)** | 2026-03-07 | | **Phase 5A** | **CoderAgent 防错护栏(XML 标签 + AST 预检 + 防御性 Prompt + 高保真 Schema)** | **~6h** | ✅ **已完成** | 2026-03-08 | | **Agent Prompt 管理化** | **PlannerAgent + CoderAgent Prompt 接入运营管理端(PromptService 三级容灾)** | **~2h** | ✅ **已完成(种子脚本 + fallback + 文档)** | 2026-03-08 | -| **Phase 5B** | **后端分步执行引擎(DB schema + 代码累加循环 + 错误分类短路 + 新 SSE 事件)** | **~10h** | 📋 待开始 | - | -| **Phase 5C** | **前端分步展示(类型扩展 + AgentCodePanel 多步骤 UI + SSE 处理器)** | **~6h** | 📋 待开始 | - | +| **Phase 5B** | **后端分步执行引擎(确定性种子 + 分步生成执行 + 错误分类短路 + step_* 事件)** | **~10h** | ✅ **已完成(严格分步主链)** | 2026-03-11 | +| **Phase 5C** | **前端分步展示(类型扩展 + AgentCodePanel 多步骤 UI + SSE 处理器)** | **~6h** | ✅ **已完成(步骤状态与结果可视化)** | 2026-03-11 | | **Phase V-B** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - | | **Phase VI** | **集成测试 + 可观测性** | **10h** | 📋 待开始 | - | @@ -229,7 +236,7 @@ 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 导出修复 | ✅ | -| **双通道 Agent 通道** | PlannerAgent(意图→分析计划)+ CoderAgent(计划→R 代码,含流式生成)+ CodeRunnerService(沙箱执行)+ AgentCodePanel(三步确认 UI)+ ModeToggle(通道切换)+ R Docker /execute-code 端点 | ✅ | +| **双通道 Agent 通道** | PlannerAgent(意图→分析计划)+ CoderAgent(按步骤生成 R 代码,执行阶段逐步生成)+ CodeRunnerService(沙箱执行)+ AgentCodePanel(三步确认 UI)+ ModeToggle(通道切换)+ R Docker /execute-code 端点 | ✅ | | **Agent 体验优化** | 方案 B 左右职责分离(视线牵引+状态互斥+历史穿梭)+ JWT 刷新 + 代码截断修复 + 重试流式生成 + R Docker 结构化错误(20+ 模式)+ Prompt 铁律 + parseCode 健壮化 + consoleOutput 类型防御 + 进度条同步 + 导出/查看代码恢复 + ExecutingProgress 动态 UI | ✅ | | **Agent Prompt 管理化** | PlannerAgent + CoderAgent System Prompt 从硬编码迁移至 PromptService 动态加载;运营管理端在线编辑/灰度预览/版本回滚;三级容灾(DB→缓存→fallback);种子脚本 `seed-ssa-agent-prompts.ts` 幂等 | ✅ | | **测试** | 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 通过 + Agent 体验测试通过(统计分析结果+图表正常) | ✅ | @@ -401,9 +408,11 @@ npx tsx prisma/seed-ssa-agent-prompts.ts # Agent: SSA_AGENT_PLANNER + SSA ``` 用户消息 → ChatHandlerService.handleAgentMode() - → AgentPlannerService.generatePlan() ← SSA_AGENT_PLANNER - → AgentCoderService.generateCodeStream() ← SSA_AGENT_CODER - → CodeRunnerService.executeCode() ← 纯 R 执行,无 Prompt + → AgentPlannerService.generatePlan() ← SSA_AGENT_PLANNER + → confirm_plan: enter code_pending only ← 不提前生成整段代码 + → confirm_code: for each step + → AgentCoderService.generateStepCodeStream() ← SSA_AGENT_CODER + → CodeRunnerService.executeCode() ← 纯 R 执行,无 Prompt ``` --- @@ -429,22 +438,18 @@ npx tsx prisma/seed-ssa-agent-prompts.ts # Agent: SSA_AGENT_PLANNER + SSA ### 近期(优先级高) -1. **Phase 5A — CoderAgent 防错护栏** - - XML 标签提取:强制 `...` 标签 + `parseCode()` 严格正则 - - 防御性 Prompt:NA 处理 / 类型转换 / 因子水平检查 / tryCatch 规则注入 - - 高保真 Schema 注入:`buildDataContext()` 增加列类型 + 前 3 条样本值 - - R Docker AST 预检:`parse()` 语法检查在 `eval()` 之前 +1. **稳定性回归与压测** + - 严格分步主链:计划确认不生成代码、执行阶段逐步生成与执行 + - 依赖短路:上游失败时后续步骤必须 `skipped` + - DB 回显:`stepResults/errorMessage/seedAudit` 全链路可追溯 -2. **Phase 5B — 后端分步执行引擎** - - DB: `SsaAgentExecution` 新增 `stepResults: Json[]` + `currentStep: Int?` - - 代码累加执行循环(R Docker 保持无状态,每步累加前序成功代码) - - 错误分类短路(Fatal→硬停 / Retriable→重试 MAX 2 / Soft→跳过) - - 新 SSE 事件:`step_coding / step_code_ready / step_executing / step_result / step_error / step_skipped / pipeline_aborted` +2. **Phase V-B — 反思编排 + 高级特性** + - 完成分步结果汇总与反思层输出增强 + - 细化失败后的人类可读修复建议 -3. **Phase 5C — 前端分步展示** - - 类型扩展:`AgentExecutionRecord` 增加 `stepResults[]` + `currentStep` - - AgentCodePanel 多步骤 UI(可折叠步骤卡片 + 状态/代码/结果/错误) - - SSE 处理器适配新步骤级事件 +3. **Phase VI — 集成测试 + 可观测性** + - 完善 step 级日志、指标和告警 + - 联调验证清单标准化 ### 中期 @@ -497,7 +502,7 @@ npx tsx prisma/seed-ssa-agent-prompts.ts # Agent: SSA_AGENT_PLANNER + SSA --- -**文档版本:** v4.2 -**最后更新:** 2026-03-08 -**当前状态:** 🎉 SSA Agent 模式 MVP 完成(QPER 闭环 + Phase I-IV + Phase V-A + 双通道架构 + Agent 体验优化 + Prompt 运营管理化 + Phase 5A 护栏) -**下一步:** Phase 5B(分步执行引擎)→ Phase 5C(前端分步展示)→ Phase V-B(反思编排) +**文档版本:** v4.3 +**最后更新:** 2026-03-11 +**当前状态:** 🎉 SSA Agent 模式已进入严格分步执行(QPER 闭环 + Phase I-IV + Phase V-A + Prompt 运营管理化 + Phase 5A/5A.5/5B/5C) +**下一步:** 稳定性回归与压测 → Phase V-B(反思编排)→ Phase VI(可观测性) diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/12-Plan-and-Execute分步执行架构开发计划.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/12-Plan-and-Execute分步执行架构开发计划.md new file mode 100644 index 00000000..735edfe4 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/12-Plan-and-Execute分步执行架构开发计划.md @@ -0,0 +1,623 @@ +# Plan-and-Execute 分步执行架构开发计划 + +> 来源:`C:\Users\zhibo\.cursor\plans\plan-and-execute_architecture_0895bce2.plan.md` +> 归档日期:2026-03-11 +> 归档说明:先完成“一步模式稳定化”,再灰度切入分步执行(与你当前共识一致) + +--- +name: Plan-and-Execute Architecture +overview: 将 Agent 通道从"一次性生成完整 R 脚本"改造为"分步生成、分步执行"架构。采用代码累加法(零改动 R Docker),配合 XML 标签提取、AST+安全预检、防御性 prompt、错误分类短路、确定性执行头注入等工程护栏,每步 50-80 行代码独立生成和执行。当前版本仅交付最小分步主链(Phase 5A/5A.5/5B/5C);单步重跑与级联重跑(Phase 5D)延期到主链稳定后再启用。PlannerAgent 决定步骤数:简单分析 1 步(等价一次性),复杂分析 3-5 步。 +todos: + - id: p5a + content: "Phase 5A: CoderAgent 防错护栏 — XML 标签提取 + AST 预检 + 防御性 prompt + 高保真 Schema 注入" + status: pending + - id: p5a5 + content: "Phase 5A.5: 变量确认与可编辑交互层 — 复用 QPER 变量编辑能力(单变量/多变量)到 Agent 计划确认阶段" + status: pending + - id: p5b + content: "Phase 5B: 后端分步执行引擎 — DB schema + 代码累加执行循环 + 按步生成 + 错误分类短路 + 新 SSE 事件" + status: pending + - id: p5c + content: "Phase 5C: 前端分步展示 — 类型扩展 + AgentCodePanel 多步骤 UI + SSE 处理器" + status: pending + - id: p5d + content: "Phase 5D: 单步重跑(延期)— 医生修改指令 + agentRerunStep + 级联重跑后续步骤 + 审计轨迹(主链稳定两周后再启动)" + status: cancelled +isProject: false +--- + +## Plan-and-Execute 分步执行架构 (v3 - 场景增强版) + +## 现状与核心痛点 + +当前 Agent 管线是"一锅炖"模式:Planner 生成 4 步计划 -> Coder 一次生成 300 行 R -> Runner 一次执行全部 -> 全成功 or 全失败。 + +**致命缺陷(因果悖论)**:Step 3(多因素回归)需要 Step 2(单因素分析)的实际 P 值来决定纳入变量。LLM 在一次性写代码时不知道 P 值,只能写复杂的动态元编程,崩溃率 95%+。分步执行后,LLM 看到真实 P 值,可直接写死 `glm(Yqol ~ age + smoke + ...)`,成功率接近 100%。 + +## 编排模型:后端驱动,CoderAgent 被动生成 + +**核心原则:CoderAgent 不控制流程,不知道循环的存在。** + +```text +ChatHandlerService(编排层) CoderAgent(代码生成器) R Docker(执行器) + │ │ │ + │ "请为 Step 1 写代码" │ │ + │ + dataSchema │ │ + │ + step.description │ │ + ├──────────────────────────────► │ │ + │ │ 返回 ... │ + │ ◄──────────────────────────────┤ │ + │ │ + │ accumulatedCode + stepCode │ + ├────────────────────────────────────────────────────────────►│ + │ result │ + │ ◄──────────────────────────────────────────────────────────┤ + │ │ + │ "请为 Step 2 写代码" │ │ + │ + step1 结果摘要 │ │ + ├──────────────────────────────► │ │ + │ ...继续循环... │ │ +``` + +- **编排层(ChatHandlerService)** 拥有全局视角:plan、步骤列表、累积代码、前序结果 +- **CoderAgent** 每次只看到"当前步骤描述 + 前序结果摘要 + 数据 Schema",输出当前步骤的 R 代码 +- **CoderAgent 不调用任何工具**,不决定是否重试/跳过/终止,这些全由编排层根据错误分类判断 +- 用户点击"修改此步骤"时,请求发送到编排层,编排层调用 CoderAgent 重新生成该步代码 + +## 统一架构:PlannerAgent 决定步骤数 + +**简单分析和复杂分析共用同一条代码路径**,区别仅在于 PlannerAgent 生成的步骤数不同: + +| 用户请求 | PlannerAgent 决策 | 执行循环次数 | 等价于 | +| --- | --- | --- | --- | +| "比较两组血压" | 1 步:独立样本 T 检验 | 1 次 | 一次性执行 | +| "描述统计 + 组间比较" | 2 步 | 2 次 | 轻量分步 | +| "单因素→多因素→敏感性" | 4 步 | 4 次 | 完整分步 | + +PlannerAgent 的步骤拆分规则(写入 System Prompt): + +- 如果只涉及一种统计方法,合并为 1 步 +- 如果涉及多种方法但彼此独立,可合并为 1-2 步 +- **只有当后续步骤需要前序步骤的运行时结果(因果依赖)时,必须拆为独立步骤** +- 步骤数建议:简单分析 1 步,标准分析 2-3 步,复杂分析 3-5 步 + +## 目标架构 + +```mermaid +flowchart TB + subgraph planPhase [Phase 1: Plan] + Planner["PlannerAgent\n生成 N 步计划"] + end + subgraph execPhase [Phase 2: Step-by-Step Execute] + S1_Code["Step 1: Coder 生成代码\n50-80 行"] --> S1_Run["R 执行\n代码累加法"] + S1_Run --> S1_Result["展示结果\n+ 传递给下一步"] + S1_Result --> S2_Code["Step 2: Coder 生成代码\n参考 Step 1 结果"] + S2_Code --> S2_Run["R 执行\nCode_A + Code_B"] + S2_Run --> S2_Result["展示结果"] + S2_Result --> SN["Step N: ..."] + end + subgraph guards [工程护栏] + XMLExtract["XML 标签提取"] + ASTCheck["AST 语法预检"] + ErrorClass["错误分类短路"] + DefPrompt["防御性 Prompt"] + end + Planner --> S1_Code + guards -.->|"每步都经过"| S1_Code + guards -.->|"每步都经过"| S2_Code + SN --> Summary["LLM 综合总结"] +``` + +**关键设计决策**: + +- **代码累加法(零改动 R Docker)**:每步执行时,将所有前序步骤代码 + 当前步骤代码拼接后一次性发给 R Docker。R Docker 保持无状态,无需 session 池。 +- **不引入独立 Fixer Agent**:CoderAgent 内置重试 prompt 模板(上下文重置模式),分步执行后每步只有 50-80 行代码,重新生成的成功率本身就很高。 +- 对于 <5000 行的医学数据集,重跑前序步骤 <1 秒,用户无感知。 + +## 分三个子阶段实施 + +### Phase 5A: CoderAgent 防错护栏 (~2h) + +**目标**:从 Prompt、代码提取、语法检查三层大幅提升首次生成成功率。 + +**改动文件**: + +- `backend/src/modules/ssa/services/AgentCoderService.ts` — Prompt + parseCode +- `r-statistics-service/plumber.R` — AST 预检 +- `backend/src/modules/ssa/services/SessionBlackboardService.ts` — Schema 增强 + +**1) XML 标签提取(替代 Markdown 代码块)** + +System Prompt 改为要求 `...` 标签包裹代码: + +```text +你必须且只能将 R 代码放在 标签之间。 +标签外面禁止出现任何代码。标签里面禁止出现任何自然语言解释。 +``` + +`parseCode()` 方法改为正则提取 `` 内容,fallback 到 markdown 代码块: + +```typescript +const xmlMatch = content.match(/([\s\S]*?)<\/r_code>/); +const mdMatch = content.match(/```r\s*([\s\S]*?)```/); +const code = xmlMatch?.[1]?.trim() || mdMatch?.[1]?.trim(); +if (!code || code.length < 20) throw new Error('未找到有效 R 代码'); +``` + +**2) 防御性编程 Prompt 注入** + +在 System Prompt 的"R 代码规范"中新增防御规则: + +```text +## 防御性编程规则(铁律) +1. 模型计算前,强制剔除涉及变量的 NA 值 +2. 分组变量强制转 as.factor(),数值变量强制转 as.numeric() +3. 回归前检查因子水平数,只有 1 个水平的变量直接跳过 +4. 所有统计检验用 tryCatch 包裹,失败时返回 NA 而非崩溃 +5. 禁止假设数据完美,永远做类型和缺失值检查 +``` + +**3) 高保真 Schema 注入** + +`buildDataContext()` 增强:除列名和类型外,注入每列的前 3 行样本值: + +```text +变量名: age, 类型: numeric, 样本: [45, 67, 32] +变量名: sex, 类型: categorical, 样本: [1, 2, 1], 水平: [1, 2] +变量名: Yqol, 类型: categorical, 样本: [0, 1, 1], 水平: [0, 1] +``` + +**4) R Docker AST + 安全预检(语法 + 危险调用拦截)** + +在 `execute-code` 端点中,`eval()` 之前增加双层预检: + +```r +tryCatch({ + parsed_code <- parse(text = input$code) + + # Layer A: 静态安全扫描(MVP) + forbidden_pattern <- "(^|[^[:alnum:]_])(system|eval|parse|source|file\\.remove|unlink|setwd|download\\.file|readLines|writeLines)\\s*\\(" + if (grepl(forbidden_pattern, input$code, perl = TRUE, ignore.case = TRUE)) { + stop("Security Violation: Detected forbidden function calls.") + } +}, error = function(e) { + return(list( + status = "error", + error_code = if (grepl("Security Violation", e$message, fixed = TRUE)) "E_SECURITY" else "E_SYNTAX", + message = paste0("R 代码预检失败: ", e$message), + user_hint = "代码存在语法或安全风险(危险函数调用),请修复后重试" + )) +}) + +# Layer B: 运行时保护(在 sandbox_env 中覆盖高风险函数) +sandbox_env$system <- function(...) stop("Security Violation: function 'system' is forbidden.") +sandbox_env$eval <- function(...) stop("Security Violation: function 'eval' is forbidden.") +sandbox_env$source <- function(...) stop("Security Violation: function 'source' is forbidden.") +sandbox_env$unlink <- function(...) stop("Security Violation: function 'unlink' is forbidden.") +sandbox_env$file.remove <- function(...) stop("Security Violation: function 'file.remove' is forbidden.") +sandbox_env$setwd <- function(...) stop("Security Violation: function 'setwd' is forbidden.") + +# 语法通过后才执行 +eval(parsed_code, envir = sandbox_env) +``` + +### Phase 5A.5: 变量确认与可编辑交互层(复用 QPER,~3h) + +**目标**:在 Agent 计划确认阶段,系统自动填入每步变量参数,并允许医生修改后再生成代码。 +**原则**:**复用已有 QPER 能力,不重写。** + +**为什么必须做**: + +- 如果不让用户在计划阶段改变量,CoderAgent 只能写大量“兜底判断代码”,复杂度和失败率都会升高。 +- 变量先确认后编码,可把代码生成约束成“确定输入 -> 直接执行”,显著降低异常分支。 + +**复用资产(已在 QPER 跑通)**: + +- 前端可编辑控件:`SingleVarSelect`、`MultiVarTags` +- 约束规则:`backend/src/modules/ssa/config/tool_param_constraints.json` +- 失配检测:`detectPlanMismatches` +- 后端参数更新 API:`PATCH /api/v1/ssa/workflow/:workflowId/params`(结构校验 + 变量存在性校验) + +**改造策略(最小改动)**: + +1. **前端复用,不重复实现** + - 将 `WorkflowTimeline` 中的变量编辑子能力抽离为可复用组件(建议迁移到 `components/param-editors/`)。 + - `AgentCodePanel` 在 `plan_pending` 阶段渲染“步骤变量编辑区”,交互行为与 QPER 一致。 + +2. **后端新增 Agent 参数更新端点(而非复用 workflow PATCH)** + - 因 Agent 没有 `workflowId`/`ssa_workflow_steps`,新增: + - `PATCH /api/v1/ssa/agent-executions/:executionId/plan-params` + - 在 `ssa_agent_executions.review_result.steps[].params` 内更新参数。 + - 参数约束复用 `tool_param_constraints.json`,避免双份规则漂移。 + +3. **执行前强校验(三层)** + - Layer 1: 前端黄条提醒(类型/水平不匹配) + - Layer 2: 后端 PATCH 校验(结构、变量存在) + - Layer 3: 点击“确认计划”时阻断弹窗(告知可能失败,允许强行继续) + +4. **编码输入确定化** + - `agentStreamCode` 读取“用户已确认后的 steps.params”作为唯一输入。 + - CoderAgent Prompt 明确:按已确认变量写代码,不要再自动发散变量选择。 + +**验收标准**: + +- Agent 计划生成后,步骤中变量默认自动填入。 +- 医生可修改单变量/多变量并保存,右侧实时更新。 +- 修改后的参数会进入后端持久化(execution.reviewResult)并参与后续代码生成。 +- 用户可在不改计划结构的情况下,仅通过改变量降低执行失败率。 +- 不新增第二套变量编辑逻辑(QPER 与 Agent 共用同一套约束与交互组件)。 + +### Phase 5B: 后端分步执行引擎(含确定性保障,~4h) + +**目标**:`agentExecuteCode` 从"一次执行全部"改为"逐步生成+逐步执行"循环,采用代码累加法,并强制保证重跑确定性。 + +**改动文件**: + +- `backend/src/modules/ssa/services/ChatHandlerService.ts` — 核心执行循环 +- `backend/src/modules/ssa/services/AgentCoderService.ts` — 按步骤生成代码 +- `backend/src/modules/ssa/services/CodeRunnerService.ts` — 代码累加包装 +- `backend/prisma/schema.prisma` — 步骤级存储 + +**1) DB Schema 扩展** + +`SsaAgentExecution` 新增两个字段: + +```prisma +model SsaAgentExecution { + // ... 现有字段 ... + stepResults Json? @map("step_results") // Array + currentStep Int? @map("current_step") +} +``` + +`AgentStepResult` 结构: + +```typescript +interface AgentStepResult { + stepOrder: number; + method: string; + status: 'pending' | 'coding' | 'executing' | 'completed' | 'error' | 'skipped'; + code?: string; + reportBlocks?: ReportBlock[]; + errorMessage?: string; + retryCount: number; + durationMs?: number; +} +``` + +**2) 代码累加执行循环** (`agentExecuteCode` 重构) + +```text +accumulatedCode = "" // 累积的成功代码 +previousResults = [] // 前序步骤摘要(供 CoderAgent 参考) + +for each step in plan.steps: + 1. CoderAgent.generateStepCodeStream(plan, step, previousResults) + → SSE: step_coding { stepOrder, partialCode } + → SSE: step_code_ready { stepOrder, code } + + 2. fullCode = deterministicHeader + accumulatedCode + "\n" + stepCode + CodeRunner.executeCode(sessionId, fullCode) + → SSE: step_executing { stepOrder } + + 3. if success: + accumulatedCode = fullCode // 累积成功代码 + previousResults.push(stepResultSummary) + → SSE: step_result { stepOrder, reportBlocks } + + 4. if error: + → 错误分类判断: + - Fatal (singular matrix / OOM): 硬阻断, SSE: pipeline_aborted + - Fixable (object not found / syntax): 重试该步骤 (MAX 2 次) + - 重试仍失败: 标记 skipped, 继续下一步 + → SSE: step_error { stepOrder, error, willRetry, isFatal } + +全部步骤完成后 → LLM 综合总结 +``` + +**2.1) 确定性执行头(P0,必须)** + +在 Node.js 拼接 `fullCode` 时,必须注入确定性头,避免“Step 1 重跑导致 Step 2 输入漂移”的隐性污染: + +```typescript +const baseSeed = deriveStableSeed({ + sessionId, + datasetHash, // 数据快照哈希 + executionId, // 本次执行 ID +}); +const stepSeed = (baseSeed + step.order) % 2147483647; + +const deterministicHeader = [ + "# --- 系统强制注入:保证累加执行确定性 ---", + `set.seed(${stepSeed})`, + "RNGkind('Mersenne-Twister', 'Inversion', 'Rejection')", + "options(warn = 1)", + "# --------------------------------------", + "" +].join("\n"); + +const fullCode = deterministicHeader + accumulatedCode + "\n" + stepCode; +``` + +约束: +- 不允许硬编码固定种子(如全局 `42`)作为唯一策略; +- 必须记录 `baseSeed/stepSeed/datasetHash` 到执行审计字段,保证结果可追溯。 + +**3) CoderAgent 按步骤生成** — 新增 `buildStepMessage` 方法 + +```typescript +private buildStepMessage( + plan: AgentPlan, + step: PlanStep, + previousResults: StepResultSummary[], +): string { + // 传入: 当前步骤描述 + 前序步骤的关键发现 + // 例如: "Step 2 单因素分析发现 age(P=0.03), smoke(P=0.08) 显著" + // 关键 prompt: "R 环境中已有 df。之前步骤的代码已执行,变量可直接使用。" + // 要求: 只生成当前步骤的代码,以 标签包裹 +} +``` + +**4) 错误分类短路机制** + +在 `CodeRunnerService` 返回错误后,后端根据 `error_code` 判断: + +```typescript +const FATAL_ERRORS = ['E005', 'E_OOM', 'E_TIMEOUT']; +const RETRIABLE_ERRORS = ['E001', 'E002', 'E_EXEC', 'E_SYNTAX', 'E100']; + +function classifyError(errorCode: string): 'fatal' | 'retriable' { + if (FATAL_ERRORS.includes(errorCode)) return 'fatal'; + return 'retriable'; +} +``` + +Fatal 错误直接中断管线,不浪费 Token 重试。 + +**5) 重试时的上下文重置** + +重试不 append 到长对话,而是构造干净的 3 元素输入: + +```typescript +private buildStepRetryMessage( + step: PlanStep, + failedCode: string, + errorDetail: string, + dataSchema: string, +): string { + return `当前步骤的代码执行失败。 + +${failedCode} +${errorDetail} +${dataSchema} + +请先分析错误的根本原因,然后输出修复后的完整代码(用 标签包裹)。`; +} +``` + +**6) 新增 SSE 事件类型** + +- `step_coding` — 步骤 N 代码流式生成中 `{ stepOrder, partialCode }` +- `step_code_ready` — 步骤 N 代码生成完成 `{ stepOrder, code }` +- `step_executing` — 步骤 N 正在执行 `{ stepOrder }` +- `step_result` — 步骤 N 执行成功 `{ stepOrder, reportBlocks, durationMs }` +- `step_error` — 步骤 N 执行失败 `{ stepOrder, error, willRetry, isFatal }` +- `step_skipped` — 步骤 N 被跳过 `{ stepOrder, reason }` +- `pipeline_aborted` — 管线因致命错误终止 `{ stepOrder, error }` + +### Phase 5C: 前端分步展示 (~4h) + +**目标**:`AgentCodePanel` 变为多步骤视图,每步独立展示代码、状态、结果。 + +**改动文件**: + +- `frontend-v2/src/modules/ssa/types/index.ts` — 类型扩展 +- `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts` — 新 SSE 事件处理 +- `frontend-v2/src/modules/ssa/stores/ssaStore.ts` — 步骤级状态 +- `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx` — 多步骤 UI + +**1) 类型扩展** + +```typescript +type StepStatus = 'pending' | 'coding' | 'executing' | 'completed' | 'error' | 'skipped'; + +interface AgentStepResult { + stepOrder: number; + method: string; + status: StepStatus; + code?: string; + partialCode?: string; + reportBlocks?: ReportBlock[]; + errorMessage?: string; + retryCount: number; + durationMs?: number; +} + +interface AgentExecutionRecord { + // ... 现有字段保留 ... + stepResults?: AgentStepResult[]; + currentStep?: number; +} +``` + +**2) AgentCodePanel 多步骤 UI** + +```text ++------------------------------------------+ +| Agent 分析流水线 [完成 3/4] | ++------------------------------------------+ +| [计划] 4 个分析步骤 已确认 | ++------------------------------------------+ +| Step 1: 描述性统计 completed 2.1s | +| [可折叠] 代码 / DynamicReport | ++------------------------------------------+ +| Step 2: 单因素分析 completed 5.3s | +| [可折叠] 代码 / DynamicReport | ++------------------------------------------+ +| Step 3: 多因素回归 executing 12s | +| [展开] 流式代码 + 计时器 | ++------------------------------------------+ +| Step 4: 敏感性分析 pending | ++------------------------------------------+ +``` + +- 每个步骤可折叠/展开(当前步骤默认展开,已完成步骤默认折叠) +- 已完成步骤展示 `DynamicReport`(表格、图表),可展开查看代码 +- 正在执行步骤展示流式代码 + 计时器 +- 失败步骤展示错误详情 + "重试此步骤" 按钮 +- 跳过步骤灰色显示 + 原因说明 +- 进度指示器:`[完成 3/4]` 或 `[Step 3/4 执行中]` + +**3) SSE 事件处理** + +在 `useSSAChat.ts` 中为每种 step_* 事件添加处理器,更新 `stepResults[]` 数组中对应 `stepOrder` 的状态。Store 中新增 `updateStepResult(stepOrder, patch)` action。 + +**4) 导出报告和查看代码** + +导出报告:累积所有步骤的 `reportBlocks` 合并为一个文档。 +查看代码:拼接所有步骤的 `code`,按步骤分段注释。 + +### Phase 5D: 单步重跑 — 医生介入修改(延期,不在当前版本) + +**状态**:延期。当前版本不交付该能力,待 Phase 5A-5C 线上稳定运行两周后再启动。 +**目标(延期后)**:医生可以对任意已完成步骤提出修改指令,系统仅重跑该步骤及其后续步骤。 + +**高阶用户场景**: + +**场景 1:强行纳入临床意义变量(Forced Entry)** + +- Step 2 结果:age(P=0.03), smoke(P=0.08), gender(P=0.15) +- AI 的 Step 3 代码排除了 gender(P>0.1) +- 医生凭临床常识,点击 Step 3 的"修改此步骤",输入"请把 Gender 也纳入模型作为混杂因素" +- 系统仅重新生成并执行 Step 3-4,Step 1-2 不受影响 + +**场景 2:图表样式个性化微调** + +- 最后一步画了彩色生存曲线,但期刊要求黑白灰度图 +- 医生输入"改成黑白配色并加上 95% 置信区间带" +- 系统仅重写最后一步画图代码,前序清洗和拟合完全不重跑 + +**改动文件**: + +- (延期)`backend/src/modules/ssa/services/ChatHandlerService.ts` — 新增 `agentRerunStep` 方法 +- (延期)`backend/src/modules/ssa/routes/chat.routes.ts` — 新增 `rerun_step` agentAction +- (延期)`frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx` — 步骤卡片增加"修改此步骤"按钮 + +**1) 后端 `agentRerunStep` 方法** + +```typescript +async agentRerunStep( + executionId: string, + stepOrder: number, + userInstruction: string, + sseWriter: SSEWriter, +) { + const execution = await this.getExecution(executionId); + const stepResults = execution.stepResults as AgentStepResult[]; + + // 1. 取出 Step 1..N-1 的已有代码作为累积前缀 + const accumulatedCode = stepResults + .filter(s => s.stepOrder < stepOrder && s.status === 'completed') + .map(s => s.code) + .join('\n'); + + // 2. 取出前序步骤结果摘要 + const previousResults = stepResults + .filter(s => s.stepOrder < stepOrder && s.status === 'completed') + .map(s => this.summarizeStepResult(s)); + + // 3. 调用 CoderAgent 重新生成该步骤代码(带用户修改指令) + const newCode = await this.coderAgent.generateStepCode({ + plan: execution.plan, + step: execution.plan.steps[stepOrder - 1], + previousResults, + userInstruction, // "请把 Gender 也纳入模型" + dataSchema: await this.getDataSchema(execution.sessionId), + }); + + // 4. 执行:累积前缀 + 新代码 + const fullCode = accumulatedCode + '\n' + newCode; + const result = await this.codeRunner.execute(execution.sessionId, fullCode); + + // 5. 更新 stepResults[stepOrder] 并标记后续步骤为 pending + // 6. 如果还有后续步骤,级联重跑 + for (let i = stepOrder; i < plan.steps.length; i++) { + // ... 继续分步执行循环 + } +} +``` + +**2) 前端交互** + +```text ++------------------------------------------+ +| Step 3: 多因素回归 completed 8.2s | +| glm(Yqol ~ age + smoke, ...) | +| [查看代码] [查看结果] [✏️ 修改此步骤] | ++------------------------------------------+ + ↓ 点击"修改此步骤" ++------------------------------------------+ +| 请输入修改指令: | +| [请把 Gender 也纳入模型作为混杂因素____] | +| [确认修改并重跑] | ++------------------------------------------+ + ↓ 确认后 ++------------------------------------------+ +| Step 3: 多因素回归 coding... | +| [流式代码生成中...] 标签: 🔄 已修改 | ++------------------------------------------+ +| Step 4: 敏感性分析 pending ⏳ | +| (等待 Step 3 完成后自动执行) | ++------------------------------------------+ +``` + +- 已修改步骤标记 `🔄 已修改(用户干预)`,保留审计轨迹 +- 该步骤之后的所有步骤自动重置为 `pending`,级联重跑 +- 左侧对话区追加审计消息:"✏️ 用户修改了步骤 3:请把 Gender 也纳入模型" + +**3) SSE 事件** + +- `step_rerun` — 步骤 N 被用户修改并重新执行 `{ stepOrder, userInstruction }` + +## 未来扩展(V2 考虑,MVP 不做) + +### 算法 A/B 分支测试 + +**场景**:前 3 步完全相同,Step 4 想对比 Logistic 回归 vs Random Forest 的效果。 + +**实现思路**:在 Step 4 处"开叉",`stepResults` 从线性数组扩展为树结构,支持同一 stepOrder 的多个 variant。前端并列展示两个 Step 4 变体的结果。 + +**MVP 降级方案**:医生先执行 Logistic 版,看完结果后点击"修改此步骤"改为 RF 版。虽然不能并列对比,但功能上可用。 + +### Human-in-the-loop 步骤间确认 + +**场景**:Step 2 跑完后,系统暂停并询问医生:"基于 P<0.1 规则,AI 拟将 age, smoke 纳入回归。您是否需要强制纳入其他变量?" + +**实现思路**:编排循环在特定步骤后挂起(`await userConfirmation()`),等待前端 `confirm_step` 事件后继续。 + +## 不需要改动的部分 + +- `PlannerAgent`:计划格式不变,`steps[]` 结构已具备 `order/method/description` +- `DynamicReport`:复用,每步结果用同一组件渲染 +- 左侧对话区审计轨迹:保持不变 +- R Docker `execute-code` 端点:保持无状态(仅新增 AST 预检) + +## 明确不做的事项(MVP) + +- 不引入独立 Fixer Agent(CoderAgent 内置重试 prompt 模板即可) +- 不做 R session 内存池(代码累加法零改动 R Docker) +- 不做 RData 序列化/NAS 共享存储(MVP 单实例,数据量小) +- 不做错题本 RAG(数据量不足,延后至系统运行 3 个月后评估) +- 不做 A/B 分支并列展示(降级为"修改此步骤"手动切换) +- 不做 Human-in-the-loop 步骤间自动暂停确认(医生可通过"修改此步骤"事后干预) +- 不做单步重跑/级联重跑(Phase 5D 延期到主链稳定两周后) + +## 风险和注意事项 + +- 代码累加法的确定性:必须注入 `deterministicHeader` 并记录种子与数据哈希,避免随机抽样/插补导致的结果漂移。 +- 代码累加法的性能:后续步骤重跑前序代码。对 <5000 行数据集影响 <1 秒。若未来遇到大数据集,可升级为 RData 快照法。 +- 步骤间依赖:CoderAgent 需获得前步骤的关键发现摘要(P 值、显著变量等),通过 `previousResults` 传递。 +- 错误分类准确性:`R_ERROR_MAPPING` 需持续扩充,以正确区分 fatal vs retriable。 +- 步骤跳过后的总结:LLM 综合总结时必须标注哪些步骤被跳过及原因。 +- 安全预检边界:`parse()` 仅覆盖语法,必须叠加危险函数拦截与运行时覆盖;后续可升级 AST 深度扫描以降低绕过风险。 + diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/20260311-Agent-Phase5A5联调验证清单.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/20260311-Agent-Phase5A5联调验证清单.md new file mode 100644 index 00000000..3baca4eb --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/20260311-Agent-Phase5A5联调验证清单.md @@ -0,0 +1,41 @@ +# 2026-03-11 Agent Phase 5A.5 联调验证清单 + +## 验证目标 + +- 确认 Agent 计划阶段每步都带有 `toolCode + params`,可进入变量编辑态。 +- 确认变量编辑保存接口可用:`PATCH /api/v1/ssa/agent-executions/:executionId/plan-params`。 +- 确认“确认计划”前会自动保存未提交的变量改动。 +- 确认分步执行事件 `step_*` 在右侧工作区可视化正常。 + +## 前置条件 + +- 后端已应用迁移:`20260311_add_ssa_agent_step_seed_fields`。 +- 前端/后端/R 服务均使用本次代码构建并启动。 +- 会话中已上传可用于回归或分组比较的数据集(包含分类与连续变量)。 + +## 联调步骤 + +1. 在 SSA 页面发起 Agent 分析请求(例如“做单因素+多因素回归”)。 +2. 等待右侧出现“分析计划”,检查每个步骤是否可见变量参数编辑控件。 +3. 修改至少一个单变量参数(如 `group_var`)与一个多变量参数(如 `predictors`)。 +4. 点击“保存变量修改”,预期出现“变量参数已保存”提示,无报错。 +5. 刷新页面后回到同一会话,确认步骤参数仍为保存后的值(持久化生效)。 +6. 再次修改参数但不点“保存”,直接点“确认计划,生成代码”。 +7. 预期系统先自动保存参数,再进入代码生成(`coding`),无“参数丢失”。 +8. 点击“确认并执行代码”,观察右侧出现分步状态流转:`coding -> executing -> completed/error/skipped`。 +9. 若有失败步骤,检查错误信息显示在对应步骤卡片,不影响其余步骤状态展示。 +10. 执行完成后,确认结果区 `DynamicReport` 正常渲染且可导出。 + +## 验收标准 + +- 计划步骤参数可编辑、可保存、可恢复。 +- 自动保存逻辑在“确认计划”入口生效。 +- `step_*` 事件驱动的步骤状态、耗时、错误信息显示正确。 +- 未出现左侧对话区替代右侧工作区更新的回归问题。 + +## 常见失败点与排查 + +- 若无变量编辑控件:检查后端 `agent_plan_ready` 是否带 `steps[].params`。 +- 若保存失败 400:检查变量是否存在于 `session.dataSchema.columns`。 +- 若保存失败 409:检查执行状态是否仍为 `plan_pending`。 +- 若步骤状态不更新:检查 SSE 是否收到 `step_*` 事件,前端控制台是否有解析错误。 diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index 8a31877a..4f4f4305 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -4,7 +4,7 @@ > **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录 > **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒 > **最后清零**: 2026-03-10(0310 部署完成后清零) -> **本次变更**: 无(当前待部署清单已清零) +> **本次变更**: 已新增待部署项(2026-03-11,含 Agent 严格分步执行模式) --- @@ -16,19 +16,24 @@ | # | 变更内容 | 迁移文件 | 优先级 | 备注 | |---|---------|---------|--------|------| -| — | *暂无* | | | | +| DB-1 | SSA Agent 执行记录新增分步执行与种子审计字段(`step_results/current_step/seed_audit`) | `20260311_add_ssa_agent_step_seed_fields` | 高 | 按数据库规范生成;Shadow DB 失败后采用降级流程产出 SQL,并已人工收敛为仅本次字段变更 | ### 后端变更 (Node.js) | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| BE-1 | SSA Agent 执行链路增加确定性种子注入、错误分类、seed 审计透传 + 分步执行事件(step_*) | `backend/src/modules/ssa/services/ChatHandlerService.ts`, `backend/src/modules/ssa/services/CodeRunnerService.ts`, `backend/src/modules/ssa/services/AgentCoderService.ts` | 重新构建镜像 | 与 DB-1 配套上线,确保执行可复现与可追溯 | +| BE-2 | 新增 Agent 计划参数编辑接口 `PATCH /api/v1/ssa/agent-executions/:executionId/plan-params`(复用参数约束配置) | `backend/src/modules/ssa/routes/agent-execution.routes.ts`, `backend/src/modules/ssa/index.ts` | 重新构建镜像 | Phase 5A.5 后端入口,限制 `plan_pending` 状态可编辑 | +| BE-3 | Agent 切换为严格分步模式:`confirm_plan` 不生成整段代码,执行阶段统一按步骤生成 + 失败后依赖短路跳过后续步骤 | `backend/src/modules/ssa/services/ChatHandlerService.ts` | 重新构建镜像 | 修复“第3步失败仍尝试第4步”问题,降低无效重试与误导性结果 | +| BE-4 | R 代码语法修复器纠正 `} else` 处理策略,避免引入 `unexpected 'else'` | `backend/src/modules/ssa/services/CodeRunnerService.ts` | 重新构建镜像 | 修复线上语法错误噪声,减少重试失败 | ### 前端变更 | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| FE-1 | Agent 通道接入 step_* SSE 事件并展示分步执行状态(兼容旧 code_* 事件) | `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts`, `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/types/index.ts`, `frontend-v2/src/modules/ssa/stores/ssaStore.ts` | 重新构建镜像 | 右侧工作区可见每步状态/错误/耗时,便于排障 | +| FE-2 | Agent 计划阶段复用 QPER 变量编辑控件(单变量/多变量)并接入保存、确认前自动保存 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx`, `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx` | 重新构建镜像 | 对接 `PATCH /agent-executions/:executionId/plan-params`,实现 5A.5 前后端闭环 | +| FE-3 | Agent 工作区增强:在分步状态下可展开查看每步已生成结果(`reportBlocks`),并兼容严格分步模式下的 `code_pending` 空代码预览 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts` | 重新构建镜像 | 修复“有结果但代码下方不可见”与状态显示误导问题 | ### Python 微服务变更 @@ -40,7 +45,7 @@ | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| R-1 | execute-code 端点升级为语法+安全双层预检,新增 E_SECURITY 与运行时高危函数拦截 | `r-statistics-service/plumber.R` | 重新构建镜像 | 阻断 system/eval/source/file.remove/setwd 等风险调用 | ### 环境变量 / 配置变更 diff --git a/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx b/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx index c437226d..b2e57b23 100644 --- a/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx +++ b/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx @@ -15,13 +15,28 @@ import { FileText, Play, Ban, + Save, } from 'lucide-react'; import { useSSAStore } from '../stores/ssaStore'; -import type { AgentExecutionStatus } from '../types'; +import type { AgentExecutionStatus, AgentStepStatus, DataOverviewColumn, VariableDictEntry } from '../types'; +import apiClient from '@/common/api/axios'; +import { + MultiVarTags, + PARAM_LABELS, + SingleVarSelect, + TOOL_CONSTRAINTS, + SINGLE_VAR_KEYS, + MULTI_VAR_KEYS, + checkMismatch, + type VarInfo, +} from './WorkflowTimeline'; +import { DynamicReport } from './DynamicReport'; export interface AgentCodePanelProps { onAction?: (action: 'confirm_plan' | 'confirm_code' | 'cancel') => void; actionLoading?: boolean; + variableDictionary?: VariableDictEntry[]; + dataOverviewColumns?: DataOverviewColumn[]; } const STATUS_LABEL: Record = { @@ -49,8 +64,24 @@ const StatusBadge: React.FC<{ status: AgentExecutionStatus }> = ({ status }) => ); }; -export const AgentCodePanel: React.FC = ({ onAction, actionLoading }) => { - const { agentExecution, executionMode } = useSSAStore(); +const STEP_STATUS_LABEL: Record = { + pending: '待执行', + coding: '生成代码中', + executing: '执行中', + completed: '已完成', + error: '失败', + skipped: '已跳过', +}; + +export const AgentCodePanel: React.FC = ({ + onAction, + actionLoading, + variableDictionary = [], + dataOverviewColumns = [], +}) => { + const { agentExecution, executionMode, addToast } = useSSAStore(); + const [paramDrafts, setParamDrafts] = useState>>({}); + const [savingParams, setSavingParams] = useState(false); if (executionMode !== 'agent') return null; @@ -69,10 +100,28 @@ export const AgentCodePanel: React.FC = ({ onAction, action ); } - const { status, planText, planSteps: rawPlanSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution; + const { + status, + planText, + planSteps: rawPlanSteps, + generatedCode, + partialCode, + errorMessage, + retryCount, + durationMs, + stepResults, + currentStep, + } = agentExecution; // 防御性:从 planText JSON 解析步骤(支持 steps / plan.steps),绝不展示原始 JSON - const planSteps = React.useMemo(() => { + const planSteps = React.useMemo; + }> | undefined>(() => { if (rawPlanSteps && rawPlanSteps.length > 0) return rawPlanSteps; if (!planText) return undefined; @@ -98,6 +147,8 @@ export const AgentCodePanel: React.FC = ({ onAction, action method: s.method ?? '', description: s.description ?? '', rationale: s.rationale, + toolCode: s.toolCode || s.tool_code, + params: s.params, })); } return undefined; @@ -129,6 +180,99 @@ export const AgentCodePanel: React.FC = ({ onAction, action const isStreamingCode = status === 'coding' && !!partialCode; const displayCode = isStreamingCode ? partialCode : (generatedCode || partialCode); + const varsMap = React.useMemo(() => { + const map = new Map(); + for (const v of variableDictionary) { + const col = dataOverviewColumns.find(c => c.name === v.name); + map.set(v.name, { + name: v.name, + type: v.confirmedType || v.inferredType, + totalLevels: col?.totalLevels, + }); + } + for (const col of dataOverviewColumns) { + if (!map.has(col.name)) { + map.set(col.name, { + name: col.name, + type: col.type, + totalLevels: col.totalLevels, + }); + } + } + return map; + }, [variableDictionary, dataOverviewColumns]); + + const allVars = React.useMemo(() => { + if (variableDictionary.length > 0) { + return variableDictionary + .filter(v => !v.isIdLike) + .map(v => { + const col = dataOverviewColumns.find(c => c.name === v.name); + return { + name: v.name, + type: v.confirmedType || v.inferredType, + totalLevels: col?.totalLevels, + }; + }); + } + return dataOverviewColumns + .filter(c => !c.isIdLike) + .map(c => ({ name: c.name, type: c.type, totalLevels: c.totalLevels })); + }, [variableDictionary, dataOverviewColumns]); + + const updateDraft = (stepOrder: number, key: string, value: unknown) => { + setParamDrafts(prev => ({ + ...prev, + [stepOrder]: { + ...(prev[stepOrder] || {}), + [key]: value, + }, + })); + }; + + const hasDrafts = Object.keys(paramDrafts).length > 0; + + const savePlanParams = async () => { + if (!agentExecution?.id || !hasDrafts) return; + const payload = { + steps: Object.entries(paramDrafts).map(([stepOrder, params]) => ({ + stepOrder: Number(stepOrder), + params, + })), + }; + setSavingParams(true); + try { + const resp = await apiClient.patch(`/api/v1/ssa/agent-executions/${agentExecution.id}/plan-params`, payload); + const nextReview = resp?.data?.reviewResult; + const nextSteps = Array.isArray(nextReview?.steps) + ? nextReview.steps.map((s: any) => ({ + order: s.order ?? 0, + method: s.method ?? '', + description: s.description ?? '', + rationale: s.rationale, + toolCode: s.toolCode || s.tool_code, + params: s.params, + })) + : planSteps; + useSSAStore.getState().updateAgentExecution({ + planSteps: nextSteps, + }); + setParamDrafts({}); + addToast('变量参数已保存', 'success'); + } catch (err: any) { + addToast(err?.response?.data?.error || err?.message || '保存变量参数失败', 'error'); + throw err; + } finally { + setSavingParams(false); + } + }; + + const handleConfirmPlan = async () => { + if (hasDrafts) { + await savePlanParams(); + } + onAction?.('confirm_plan'); + }; return (
@@ -186,6 +330,46 @@ export const AgentCodePanel: React.FC = ({ onAction, action {s.method} {s.description} {s.rationale && {s.rationale}} + {status === 'plan_pending' && s.params && Object.keys(s.params).length > 0 && ( +
+ {Object.entries(s.params).map(([k, raw]) => { + const toolCode = s.toolCode || ''; + const rule = TOOL_CONSTRAINTS[toolCode]?.[k]; + const edited = (paramDrafts[s.order] || {})[k]; + const value = edited !== undefined ? edited : raw; + const isSingle = SINGLE_VAR_KEYS.has(k); + const isMulti = MULTI_VAR_KEYS.has(k); + const mismatch = rule && typeof value === 'string' + ? checkMismatch(value, rule, varsMap) + : null; + return ( +
+
{PARAM_LABELS[k] || k}
+ {isSingle ? ( + updateDraft(s.order, k, v)} + /> + ) : isMulti ? ( + updateDraft(s.order, k, v)} + /> + ) : ( + {String(value ?? '—')} + )} + {mismatch && {mismatch}} +
+ ); + })} +
+ )}
))} @@ -200,10 +384,21 @@ export const AgentCodePanel: React.FC = ({ onAction, action {/* 计划确认操作按钮 */} {status === 'plan_pending' && onAction && (
+ {hasDrafts && ( + + )}
)} - {/* Step 2: R 代码 */} - {(displayCode || status === 'coding') && ( + {/* Step-by-step 执行状态(Phase 5B) */} + {stepResults && stepResults.length > 0 && ( +
+
+ + 分步执行状态 +
+
+ {stepResults + .slice() + .sort((a, b) => a.stepOrder - b.stepOrder) + .map((s, i) => ( +
+ {s.stepOrder} +
+ {s.method || `Step ${s.stepOrder}`} + + {STEP_STATUS_LABEL[s.status]} + {currentStep === s.stepOrder && ['coding', 'executing'].includes(s.status) ? ' · 当前步骤' : ''} + {typeof s.durationMs === 'number' ? ` · ${(s.durationMs / 1000).toFixed(1)}s` : ''} + + {!!s.errorMessage && {s.errorMessage}} + {s.status === 'completed' && s.reportBlocks && s.reportBlocks.length > 0 && ( +
+
+ + 查看该步骤结果({s.reportBlocks.length} 个模块) + +
+ +
+
+
+ )} +
+
+ ))} +
+
+ )} + + {/* Step 2: R 代码(严格分步模式下展示首步预览;后续步骤执行时逐步生成) */} + {(displayCode || ['coding', 'code_pending', 'executing', 'completed', 'error'].includes(status)) && (
@@ -234,6 +470,11 @@ export const AgentCodePanel: React.FC = ({ onAction, action
{displayCode ? (
{displayCode}
+ ) : status === 'code_pending' ? ( +
+ + 当前为分步执行模式:后续步骤代码将在执行阶段逐步生成。 +
) : (
diff --git a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx index fb2c2b19..f220dc37 100644 --- a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx @@ -31,6 +31,7 @@ import { AgentCodePanel } from './AgentCodePanel'; import { DynamicReport } from './DynamicReport'; import { exportBlocksToWord } from '../utils/exportBlocksToWord'; import apiClient from '@/common/api/axios'; +import type { VariableDictEntry, DataOverviewColumn } from '../types'; const stepHasResult = (s: WorkflowStepResult) => (s.status === 'success' || s.status === 'warning') && s.result; @@ -340,6 +341,8 @@ export const SSAWorkspacePane: React.FC = () => { {/* Agent 模式的报告输出复用 DynamicReport */} {agentExecution?.status === 'completed' && agentExecution.reportBlocks && agentExecution.reportBlocks.length > 0 && ( diff --git a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx index 83f645b9..32032954 100644 --- a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx +++ b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx @@ -30,7 +30,7 @@ import type { // ────────────────────────── Param constraints ────────────────────────── -interface ParamConstraint { +export interface ParamConstraint { paramType: 'single' | 'multi'; requiredType: 'categorical' | 'numeric' | 'any'; minLevels?: number; @@ -38,9 +38,9 @@ interface ParamConstraint { hint: string; } -type ToolConstraints = Record>; +export type ToolConstraints = Record>; -const TOOL_CONSTRAINTS: ToolConstraints = { +export const TOOL_CONSTRAINTS: ToolConstraints = { ST_DESCRIPTIVE: { variables: { paramType: 'multi', requiredType: 'any', hint: '选择需要描述的变量' }, group_var: { paramType: 'single', requiredType: 'categorical', hint: '分组变量(可选)' }, @@ -93,25 +93,25 @@ const TOOL_CONSTRAINTS: ToolConstraints = { }, }; -const SINGLE_VAR_KEYS = new Set([ +export const SINGLE_VAR_KEYS = new Set([ 'group_var', 'outcome_var', 'value_var', 'var_x', 'var_y', 'before_var', 'after_var', 'var1', 'var2', ]); -const MULTI_VAR_KEYS = new Set([ +export const MULTI_VAR_KEYS = new Set([ 'analyze_vars', 'predictors', 'variables', 'confounders', ]); -function isVariableParam(key: string): boolean { +export function isVariableParam(key: string): boolean { return SINGLE_VAR_KEYS.has(key) || MULTI_VAR_KEYS.has(key); } -interface VarInfo { +export interface VarInfo { name: string; type: string; totalLevels?: number; } -function checkMismatch( +export function checkMismatch( varName: string, constraint: ParamConstraint, varsMap: Map @@ -202,7 +202,7 @@ const formatValue = (value: unknown): string => { return s.length > 50 ? s.slice(0, 47) + '…' : s; }; -const PARAM_LABELS: Record = { +export const PARAM_LABELS: Record = { variables: '分析变量', outcome_var: '结局变量 (Y)', predictors: '自变量 (X)', @@ -231,7 +231,7 @@ interface SingleVarSelectProps { onChange: (v: string | null) => void; } -const SingleVarSelect: React.FC = ({ value, constraint, varsMap, allVars, onChange }) => { +export const SingleVarSelect: React.FC = ({ value, constraint, varsMap, allVars, onChange }) => { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -318,7 +318,7 @@ interface MultiVarTagsProps { onChange: (v: string[]) => void; } -const MultiVarTags: React.FC = ({ values, constraint, varsMap, allVars, onChange }) => { +export const MultiVarTags: React.FC = ({ values, constraint, varsMap, allVars, onChange }) => { const [showDropdown, setShowDropdown] = useState(false); const ref = useRef(null); diff --git a/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts b/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts index 48be5867..eea98343 100644 --- a/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts +++ b/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts @@ -17,7 +17,7 @@ import { useState, useCallback, useRef } from 'react'; import { getAccessToken, isTokenExpired, refreshAccessToken } from '@/framework/auth/api'; import { useSSAStore } from '../stores/ssaStore'; import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard'; -import type { WorkflowPlan } from '../types'; +import type { WorkflowPlan, AgentStepResult } from '../types'; // ──────────────────────────────────────────── // Types @@ -229,7 +229,19 @@ export function useSSAChat(): UseSSAChatReturn { setIntentMeta(null); setPendingQuestion(null); - const isAgentAction = !!metadata?.agentAction; + const { executionMode, agentExecution } = useSSAStore.getState(); + const isAgentInlineInstruction = + executionMode === 'agent' + && !!agentExecution + && (agentExecution.status === 'plan_pending' || agentExecution.status === 'code_pending') + && !metadata?.agentAction + && !metadata?.askUserResponse; + + const finalMetadata = isAgentInlineInstruction + ? { ...(metadata || {}), agentInlineInstruction: true } + : metadata; + + const isAgentAction = !!finalMetadata?.agentAction || isAgentInlineInstruction; const assistantMsgId = crypto.randomUUID(); const assistantPlaceholder: ChatMessage = { @@ -254,7 +266,8 @@ export function useSSAChat(): UseSSAChatReturn { setChatMessages(prev => [...prev, userMsg, assistantPlaceholder]); } - abortRef.current = new AbortController(); + const controller = new AbortController(); + abortRef.current = controller; let fullContent = ''; let fullThinking = ''; @@ -267,8 +280,8 @@ export function useSSAChat(): UseSSAChatReturn { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ content, ...(metadata ? { metadata } : {}) }), - signal: abortRef.current.signal, + body: JSON.stringify({ content, ...(finalMetadata ? { metadata: finalMetadata } : {}) }), + signal: controller.signal, }); if (!response.ok) { @@ -359,10 +372,115 @@ export function useSSAChat(): UseSSAChatReturn { if (parsed.type === 'agent_plan_ready') { const { updateAgentExecution } = useSSAStore.getState(); + const initialStepResults: AgentStepResult[] = Array.isArray(parsed.plan?.steps) + ? parsed.plan.steps.map((s: any) => ({ + stepOrder: s.order || 0, + method: s.method || '', + status: 'pending', + retryCount: 0, + })) + : []; updateAgentExecution({ planText: parsed.planText, planSteps: parsed.plan?.steps, status: 'plan_pending', + stepResults: initialStepResults.length > 0 ? initialStepResults : undefined, + currentStep: initialStepResults.length > 0 ? initialStepResults[0].stepOrder : undefined, + }); + continue; + } + + const patchStepResult = (stepOrder: number, patch: Partial) => { + const { agentExecution, updateAgentExecution } = useSSAStore.getState(); + const existing = agentExecution?.stepResults || []; + const idx = existing.findIndex(s => s.stepOrder === stepOrder); + let next: AgentStepResult[]; + if (idx >= 0) { + next = existing.map((s, i) => (i === idx ? { ...s, ...patch } : s)); + } else { + next = [ + ...existing, + { + stepOrder, + method: '', + status: 'pending', + retryCount: 0, + ...patch, + } as AgentStepResult, + ].sort((a, b) => a.stepOrder - b.stepOrder); + } + updateAgentExecution({ stepResults: next, currentStep: stepOrder }); + }; + + if (parsed.type === 'step_coding') { + patchStepResult(parsed.stepOrder || 0, { + status: 'coding', + partialCode: parsed.partialCode, + retryCount: parsed.retryCount || 0, + }); + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + partialCode: parsed.partialCode, + status: 'coding', + }); + continue; + } + + if (parsed.type === 'step_code_ready') { + patchStepResult(parsed.stepOrder || 0, { + status: 'coding', + code: parsed.code, + partialCode: undefined, + }); + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + generatedCode: parsed.code, + partialCode: undefined, + status: 'coding', + }); + continue; + } + + if (parsed.type === 'step_executing') { + patchStepResult(parsed.stepOrder || 0, { status: 'executing' }); + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ status: 'executing' }); + continue; + } + + if (parsed.type === 'step_result') { + patchStepResult(parsed.stepOrder || 0, { + status: 'completed', + reportBlocks: parsed.reportBlocks, + durationMs: parsed.durationMs, + errorMessage: undefined, + }); + continue; + } + + if (parsed.type === 'step_error') { + patchStepResult(parsed.stepOrder || 0, { + status: parsed.willRetry ? 'coding' : 'error', + errorMessage: parsed.message, + retryCount: parsed.retryCount || 0, + }); + continue; + } + + if (parsed.type === 'step_skipped') { + patchStepResult(parsed.stepOrder || 0, { + status: 'skipped', + errorMessage: parsed.reason, + }); + continue; + } + + if (parsed.type === 'pipeline_aborted') { + const { updateAgentExecution } = useSSAStore.getState(); + updateAgentExecution({ + status: 'error', + errorMessage: parsed.error || '执行已终止', + currentStep: parsed.stepOrder, }); continue; } @@ -379,9 +497,9 @@ export function useSSAChat(): UseSSAChatReturn { if (parsed.type === 'code_generated') { const { updateAgentExecution } = useSSAStore.getState(); updateAgentExecution({ - generatedCode: parsed.code, + generatedCode: parsed.code || undefined, partialCode: undefined, - status: parsed.code ? 'code_pending' : 'coding', + status: 'code_pending', }); continue; } @@ -399,6 +517,7 @@ export function useSSAChat(): UseSSAChatReturn { generatedCode: parsed.code || curExec?.generatedCode, status: 'completed', durationMs: parsed.durationMs, + stepResults: parsed.stepResults || curExec?.stepResults, }); // 在对话中插入可点击的结果卡片 @@ -503,7 +622,9 @@ export function useSSAChat(): UseSSAChatReturn { : m )); setIsGenerating(false); - abortRef.current = null; + if (abortRef.current === controller) { + abortRef.current = null; + } await new Promise(r => setTimeout(r, delay)); return sendChatMessage(sessionId, content, metadata); } @@ -521,7 +642,9 @@ export function useSSAChat(): UseSSAChatReturn { setIsGenerating(false); setStreamingContent(''); setThinkingContent(''); - abortRef.current = null; + if (abortRef.current === controller) { + abortRef.current = null; + } } }, []); @@ -533,7 +656,7 @@ export function useSSAChat(): UseSSAChatReturn { */ const executeAgentAction = useCallback(async (sessionId: string, action: AgentActionType) => { const AUDIT_MESSAGES: Record = { - confirm_plan: '✅ 方案已确认,正在生成 R 代码...', + confirm_plan: '✅ 方案已确认,已进入执行确认(执行时将分步生成代码)...', confirm_code: '✅ 代码已确认,R 引擎正在执行...', cancel: '❌ 已取消当前分析', }; diff --git a/frontend-v2/src/modules/ssa/stores/ssaStore.ts b/frontend-v2/src/modules/ssa/stores/ssaStore.ts index f246d464..9ed96e91 100644 --- a/frontend-v2/src/modules/ssa/stores/ssaStore.ts +++ b/frontend-v2/src/modules/ssa/stores/ssaStore.ts @@ -20,6 +20,7 @@ import type { FiveSectionReport, VariableDetailData, AgentExecutionRecord, + AgentStepResult, } from '../types'; type ArtifactPane = 'empty' | 'sap' | 'execution' | 'result'; @@ -250,6 +251,8 @@ export const useSSAStore = create((set) => ({ method: s.method, description: s.description, rationale: s.rationale, + toolCode: s.toolCode || s.tool_code, + params: s.params, })); } planMeta = { title: structured.title, designType: structured.designType }; @@ -261,7 +264,12 @@ export const useSSAStore = create((set) => ({ const arr = Array.isArray(parsed?.steps) ? parsed.steps : parsed?.plan?.steps; if (Array.isArray(arr)) { planSteps = arr.map((s: any) => ({ - order: s.order, method: s.method, description: s.description, rationale: s.rationale, + order: s.order, + method: s.method, + description: s.description, + rationale: s.rationale, + toolCode: s.toolCode || s.tool_code, + params: s.params, })); } if (!planMeta) planMeta = { title: parsed.title, designType: parsed.designType }; @@ -278,6 +286,8 @@ export const useSSAStore = create((set) => ({ reportBlocks: e.reportBlocks, retryCount: e.retryCount || 0, status: e.status, + stepResults: Array.isArray(e.stepResults) ? (e.stepResults as AgentStepResult[]) : undefined, + currentStep: typeof e.currentStep === 'number' ? e.currentStep : undefined, errorMessage: e.errorMessage, durationMs: e.durationMs, createdAt: e.createdAt, diff --git a/frontend-v2/src/modules/ssa/types/index.ts b/frontend-v2/src/modules/ssa/types/index.ts index 04d1f97b..7bdc2efd 100644 --- a/frontend-v2/src/modules/ssa/types/index.ts +++ b/frontend-v2/src/modules/ssa/types/index.ts @@ -377,6 +377,7 @@ export type SSEMessageType = | 'workflow_complete' | 'workflow_error' | 'qper_status' | 'reflection_complete' | 'agent_planning' | 'agent_plan_ready' + | 'step_coding' | 'step_code_ready' | 'step_executing' | 'step_result' | 'step_skipped' | 'pipeline_aborted' | 'code_generating' | 'code_generated' | 'code_executing' | 'code_result' | 'code_error' | 'code_retry'; @@ -386,17 +387,40 @@ export type AgentExecutionStatus = | 'coding' | 'code_pending' | 'executing' | 'completed' | 'error'; +export type AgentStepStatus = 'pending' | 'coding' | 'executing' | 'completed' | 'error' | 'skipped'; + +export interface AgentStepResult { + stepOrder: number; + method: string; + status: AgentStepStatus; + code?: string; + partialCode?: string; + reportBlocks?: ReportBlock[]; + errorMessage?: string; + retryCount: number; + durationMs?: number; +} + export interface AgentExecutionRecord { id: string; sessionId: string; query: string; planText?: string; - planSteps?: Array<{ order: number; method: string; description: string; rationale?: string }>; + planSteps?: Array<{ + order: number; + method: string; + description: string; + rationale?: string; + toolCode?: string; + params?: Record; + }>; generatedCode?: string; partialCode?: string; reportBlocks?: ReportBlock[]; retryCount: number; status: AgentExecutionStatus; + stepResults?: AgentStepResult[]; + currentStep?: number; errorMessage?: string; durationMs?: number; createdAt?: string; diff --git a/frontend-v2/tsconfig.tsbuildinfo b/frontend-v2/tsconfig.tsbuildinfo index 0fd52701..03c315c8 100644 --- a/frontend-v2/tsconfig.tsbuildinfo +++ b/frontend-v2/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/common/api/axios.ts","./src/framework/auth/authcontext.tsx","./src/framework/auth/api.ts","./src/framework/auth/index.ts","./src/framework/auth/moduleapi.ts","./src/framework/auth/types.ts","./src/framework/layout/adminlayout.tsx","./src/framework/layout/mainlayout.tsx","./src/framework/layout/orglayout.tsx","./src/framework/layout/topnavigation.tsx","./src/framework/modules/errorboundary.tsx","./src/framework/modules/moduleerrorfallback.tsx","./src/framework/modules/moduleregistry.ts","./src/framework/modules/types.ts","./src/framework/permission/permissioncontext.tsx","./src/framework/permission/index.ts","./src/framework/permission/types.ts","./src/framework/permission/usepermission.ts","./src/framework/router/permissiondenied.tsx","./src/framework/router/routeguard.tsx","./src/framework/router/index.ts","./src/modules/admin/index.tsx","./src/modules/admin/api/iitprojectapi.ts","./src/modules/admin/api/statsapi.ts","./src/modules/admin/api/systemkbapi.ts","./src/modules/admin/api/userapi.ts","./src/modules/admin/components/assigntenantmodal.tsx","./src/modules/admin/components/importusermodal.tsx","./src/modules/admin/components/modulepermissionmodal.tsx","./src/modules/admin/components/qc-cockpit/qcdetaildrawer.tsx","./src/modules/admin/components/qc-cockpit/qcreportdrawer.tsx","./src/modules/admin/components/qc-cockpit/qcstatcards.tsx","./src/modules/admin/components/qc-cockpit/riskheatmap.tsx","./src/modules/admin/components/qc-cockpit/index.ts","./src/modules/admin/pages/iitmembermanagepage.tsx","./src/modules/admin/pages/iitprojectdetailpage.tsx","./src/modules/admin/pages/iitprojectlistpage.tsx","./src/modules/admin/pages/iitqccockpitpage.tsx","./src/modules/admin/pages/statsdashboardpage.tsx","./src/modules/admin/pages/systemkbdetailpage.tsx","./src/modules/admin/pages/systemkblistpage.tsx","./src/modules/admin/pages/userdetailpage.tsx","./src/modules/admin/pages/userformpage.tsx","./src/modules/admin/pages/userlistpage.tsx","./src/modules/admin/types/iitproject.ts","./src/modules/admin/types/qccockpit.ts","./src/modules/admin/types/systemkb.ts","./src/modules/admin/types/user.ts","./src/modules/aia/constants.ts","./src/modules/aia/index.tsx","./src/modules/aia/types.ts","./src/modules/aia/components/agentcard.tsx","./src/modules/aia/components/agenthub.tsx","./src/modules/aia/components/chatworkspace.tsx","./src/modules/aia/components/index.ts","./src/modules/aia/protocol-agent/protocolagentpage.tsx","./src/modules/aia/protocol-agent/index.ts","./src/modules/aia/protocol-agent/types.ts","./src/modules/aia/protocol-agent/components/actioncard.tsx","./src/modules/aia/protocol-agent/components/chatarea.tsx","./src/modules/aia/protocol-agent/components/documentpanel.tsx","./src/modules/aia/protocol-agent/components/markdowncontent.tsx","./src/modules/aia/protocol-agent/components/reflexionmessage.tsx","./src/modules/aia/protocol-agent/components/resizablesplitpane.tsx","./src/modules/aia/protocol-agent/components/stagecard.tsx","./src/modules/aia/protocol-agent/components/stageeditmodal.tsx","./src/modules/aia/protocol-agent/components/statepanel.tsx","./src/modules/aia/protocol-agent/components/syncbutton.tsx","./src/modules/aia/protocol-agent/components/viewswitcher.tsx","./src/modules/aia/protocol-agent/components/index.ts","./src/modules/aia/protocol-agent/hooks/index.ts","./src/modules/aia/protocol-agent/hooks/useprotocolcontext.ts","./src/modules/aia/protocol-agent/hooks/useprotocolconversations.ts","./src/modules/aia/protocol-agent/hooks/useprotocolgeneration.ts","./src/modules/asl/index.tsx","./src/modules/asl/api/index.ts","./src/modules/asl/components/asllayout.tsx","./src/modules/asl/components/conclusiontag.tsx","./src/modules/asl/components/detailreviewdrawer.tsx","./src/modules/asl/components/fulltextdetaildrawer.tsx","./src/modules/asl/components/judgmentbadge.tsx","./src/modules/asl/components/charting/baselinetable.tsx","./src/modules/asl/components/charting/datasourceselector.tsx","./src/modules/asl/components/charting/prismaflowdiagram.tsx","./src/modules/asl/components/deep-research/agentterminal.tsx","./src/modules/asl/components/deep-research/landingview.tsx","./src/modules/asl/components/deep-research/resultsview.tsx","./src/modules/asl/components/deep-research/setuppanel.tsx","./src/modules/asl/components/deep-research/strategyconfirm.tsx","./src/modules/asl/components/extraction/extractiondrawer.tsx","./src/modules/asl/components/extraction/extractionstatusbadge.tsx","./src/modules/asl/components/extraction/fieldgroup.tsx","./src/modules/asl/components/extraction/processingterminal.tsx","./src/modules/asl/components/extraction/quoteblock.tsx","./src/modules/asl/components/meta/resultspanel.tsx","./src/modules/asl/hooks/usedeepresearchtask.ts","./src/modules/asl/hooks/useextractionlogs.ts","./src/modules/asl/hooks/usefulltextresults.ts","./src/modules/asl/hooks/usefulltexttask.ts","./src/modules/asl/hooks/usescreeningresults.ts","./src/modules/asl/hooks/usescreeningtask.ts","./src/modules/asl/pages/deepresearchpage.tsx","./src/modules/asl/pages/extractionpage.tsx","./src/modules/asl/pages/extractionprogress.tsx","./src/modules/asl/pages/extractionsetup.tsx","./src/modules/asl/pages/extractionworkbench.tsx","./src/modules/asl/pages/fulltextprogress.tsx","./src/modules/asl/pages/fulltextresults.tsx","./src/modules/asl/pages/fulltextsettings.tsx","./src/modules/asl/pages/fulltextworkbench.tsx","./src/modules/asl/pages/metaanalysisengine.tsx","./src/modules/asl/pages/researchsearch.tsx","./src/modules/asl/pages/srchartgenerator.tsx","./src/modules/asl/pages/screeningresults.tsx","./src/modules/asl/pages/screeningworkbench.tsx","./src/modules/asl/pages/titlescreeningsettings.tsx","./src/modules/asl/types/deepresearch.ts","./src/modules/asl/types/index.ts","./src/modules/asl/utils/chartingexcelutils.ts","./src/modules/asl/utils/excelexport.ts","./src/modules/asl/utils/excelutils.ts","./src/modules/asl/utils/metaexcelutils.ts","./src/modules/asl/utils/tabletransform.ts","./src/modules/dc/index.tsx","./src/modules/dc/api/toolb.ts","./src/modules/dc/api/toolc.ts","./src/modules/dc/components/assetlibrary.tsx","./src/modules/dc/components/tasklist.tsx","./src/modules/dc/components/toolcard.tsx","./src/modules/dc/hooks/useassets.ts","./src/modules/dc/hooks/userecenttasks.ts","./src/modules/dc/pages/portal.tsx","./src/modules/dc/pages/tool-b/step1upload.tsx","./src/modules/dc/pages/tool-b/step2schema.tsx","./src/modules/dc/pages/tool-b/step3processing.tsx","./src/modules/dc/pages/tool-b/step4verify.tsx","./src/modules/dc/pages/tool-b/step5result.tsx","./src/modules/dc/pages/tool-b/index.tsx","./src/modules/dc/pages/tool-b/components/stepindicator.tsx","./src/modules/dc/pages/tool-c/index.tsx","./src/modules/dc/pages/tool-c/components/binningdialog.tsx","./src/modules/dc/pages/tool-c/components/binningdialog_improved.tsx","./src/modules/dc/pages/tool-c/components/computedialog.tsx","./src/modules/dc/pages/tool-c/components/conditionaldialog.tsx","./src/modules/dc/pages/tool-c/components/datagrid.tsx","./src/modules/dc/pages/tool-c/components/dropnadialog.tsx","./src/modules/dc/pages/tool-c/components/filterdialog.tsx","./src/modules/dc/pages/tool-c/components/header.tsx","./src/modules/dc/pages/tool-c/components/metrictimepanel.tsx","./src/modules/dc/pages/tool-c/components/missingvaluedialog.tsx","./src/modules/dc/pages/tool-c/components/multimetricpanel.tsx","./src/modules/dc/pages/tool-c/components/pivotdialog.tsx","./src/modules/dc/pages/tool-c/components/pivotpanel.tsx","./src/modules/dc/pages/tool-c/components/recodedialog.tsx","./src/modules/dc/pages/tool-c/components/sidebar.tsx","./src/modules/dc/pages/tool-c/components/streamingsteps.tsx","./src/modules/dc/pages/tool-c/components/toolbar.tsx","./src/modules/dc/pages/tool-c/components/transformdialog.tsx","./src/modules/dc/pages/tool-c/components/unpivotpanel.tsx","./src/modules/dc/pages/tool-c/hooks/usesessionstatus.ts","./src/modules/dc/pages/tool-c/types/index.ts","./src/modules/dc/types/portal.ts","./src/modules/iit/iitlayout.tsx","./src/modules/iit/index.tsx","./src/modules/iit/api/iitprojectapi.ts","./src/modules/iit/components/ruletemplatebuilder.tsx","./src/modules/iit/components/variablepicker.tsx","./src/modules/iit/components/reports/completenesstable.tsx","./src/modules/iit/components/reports/deviationlogtable.tsx","./src/modules/iit/components/reports/eligibilitytable.tsx","./src/modules/iit/components/reports/equerylogtable.tsx","./src/modules/iit/config/projectdetailpage.tsx","./src/modules/iit/config/projectlistpage.tsx","./src/modules/iit/context/iitprojectcontext.tsx","./src/modules/iit/pages/aichatpage.tsx","./src/modules/iit/pages/aistreampage.tsx","./src/modules/iit/pages/dashboardpage.tsx","./src/modules/iit/pages/equerypage.tsx","./src/modules/iit/pages/reportspage.tsx","./src/modules/iit/pages/variablelistpage.tsx","./src/modules/iit/types/iitproject.ts","./src/modules/iit/types/qccockpit.ts","./src/modules/legacy/legacysystempage.tsx","./src/modules/legacy/researchmanagement.tsx","./src/modules/legacy/statisticaltools.tsx","./src/modules/pkb/index.tsx","./src/modules/pkb/api/knowledgebaseapi.ts","./src/modules/pkb/components/createkbdialog.tsx","./src/modules/pkb/components/documentlist.tsx","./src/modules/pkb/components/documentupload.tsx","./src/modules/pkb/components/editkbdialog.tsx","./src/modules/pkb/components/knowledgebaselist.tsx","./src/modules/pkb/components/workspace/batchmode.tsx","./src/modules/pkb/components/workspace/batchmodecomplete.tsx","./src/modules/pkb/components/workspace/deepreadmode.tsx","./src/modules/pkb/components/workspace/fulltextmode.tsx","./src/modules/pkb/components/workspace/workmodeselector.tsx","./src/modules/pkb/hooks/useworkmode.ts","./src/modules/pkb/pages/dashboardpage.tsx","./src/modules/pkb/pages/knowledgepage.tsx","./src/modules/pkb/pages/workspacepage.tsx","./src/modules/pkb/stores/useknowledgebasestore.ts","./src/modules/pkb/types/workspace.ts","./src/modules/rvw/index.tsx","./src/modules/rvw/api/index.ts","./src/modules/rvw/components/agentmodal.tsx","./src/modules/rvw/components/batchtoolbar.tsx","./src/modules/rvw/components/clinicalreport.tsx","./src/modules/rvw/components/editorialreport.tsx","./src/modules/rvw/components/filterchips.tsx","./src/modules/rvw/components/forensicsreport.tsx","./src/modules/rvw/components/header.tsx","./src/modules/rvw/components/methodologyreport.tsx","./src/modules/rvw/components/reportdetail.tsx","./src/modules/rvw/components/scorering.tsx","./src/modules/rvw/components/sidebar.tsx","./src/modules/rvw/components/taskdetail.tsx","./src/modules/rvw/components/tasktable.tsx","./src/modules/rvw/components/index.ts","./src/modules/rvw/pages/dashboard.tsx","./src/modules/rvw/types/index.ts","./src/modules/ssa/ssaworkspace.tsx","./src/modules/ssa/index.tsx","./src/modules/ssa/components/agentcodepanel.tsx","./src/modules/ssa/components/askusercard.tsx","./src/modules/ssa/components/clarificationcard.tsx","./src/modules/ssa/components/conclusionreport.tsx","./src/modules/ssa/components/datacontextcard.tsx","./src/modules/ssa/components/dataprofilecard.tsx","./src/modules/ssa/components/dataprofilemodal.tsx","./src/modules/ssa/components/dynamicreport.tsx","./src/modules/ssa/components/modetoggle.tsx","./src/modules/ssa/components/ssachatpane.tsx","./src/modules/ssa/components/ssacodemodal.tsx","./src/modules/ssa/components/ssasidebar.tsx","./src/modules/ssa/components/ssatoast.tsx","./src/modules/ssa/components/ssaworkspacepane.tsx","./src/modules/ssa/components/stepprogresscard.tsx","./src/modules/ssa/components/typewriter.tsx","./src/modules/ssa/components/variabledetailpanel.tsx","./src/modules/ssa/components/variabledictionarypanel.tsx","./src/modules/ssa/components/workflowtimeline.tsx","./src/modules/ssa/components/index.ts","./src/modules/ssa/hooks/index.ts","./src/modules/ssa/hooks/useanalysis.ts","./src/modules/ssa/hooks/useartifactparser.ts","./src/modules/ssa/hooks/usessachat.ts","./src/modules/ssa/hooks/useworkflow.ts","./src/modules/ssa/stores/ssastore.ts","./src/modules/ssa/types/index.ts","./src/modules/ssa/utils/exportblockstoword.ts","./src/modules/st/index.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/admin/activitylogspage.tsx","./src/pages/admin/admindashboard.tsx","./src/pages/admin/prompteditorpage.tsx","./src/pages/admin/promptlistpage.tsx","./src/pages/admin/api/activityapi.ts","./src/pages/admin/api/promptapi.ts","./src/pages/admin/components/prompteditor.tsx","./src/pages/admin/tenants/tenantdetailpage.tsx","./src/pages/admin/tenants/tenantlistpage.tsx","./src/pages/admin/tenants/api/tenantapi.ts","./src/pages/org/orgdashboard.tsx","./src/pages/user/profilepage.tsx","./src/shared/components/placeholder.tsx","./src/shared/components/index.ts","./src/shared/components/chat/aistreamchat.tsx","./src/shared/components/chat/chatcontainer.tsx","./src/shared/components/chat/codeblockrenderer.tsx","./src/shared/components/chat/conversationlist.tsx","./src/shared/components/chat/messagerenderer.tsx","./src/shared/components/chat/thinkingblock.tsx","./src/shared/components/chat/index.ts","./src/shared/components/chat/types.ts","./src/shared/components/chat/hooks/index.ts","./src/shared/components/chat/hooks/useaistream.ts","./src/shared/components/chat/hooks/useconversations.ts","./src/shared/components/layout/resizablesplitpane.tsx","./src/shared/components/layout/index.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/common/api/axios.ts","./src/framework/auth/authcontext.tsx","./src/framework/auth/api.ts","./src/framework/auth/index.ts","./src/framework/auth/moduleapi.ts","./src/framework/auth/sessionguard.ts","./src/framework/auth/types.ts","./src/framework/auth/useauthheartbeat.ts","./src/framework/layout/adminlayout.tsx","./src/framework/layout/mainlayout.tsx","./src/framework/layout/orglayout.tsx","./src/framework/layout/topnavigation.tsx","./src/framework/modules/errorboundary.tsx","./src/framework/modules/moduleerrorfallback.tsx","./src/framework/modules/moduleregistry.ts","./src/framework/modules/types.ts","./src/framework/permission/permissioncontext.tsx","./src/framework/permission/index.ts","./src/framework/permission/types.ts","./src/framework/permission/usepermission.ts","./src/framework/router/permissiondenied.tsx","./src/framework/router/routeguard.tsx","./src/framework/router/index.ts","./src/modules/admin/index.tsx","./src/modules/admin/api/iitprojectapi.ts","./src/modules/admin/api/statsapi.ts","./src/modules/admin/api/systemkbapi.ts","./src/modules/admin/api/userapi.ts","./src/modules/admin/components/assigntenantmodal.tsx","./src/modules/admin/components/importusermodal.tsx","./src/modules/admin/components/modulepermissionmodal.tsx","./src/modules/admin/components/qc-cockpit/qcdetaildrawer.tsx","./src/modules/admin/components/qc-cockpit/qcreportdrawer.tsx","./src/modules/admin/components/qc-cockpit/qcstatcards.tsx","./src/modules/admin/components/qc-cockpit/riskheatmap.tsx","./src/modules/admin/components/qc-cockpit/index.ts","./src/modules/admin/pages/iitmembermanagepage.tsx","./src/modules/admin/pages/iitprojectdetailpage.tsx","./src/modules/admin/pages/iitprojectlistpage.tsx","./src/modules/admin/pages/iitqccockpitpage.tsx","./src/modules/admin/pages/statsdashboardpage.tsx","./src/modules/admin/pages/systemkbdetailpage.tsx","./src/modules/admin/pages/systemkblistpage.tsx","./src/modules/admin/pages/userdetailpage.tsx","./src/modules/admin/pages/userformpage.tsx","./src/modules/admin/pages/userlistpage.tsx","./src/modules/admin/types/iitproject.ts","./src/modules/admin/types/qccockpit.ts","./src/modules/admin/types/systemkb.ts","./src/modules/admin/types/user.ts","./src/modules/aia/constants.ts","./src/modules/aia/index.tsx","./src/modules/aia/types.ts","./src/modules/aia/components/agentcard.tsx","./src/modules/aia/components/agenthub.tsx","./src/modules/aia/components/chatworkspace.tsx","./src/modules/aia/components/index.ts","./src/modules/aia/protocol-agent/protocolagentpage.tsx","./src/modules/aia/protocol-agent/index.ts","./src/modules/aia/protocol-agent/types.ts","./src/modules/aia/protocol-agent/components/actioncard.tsx","./src/modules/aia/protocol-agent/components/chatarea.tsx","./src/modules/aia/protocol-agent/components/documentpanel.tsx","./src/modules/aia/protocol-agent/components/markdowncontent.tsx","./src/modules/aia/protocol-agent/components/reflexionmessage.tsx","./src/modules/aia/protocol-agent/components/resizablesplitpane.tsx","./src/modules/aia/protocol-agent/components/stagecard.tsx","./src/modules/aia/protocol-agent/components/stageeditmodal.tsx","./src/modules/aia/protocol-agent/components/statepanel.tsx","./src/modules/aia/protocol-agent/components/syncbutton.tsx","./src/modules/aia/protocol-agent/components/viewswitcher.tsx","./src/modules/aia/protocol-agent/components/index.ts","./src/modules/aia/protocol-agent/hooks/index.ts","./src/modules/aia/protocol-agent/hooks/useprotocolcontext.ts","./src/modules/aia/protocol-agent/hooks/useprotocolconversations.ts","./src/modules/aia/protocol-agent/hooks/useprotocolgeneration.ts","./src/modules/asl/index.tsx","./src/modules/asl/api/index.ts","./src/modules/asl/components/asllayout.tsx","./src/modules/asl/components/conclusiontag.tsx","./src/modules/asl/components/detailreviewdrawer.tsx","./src/modules/asl/components/fulltextdetaildrawer.tsx","./src/modules/asl/components/judgmentbadge.tsx","./src/modules/asl/components/charting/baselinetable.tsx","./src/modules/asl/components/charting/datasourceselector.tsx","./src/modules/asl/components/charting/prismaflowdiagram.tsx","./src/modules/asl/components/deep-research/agentterminal.tsx","./src/modules/asl/components/deep-research/landingview.tsx","./src/modules/asl/components/deep-research/resultsview.tsx","./src/modules/asl/components/deep-research/setuppanel.tsx","./src/modules/asl/components/deep-research/strategyconfirm.tsx","./src/modules/asl/components/extraction/extractiondrawer.tsx","./src/modules/asl/components/extraction/extractionstatusbadge.tsx","./src/modules/asl/components/extraction/fieldgroup.tsx","./src/modules/asl/components/extraction/processingterminal.tsx","./src/modules/asl/components/extraction/quoteblock.tsx","./src/modules/asl/components/meta/resultspanel.tsx","./src/modules/asl/hooks/usedeepresearchtask.ts","./src/modules/asl/hooks/useextractionlogs.ts","./src/modules/asl/hooks/usefulltextresults.ts","./src/modules/asl/hooks/usefulltexttask.ts","./src/modules/asl/hooks/usescreeningresults.ts","./src/modules/asl/hooks/usescreeningtask.ts","./src/modules/asl/pages/deepresearchpage.tsx","./src/modules/asl/pages/extractionpage.tsx","./src/modules/asl/pages/extractionprogress.tsx","./src/modules/asl/pages/extractionsetup.tsx","./src/modules/asl/pages/extractionworkbench.tsx","./src/modules/asl/pages/fulltextprogress.tsx","./src/modules/asl/pages/fulltextresults.tsx","./src/modules/asl/pages/fulltextsettings.tsx","./src/modules/asl/pages/fulltextworkbench.tsx","./src/modules/asl/pages/metaanalysisengine.tsx","./src/modules/asl/pages/researchsearch.tsx","./src/modules/asl/pages/srchartgenerator.tsx","./src/modules/asl/pages/screeningresults.tsx","./src/modules/asl/pages/screeningworkbench.tsx","./src/modules/asl/pages/titlescreeningsettings.tsx","./src/modules/asl/types/deepresearch.ts","./src/modules/asl/types/index.ts","./src/modules/asl/utils/chartingexcelutils.ts","./src/modules/asl/utils/excelexport.ts","./src/modules/asl/utils/excelutils.ts","./src/modules/asl/utils/metaexcelutils.ts","./src/modules/asl/utils/tabletransform.ts","./src/modules/dc/index.tsx","./src/modules/dc/api/toolb.ts","./src/modules/dc/api/toolc.ts","./src/modules/dc/components/assetlibrary.tsx","./src/modules/dc/components/tasklist.tsx","./src/modules/dc/components/toolcard.tsx","./src/modules/dc/hooks/useassets.ts","./src/modules/dc/hooks/userecenttasks.ts","./src/modules/dc/pages/portal.tsx","./src/modules/dc/pages/tool-b/step1upload.tsx","./src/modules/dc/pages/tool-b/step2schema.tsx","./src/modules/dc/pages/tool-b/step3processing.tsx","./src/modules/dc/pages/tool-b/step4verify.tsx","./src/modules/dc/pages/tool-b/step5result.tsx","./src/modules/dc/pages/tool-b/index.tsx","./src/modules/dc/pages/tool-b/components/stepindicator.tsx","./src/modules/dc/pages/tool-c/index.tsx","./src/modules/dc/pages/tool-c/components/binningdialog.tsx","./src/modules/dc/pages/tool-c/components/binningdialog_improved.tsx","./src/modules/dc/pages/tool-c/components/computedialog.tsx","./src/modules/dc/pages/tool-c/components/conditionaldialog.tsx","./src/modules/dc/pages/tool-c/components/datagrid.tsx","./src/modules/dc/pages/tool-c/components/dropnadialog.tsx","./src/modules/dc/pages/tool-c/components/filterdialog.tsx","./src/modules/dc/pages/tool-c/components/header.tsx","./src/modules/dc/pages/tool-c/components/metrictimepanel.tsx","./src/modules/dc/pages/tool-c/components/missingvaluedialog.tsx","./src/modules/dc/pages/tool-c/components/multimetricpanel.tsx","./src/modules/dc/pages/tool-c/components/pivotdialog.tsx","./src/modules/dc/pages/tool-c/components/pivotpanel.tsx","./src/modules/dc/pages/tool-c/components/recodedialog.tsx","./src/modules/dc/pages/tool-c/components/sidebar.tsx","./src/modules/dc/pages/tool-c/components/streamingsteps.tsx","./src/modules/dc/pages/tool-c/components/toolbar.tsx","./src/modules/dc/pages/tool-c/components/transformdialog.tsx","./src/modules/dc/pages/tool-c/components/unpivotpanel.tsx","./src/modules/dc/pages/tool-c/hooks/usesessionstatus.ts","./src/modules/dc/pages/tool-c/types/index.ts","./src/modules/dc/types/portal.ts","./src/modules/iit/iitlayout.tsx","./src/modules/iit/index.tsx","./src/modules/iit/api/iitprojectapi.ts","./src/modules/iit/components/ruletemplatebuilder.tsx","./src/modules/iit/components/variablepicker.tsx","./src/modules/iit/components/reports/completenesstable.tsx","./src/modules/iit/components/reports/deviationlogtable.tsx","./src/modules/iit/components/reports/eligibilitytable.tsx","./src/modules/iit/components/reports/equerylogtable.tsx","./src/modules/iit/config/projectdetailpage.tsx","./src/modules/iit/config/projectlistpage.tsx","./src/modules/iit/context/iitprojectcontext.tsx","./src/modules/iit/pages/aichatpage.tsx","./src/modules/iit/pages/aistreampage.tsx","./src/modules/iit/pages/dashboardpage.tsx","./src/modules/iit/pages/equerypage.tsx","./src/modules/iit/pages/reportspage.tsx","./src/modules/iit/pages/variablelistpage.tsx","./src/modules/iit/types/iitproject.ts","./src/modules/iit/types/qccockpit.ts","./src/modules/legacy/legacysystempage.tsx","./src/modules/legacy/researchmanagement.tsx","./src/modules/legacy/statisticaltools.tsx","./src/modules/pkb/index.tsx","./src/modules/pkb/api/knowledgebaseapi.ts","./src/modules/pkb/components/createkbdialog.tsx","./src/modules/pkb/components/documentlist.tsx","./src/modules/pkb/components/documentupload.tsx","./src/modules/pkb/components/editkbdialog.tsx","./src/modules/pkb/components/knowledgebaselist.tsx","./src/modules/pkb/components/workspace/batchmode.tsx","./src/modules/pkb/components/workspace/batchmodecomplete.tsx","./src/modules/pkb/components/workspace/deepreadmode.tsx","./src/modules/pkb/components/workspace/fulltextmode.tsx","./src/modules/pkb/components/workspace/workmodeselector.tsx","./src/modules/pkb/hooks/useworkmode.ts","./src/modules/pkb/pages/dashboardpage.tsx","./src/modules/pkb/pages/knowledgepage.tsx","./src/modules/pkb/pages/workspacepage.tsx","./src/modules/pkb/stores/useknowledgebasestore.ts","./src/modules/pkb/types/workspace.ts","./src/modules/rvw/index.tsx","./src/modules/rvw/api/index.ts","./src/modules/rvw/components/agentmodal.tsx","./src/modules/rvw/components/batchtoolbar.tsx","./src/modules/rvw/components/clinicalreport.tsx","./src/modules/rvw/components/editorialreport.tsx","./src/modules/rvw/components/filterchips.tsx","./src/modules/rvw/components/forensicsreport.tsx","./src/modules/rvw/components/header.tsx","./src/modules/rvw/components/methodologyreport.tsx","./src/modules/rvw/components/reportdetail.tsx","./src/modules/rvw/components/scorering.tsx","./src/modules/rvw/components/sidebar.tsx","./src/modules/rvw/components/taskdetail.tsx","./src/modules/rvw/components/tasktable.tsx","./src/modules/rvw/components/index.ts","./src/modules/rvw/pages/dashboard.tsx","./src/modules/rvw/types/index.ts","./src/modules/ssa/ssaworkspace.tsx","./src/modules/ssa/index.tsx","./src/modules/ssa/components/agentcodepanel.tsx","./src/modules/ssa/components/askusercard.tsx","./src/modules/ssa/components/clarificationcard.tsx","./src/modules/ssa/components/conclusionreport.tsx","./src/modules/ssa/components/datacontextcard.tsx","./src/modules/ssa/components/dataprofilecard.tsx","./src/modules/ssa/components/dataprofilemodal.tsx","./src/modules/ssa/components/dynamicreport.tsx","./src/modules/ssa/components/modetoggle.tsx","./src/modules/ssa/components/ssachatpane.tsx","./src/modules/ssa/components/ssacodemodal.tsx","./src/modules/ssa/components/ssasidebar.tsx","./src/modules/ssa/components/ssatoast.tsx","./src/modules/ssa/components/ssaworkspacepane.tsx","./src/modules/ssa/components/stepprogresscard.tsx","./src/modules/ssa/components/typewriter.tsx","./src/modules/ssa/components/variabledetailpanel.tsx","./src/modules/ssa/components/variabledictionarypanel.tsx","./src/modules/ssa/components/workflowtimeline.tsx","./src/modules/ssa/components/index.ts","./src/modules/ssa/hooks/index.ts","./src/modules/ssa/hooks/useanalysis.ts","./src/modules/ssa/hooks/useartifactparser.ts","./src/modules/ssa/hooks/usessachat.ts","./src/modules/ssa/hooks/useworkflow.ts","./src/modules/ssa/stores/ssastore.ts","./src/modules/ssa/types/index.ts","./src/modules/ssa/utils/exportblockstoword.ts","./src/modules/st/index.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/admin/activitylogspage.tsx","./src/pages/admin/admindashboard.tsx","./src/pages/admin/prompteditorpage.tsx","./src/pages/admin/promptlistpage.tsx","./src/pages/admin/api/activityapi.ts","./src/pages/admin/api/promptapi.ts","./src/pages/admin/components/prompteditor.tsx","./src/pages/admin/tenants/tenantdetailpage.tsx","./src/pages/admin/tenants/tenantlistpage.tsx","./src/pages/admin/tenants/api/tenantapi.ts","./src/pages/org/orgdashboard.tsx","./src/pages/user/profilepage.tsx","./src/shared/components/placeholder.tsx","./src/shared/components/index.ts","./src/shared/components/chat/aistreamchat.tsx","./src/shared/components/chat/chatcontainer.tsx","./src/shared/components/chat/codeblockrenderer.tsx","./src/shared/components/chat/conversationlist.tsx","./src/shared/components/chat/messagerenderer.tsx","./src/shared/components/chat/thinkingblock.tsx","./src/shared/components/chat/index.ts","./src/shared/components/chat/types.ts","./src/shared/components/chat/hooks/index.ts","./src/shared/components/chat/hooks/useaistream.ts","./src/shared/components/chat/hooks/useconversations.ts","./src/shared/components/layout/resizablesplitpane.tsx","./src/shared/components/layout/index.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file diff --git a/r-statistics-service/plumber.R b/r-statistics-service/plumber.R index 3048f588..9c9b8581 100644 --- a/r-statistics-service/plumber.R +++ b/r-statistics-service/plumber.R @@ -221,9 +221,10 @@ function(req) { message(glue::glue("[ExecuteCode] session={session_id}, code_length={nchar(code)}, timeout={timeout_sec}s")) - # ── AST 语法预检:parse() 先于 eval(),快速捕获语法错误 ── + # ── AST + 安全双层预检:语法检查 + 危险调用拦截 ── + parsed_code <- NULL ast_check <- tryCatch({ - parse(text = code) + parsed_code <<- parse(text = code) NULL }, error = function(e) { e$message @@ -252,8 +253,32 @@ function(req) { duration_ms = 0 )) } + + # 安全预检(静态扫描,MVP) + # 注:为减少误报,先粗略移除注释行再扫描 + code_for_scan <- gsub("(?m)^\\s*#.*$", "", code, perl = TRUE) + forbidden_pattern <- "(^|[^[:alnum:]_\\.])((base::)?system|(base::)?eval|(base::)?parse|(base::)?source|file\\.remove|setwd|download\\.file|readLines|writeLines)\\s*\\(" + security_hit <- regexpr(forbidden_pattern, code_for_scan, perl = TRUE, ignore.case = TRUE) + if (security_hit[1] != -1) { + hit_text <- regmatches(code_for_scan, security_hit)[1] + return(list( + status = "error", + error_code = "E_SECURITY", + error_type = "security", + message = paste0("Security Violation: Detected forbidden function call: ", hit_text), + user_hint = "代码包含高风险函数调用(如 system/eval/source/file.remove/setwd),已被系统拦截", + console_output = list(), + duration_ms = 0 + )) + } sandbox_env <- new.env(parent = globalenv()) + # 运行时保护:即使静态扫描漏检,也在沙箱层阻断关键高风险调用 + sandbox_env$system <- function(...) stop("Security Violation: function 'system' is forbidden.") + sandbox_env$eval <- function(...) stop("Security Violation: function 'eval' is forbidden.") + sandbox_env$source <- function(...) stop("Security Violation: function 'source' is forbidden.") + sandbox_env$setwd <- function(...) stop("Security Violation: function 'setwd' is forbidden.") + sandbox_env$file.remove <- function(...) stop("Security Violation: function 'file.remove' is forbidden.") if (!is.null(session_id) && nchar(session_id) > 0) { sandbox_env$SESSION_ID <- session_id @@ -269,7 +294,7 @@ function(req) { { captured_output <- utils::capture.output({ result <- withCallingHandlers( - eval(parse(text = code), envir = sandbox_env), + eval(parsed_code, envir = sandbox_env), warning = function(w) { collected_warnings[[length(collected_warnings) + 1]] <<- w$message invokeRestart("muffleWarning")