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
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
183
backend/src/modules/ssa/routes/agent-execution.routes.ts
Normal file
183
backend/src/modules/ssa/routes/agent-execution.routes.ts
Normal file
@@ -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<string, Record<string, ParamRule>>;
|
||||
|
||||
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<string, unknown> }> };
|
||||
}>(
|
||||
'/: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<any>;
|
||||
const columnTypeMap = new Map<string, 'numeric' | 'categorical' | 'other'>(
|
||||
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<string, unknown>;
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<GeneratedCode> {
|
||||
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<string> {
|
||||
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. **必须将修正后的完整代码放在 <r_code>...</r_code> 标签中**`;
|
||||
7. **严禁使用未预装包**(尤其是 pROC、nortest、exact2x2);如涉及 ROC/AUC,请使用基础 R 实现,不要写 pROC::*
|
||||
8. 若报错包含 "unexpected 'if'" 或语法错误,必须检查并修复所有单行拼接错误:表达式后面的 if/else 必须换行并带完整代码块
|
||||
9. **必须将修正后的完整代码放在 <r_code>...</r_code> 标签中**`;
|
||||
}
|
||||
|
||||
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. 必须使用 <r_code>...</r_code> 包裹完整代码`;
|
||||
}
|
||||
|
||||
private buildStepRetryMessage(
|
||||
plan: AgentPlan,
|
||||
step: AgentPlan['steps'][number],
|
||||
previousResults: StepResultSummary[],
|
||||
errorFeedback: string,
|
||||
previousCode?: string,
|
||||
): string {
|
||||
const codeSection = previousCode
|
||||
? `## 上次失败代码
|
||||
<previous_code>
|
||||
${previousCode}
|
||||
</previous_code>`
|
||||
: '';
|
||||
|
||||
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}
|
||||
|
||||
## 错误信息
|
||||
<error>
|
||||
${errorFeedback}
|
||||
</error>
|
||||
|
||||
## 修复要求
|
||||
1. 只修复当前步骤代码,不生成其他步骤代码
|
||||
2. 检查并修复语法、对象存在性、类型转换、缺失值处理
|
||||
3. 禁止未安装包,禁止 system/eval/source 等高危调用
|
||||
4. 仍需返回包含 report_blocks 的 list
|
||||
5. 必须用 <r_code>...</r_code> 输出完整代码`;
|
||||
}
|
||||
|
||||
private parseCode(content: string): GeneratedCode {
|
||||
|
||||
@@ -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<string, unknown> } {
|
||||
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<HandleResult> {
|
||||
const sendEvent = (type: string, data: Record<string, any>) => {
|
||||
writer.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'coding' },
|
||||
});
|
||||
|
||||
sendEvent('code_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<HandleResult> {
|
||||
@@ -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<string> {
|
||||
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(
|
||||
|
||||
@@ -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<CodeExecutionResult> {
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -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 标签提取:强制 `<r_code>...</r_code>` 标签 + `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(可观测性)
|
||||
|
||||
623
docs/03-业务模块/SSA-智能统计分析/04-开发计划/12-Plan-and-Execute分步执行架构开发计划.md
Normal file
623
docs/03-业务模块/SSA-智能统计分析/04-开发计划/12-Plan-and-Execute分步执行架构开发计划.md
Normal file
@@ -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 │ │
|
||||
├──────────────────────────────► │ │
|
||||
│ │ 返回 <r_code>...</r_code> │
|
||||
│ ◄──────────────────────────────┤ │
|
||||
│ │
|
||||
│ 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 改为要求 `<r_code>...</r_code>` 标签包裹代码:
|
||||
|
||||
```text
|
||||
你必须且只能将 R 代码放在 <r_code> 和 </r_code> 标签之间。
|
||||
标签外面禁止出现任何代码。标签里面禁止出现任何自然语言解释。
|
||||
```
|
||||
|
||||
`parseCode()` 方法改为正则提取 `<r_code>` 内容,fallback 到 markdown 代码块:
|
||||
|
||||
```typescript
|
||||
const xmlMatch = content.match(/<r_code>([\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<AgentStepResult>
|
||||
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。之前步骤的代码已执行,变量可直接使用。"
|
||||
// 要求: 只生成当前步骤的代码,以 <r_code> 标签包裹
|
||||
}
|
||||
```
|
||||
|
||||
**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 `当前步骤的代码执行失败。
|
||||
|
||||
<original_code>${failedCode}</original_code>
|
||||
<error_log>${errorDetail}</error_log>
|
||||
<data_schema>${dataSchema}</data_schema>
|
||||
|
||||
请先分析错误的根本原因,然后输出修复后的完整代码(用 <r_code> 标签包裹)。`;
|
||||
}
|
||||
```
|
||||
|
||||
**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 深度扫描以降低绕过风险。
|
||||
|
||||
@@ -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_*` 事件,前端控制台是否有解析错误。
|
||||
@@ -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 等风险调用 |
|
||||
|
||||
### 环境变量 / 配置变更
|
||||
|
||||
|
||||
@@ -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<AgentExecutionStatus, string> = {
|
||||
@@ -49,8 +64,24 @@ const StatusBadge: React.FC<{ status: AgentExecutionStatus }> = ({ status }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, actionLoading }) => {
|
||||
const { agentExecution, executionMode } = useSSAStore();
|
||||
const STEP_STATUS_LABEL: Record<AgentStepStatus, string> = {
|
||||
pending: '待执行',
|
||||
coding: '生成代码中',
|
||||
executing: '执行中',
|
||||
completed: '已完成',
|
||||
error: '失败',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({
|
||||
onAction,
|
||||
actionLoading,
|
||||
variableDictionary = [],
|
||||
dataOverviewColumns = [],
|
||||
}) => {
|
||||
const { agentExecution, executionMode, addToast } = useSSAStore();
|
||||
const [paramDrafts, setParamDrafts] = useState<Record<number, Record<string, unknown>>>({});
|
||||
const [savingParams, setSavingParams] = useState(false);
|
||||
|
||||
if (executionMode !== 'agent') return null;
|
||||
|
||||
@@ -69,10 +100,28 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ 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<Array<{
|
||||
order: number;
|
||||
method: string;
|
||||
description: string;
|
||||
rationale?: string;
|
||||
toolCode?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}> | undefined>(() => {
|
||||
if (rawPlanSteps && rawPlanSteps.length > 0) return rawPlanSteps;
|
||||
if (!planText) return undefined;
|
||||
|
||||
@@ -98,6 +147,8 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ 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<AgentCodePanelProps> = ({ onAction, action
|
||||
|
||||
const isStreamingCode = status === 'coding' && !!partialCode;
|
||||
const displayCode = isStreamingCode ? partialCode : (generatedCode || partialCode);
|
||||
const varsMap = React.useMemo(() => {
|
||||
const map = new Map<string, VarInfo>();
|
||||
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 (
|
||||
<div className="agent-code-panel">
|
||||
@@ -186,6 +330,46 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
|
||||
<span className="step-method">{s.method}</span>
|
||||
<span className="step-desc">{s.description}</span>
|
||||
{s.rationale && <span className="step-rationale">{s.rationale}</span>}
|
||||
{status === 'plan_pending' && s.params && Object.keys(s.params).length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'grid', gap: 8 }}>
|
||||
{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 (
|
||||
<div key={k}>
|
||||
<div className="step-rationale" style={{ marginBottom: 4 }}>{PARAM_LABELS[k] || k}</div>
|
||||
{isSingle ? (
|
||||
<SingleVarSelect
|
||||
value={typeof value === 'string' ? value : null}
|
||||
constraint={rule}
|
||||
varsMap={varsMap}
|
||||
allVars={allVars}
|
||||
onChange={(v) => updateDraft(s.order, k, v)}
|
||||
/>
|
||||
) : isMulti ? (
|
||||
<MultiVarTags
|
||||
values={Array.isArray(value) ? value.map(String) : []}
|
||||
constraint={rule}
|
||||
varsMap={varsMap}
|
||||
allVars={allVars}
|
||||
onChange={(v) => updateDraft(s.order, k, v)}
|
||||
/>
|
||||
) : (
|
||||
<span className="step-rationale">{String(value ?? '—')}</span>
|
||||
)}
|
||||
{mismatch && <span className="step-rationale" style={{ color: '#dc2626' }}>{mismatch}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -200,10 +384,21 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
|
||||
{/* 计划确认操作按钮 */}
|
||||
{status === 'plan_pending' && onAction && (
|
||||
<div className="agent-action-bar">
|
||||
{hasDrafts && (
|
||||
<button
|
||||
className="agent-action-btn secondary"
|
||||
onClick={savePlanParams}
|
||||
disabled={actionLoading || savingParams}
|
||||
title="保存变量修改"
|
||||
>
|
||||
{savingParams ? <Loader2 size={14} className="spin" /> : <Save size={14} />}
|
||||
保存变量修改
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="agent-action-btn primary"
|
||||
onClick={() => onAction('confirm_plan')}
|
||||
disabled={actionLoading}
|
||||
onClick={handleConfirmPlan}
|
||||
disabled={actionLoading || savingParams}
|
||||
>
|
||||
{actionLoading ? <Loader2 size={14} className="spin" /> : <CheckCircle size={14} />}
|
||||
确认计划,生成代码
|
||||
@@ -221,8 +416,49 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: R 代码 */}
|
||||
{(displayCode || status === 'coding') && (
|
||||
{/* Step-by-step 执行状态(Phase 5B) */}
|
||||
{stepResults && stepResults.length > 0 && (
|
||||
<div className="agent-section">
|
||||
<div className="agent-section-title">
|
||||
<Sparkles size={13} />
|
||||
<span>分步执行状态</span>
|
||||
</div>
|
||||
<div className="agent-plan-steps">
|
||||
{stepResults
|
||||
.slice()
|
||||
.sort((a, b) => a.stepOrder - b.stepOrder)
|
||||
.map((s, i) => (
|
||||
<div key={i} className="plan-step-item">
|
||||
<span className="step-num">{s.stepOrder}</span>
|
||||
<div className="plan-step-body">
|
||||
<span className="step-method">{s.method || `Step ${s.stepOrder}`}</span>
|
||||
<span className="step-desc">
|
||||
{STEP_STATUS_LABEL[s.status]}
|
||||
{currentStep === s.stepOrder && ['coding', 'executing'].includes(s.status) ? ' · 当前步骤' : ''}
|
||||
{typeof s.durationMs === 'number' ? ` · ${(s.durationMs / 1000).toFixed(1)}s` : ''}
|
||||
</span>
|
||||
{!!s.errorMessage && <span className="step-rationale">{s.errorMessage}</span>}
|
||||
{s.status === 'completed' && s.reportBlocks && s.reportBlocks.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer', color: '#93c5fd' }}>
|
||||
查看该步骤结果({s.reportBlocks.length} 个模块)
|
||||
</summary>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<DynamicReport blocks={s.reportBlocks} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: R 代码(严格分步模式下展示首步预览;后续步骤执行时逐步生成) */}
|
||||
{(displayCode || ['coding', 'code_pending', 'executing', 'completed', 'error'].includes(status)) && (
|
||||
<div className="agent-section">
|
||||
<div className="agent-section-title">
|
||||
<Code size={13} />
|
||||
@@ -234,6 +470,11 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
|
||||
<div className="agent-code-body">
|
||||
{displayCode ? (
|
||||
<pre className={isStreamingCode ? 'streaming' : ''}>{displayCode}</pre>
|
||||
) : status === 'code_pending' ? (
|
||||
<div className="agent-code-loading">
|
||||
<Sparkles size={16} />
|
||||
<span>当前为分步执行模式:后续步骤代码将在执行阶段逐步生成。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-code-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
|
||||
@@ -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 = () => {
|
||||
<AgentCodePanel
|
||||
onAction={handleAgentAction}
|
||||
actionLoading={agentActionLoading}
|
||||
variableDictionary={variableDictionary as VariableDictEntry[]}
|
||||
dataOverviewColumns={dataOverviewColumns as DataOverviewColumn[]}
|
||||
/>
|
||||
{/* Agent 模式的报告输出复用 DynamicReport */}
|
||||
{agentExecution?.status === 'completed' && agentExecution.reportBlocks && agentExecution.reportBlocks.length > 0 && (
|
||||
|
||||
@@ -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<string, Record<string, ParamConstraint>>;
|
||||
export type ToolConstraints = Record<string, Record<string, ParamConstraint>>;
|
||||
|
||||
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<string, VarInfo>
|
||||
@@ -202,7 +202,7 @@ const formatValue = (value: unknown): string => {
|
||||
return s.length > 50 ? s.slice(0, 47) + '…' : s;
|
||||
};
|
||||
|
||||
const PARAM_LABELS: Record<string, string> = {
|
||||
export const PARAM_LABELS: Record<string, string> = {
|
||||
variables: '分析变量',
|
||||
outcome_var: '结局变量 (Y)',
|
||||
predictors: '自变量 (X)',
|
||||
@@ -231,7 +231,7 @@ interface SingleVarSelectProps {
|
||||
onChange: (v: string | null) => void;
|
||||
}
|
||||
|
||||
const SingleVarSelect: React.FC<SingleVarSelectProps> = ({ value, constraint, varsMap, allVars, onChange }) => {
|
||||
export const SingleVarSelect: React.FC<SingleVarSelectProps> = ({ value, constraint, varsMap, allVars, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -318,7 +318,7 @@ interface MultiVarTagsProps {
|
||||
onChange: (v: string[]) => void;
|
||||
}
|
||||
|
||||
const MultiVarTags: React.FC<MultiVarTagsProps> = ({ values, constraint, varsMap, allVars, onChange }) => {
|
||||
export const MultiVarTags: React.FC<MultiVarTagsProps> = ({ values, constraint, varsMap, allVars, onChange }) => {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -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<AgentStepResult>) => {
|
||||
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<AgentActionType, string> = {
|
||||
confirm_plan: '✅ 方案已确认,正在生成 R 代码...',
|
||||
confirm_plan: '✅ 方案已确认,已进入执行确认(执行时将分步生成代码)...',
|
||||
confirm_code: '✅ 代码已确认,R 引擎正在执行...',
|
||||
cancel: '❌ 已取消当前分析',
|
||||
};
|
||||
|
||||
@@ -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<SSAState>((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<SSAState>((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<SSAState>((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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}>;
|
||||
generatedCode?: string;
|
||||
partialCode?: string;
|
||||
reportBlocks?: ReportBlock[];
|
||||
retryCount: number;
|
||||
status: AgentExecutionStatus;
|
||||
stepResults?: AgentStepResult[];
|
||||
currentStep?: number;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
createdAt?: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -253,7 +254,31 @@ function(req) {
|
||||
))
|
||||
}
|
||||
|
||||
# 安全预检(静态扫描,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")
|
||||
|
||||
Reference in New Issue
Block a user