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:
2026-03-11 22:49:05 +08:00
parent d3b24bd8c3
commit 6edfad032f
19 changed files with 2105 additions and 158 deletions

View File

@@ -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;

View File

@@ -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")

View File

@@ -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;

View 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,
});
},
);
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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
*/

View File

@@ -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 前端** | useSSAChatanalysis_plan+plan_confirmed SSE 处理+pendingPlanConfirm→executeWorkflow+ SSAChatPaneAskUserCard 渲染+幽灵卡片清除 H2 | ✅ |
| **Phase V-A 后端** | PATCH /workflow/:id/paramsZod 结构校验防火墙)+ tool_param_constraints.json12 工具参数约束)+ 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()` 严格正则
- 防御性 PromptNA 处理 / 类型转换 / 因子水平检查 / 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可观测性

View 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 代码排除了 genderP>0.1
- 医生凭临床常识,点击 Step 3 的"修改此步骤",输入"请把 Gender 也纳入模型作为混杂因素"
- 系统仅重新生成并执行 Step 3-4Step 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 AgentCoderAgent 内置重试 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 深度扫描以降低绕过风险。

View File

@@ -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_*` 事件,前端控制台是否有解析错误。

View File

@@ -4,7 +4,7 @@
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
> **最后清零**: 2026-03-100310 部署完成后清零)
> **本次变更**: 无(当前待部署清单已清零
> **本次变更**: 已新增待部署项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 等风险调用 |
### 环境变量 / 配置变更

View File

@@ -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" />

View File

@@ -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 && (

View File

@@ -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);

View File

@@ -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: '❌ 已取消当前分析',
};

View File

@@ -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,

View File

@@ -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

View File

@@ -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")