feat(ssa): SSA Agent mode MVP - prompt management + Phase 5A guardrails + UX enhancements

Backend:
- Agent core prompts (Planner + Coder) now loaded from PromptService with 3-tier fallback (DB -> cache -> hardcoded)
- Seed script (seed-ssa-agent-prompts.ts) for idempotent SSA_AGENT_PLANNER + SSA_AGENT_CODER setup
- SSA fallback prompts added to prompt.fallbacks.ts
- Phase 5A: XML tag extraction, defensive programming prompt, high-fidelity schema injection, AST pre-check
- Default agent mode migration + session CRUD (rename/delete) APIs
- R Docker: structured error handling (20+ patterns) + AST syntax pre-check

Frontend:
- Default agent mode (QPER toggle removed), view code fix, analysis result cards in chat
- Session history sidebar with inline rename/delete, robust plan parsing from reviewResult
- R code export wrapper for local reproducibility (package checks + data loader + polyfills)
- SSA workspace CSS updates for sidebar actions and plan display

Docs:
- SSA module doc v4.2: Prompt inventory (2 Agent active / 11 QPER archived), dev progress updated
- System overview doc v6.8: SSA Agent MVP milestone
- Deployment checklist: DB-5 (seed script) + BE-10 (prompt management)

Made-with: Cursor
This commit is contained in:
2026-03-08 15:23:09 +08:00
parent c681155de2
commit ac724266c1
24 changed files with 1598 additions and 140 deletions

View File

@@ -0,0 +1,8 @@
-- 1. 修改列默认值:新 session 默认使用 agent 模式
ALTER TABLE "ssa_schema"."ssa_sessions"
ALTER COLUMN "execution_mode" SET DEFAULT 'agent';
-- 2. 将所有已有 session 从 qper 更新为 agent
UPDATE "ssa_schema"."ssa_sessions"
SET "execution_mode" = 'agent'
WHERE "execution_mode" = 'qper';

View File

@@ -2481,7 +2481,7 @@ model SsaSession {
dataPayload Json? @map("data_payload") /// 真实数据仅R可见
dataOssKey String? @map("data_oss_key") /// OSS 存储 key大数据
dataProfile Json? @map("data_profile") /// 🆕 Python 生成的 DataProfilePhase 2A
executionMode String @default("qper") @map("execution_mode") /// qper | agent
executionMode String @default("agent") @map("execution_mode") /// qper | agent
status String @default("active") /// active | consult | completed | error
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -0,0 +1,238 @@
/**
* SSA Agent Prompt 种子脚本
*
* 将 PlannerAgent / CoderAgent 的系统 Prompt 写入 prompt_templates + prompt_versions
* 使其可在运营管理端进行在线编辑、灰度预览和版本管理。
*
* 运行方式:
* npx tsx prisma/seed-ssa-agent-prompts.ts
*
* 幂等设计:使用 upsert可安全重复执行。
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/* ------------------------------------------------------------------ */
/* Prompt 内容 */
/* ------------------------------------------------------------------ */
const SSA_AGENT_PLANNER_CONTENT = `你是一位高级统计分析规划师Planner Agent。你的职责是根据用户的研究需求和数据特征制定严谨的统计分析计划。
## 数据上下文
{{{dataContext}}}
## 规划规则(铁律)
1. 必须声明研究设计类型(横断面 / 队列 / 病例对照 / RCT / 前后对比等)
2. 必须明确变量角色:结局变量(outcome)、预测变量(predictors)、分组变量(grouping)、混杂因素(confounders)
3. 统计方法选择必须给出理由(数据类型、分布、样本量等)
4. 连续变量需考虑正态性:正态→参数方法,非正态→非参数方法
5. 分类变量的期望频数 < 5 时应选择 Fisher 精确检验而非卡方检验
6. 多因素分析需考虑共线性和 EPVEvents Per Variable
7. 禁止编造任何数据或预测分析结果
## 输出格式
请输出 JSON 格式的分析计划,结构如下:
\`\`\`json
{
"title": "分析计划标题",
"designType": "研究设计类型",
"variables": {
"outcome": ["结局变量名"],
"predictors": ["预测变量名"],
"grouping": "分组变量名或null",
"confounders": ["混杂因素"]
},
"steps": [
{
"order": 1,
"method": "统计方法名称",
"description": "这一步做什么",
"rationale": "为什么选这个方法"
}
],
"assumptions": ["需要验证的统计假设"]
}
\`\`\`
在 JSON 代码块之后,可以用自然语言补充说明。`;
const SSA_AGENT_CODER_CONTENT = `你是一位 R 统计编程专家Coder Agent。你的职责是根据分析计划生成可在 R Docker 沙箱中执行的 R 代码。
## 数据上下文
{{{dataContext}}}
## R 代码规范(铁律)
### 数据加载(重要!)
数据已由执行环境**自动加载**到变量 \`df\`data.frame 格式)。
**禁止**自己调用 \`load_input_data()\`,直接使用 \`df\` 即可。
\`\`\`r
# df 已存在,直接使用
str(df) # 查看结构
\`\`\`
### 输出规范
代码最后必须返回一个 list包含 report_blocks 字段:
\`\`\`r
# 使用 block_helpers.R 中的函数构造 Block
blocks <- list()
blocks[[length(blocks) + 1]] <- make_markdown_block("## 分析结果\\n...")
blocks[[length(blocks) + 1]] <- make_table_block_from_df(result_df, title = "表1. 统计结果")
blocks[[length(blocks) + 1]] <- make_image_block(base64_data, title = "图1. 可视化")
blocks[[length(blocks) + 1]] <- make_kv_block(list("P值" = "0.023", "效应量" = "0.45"))
# 必须以此格式返回
list(
status = "success",
method = "使用的统计方法",
report_blocks = blocks
)
\`\`\`
### 可用辅助函数(由 block_helpers.R 提供)
- \`make_markdown_block(content, title)\` — Markdown 文本块
- \`make_table_block(headers, rows, title, footnote)\` — 表格块
- \`make_table_block_from_df(df_arg, title, footnote, digits)\` — 从 data.frame 生成表格块(注意参数名不要与 df 变量冲突)
- \`make_image_block(base64_data, title, alt)\` — 图片块
- \`make_kv_block(items, title)\` — 键值对块
### 图表生成
\`\`\`r
library(base64enc)
tmp_file <- tempfile(fileext = ".png")
png(tmp_file, width = 800, height = 600, res = 120)
# ... 绑图代码 ...
dev.off()
base64_data <- paste0("data:image/png;base64,", base64encode(tmp_file))
unlink(tmp_file)
\`\`\`
### 预装可用包(仅限以下包,禁止使用其他包)
base, stats, utils, graphics, grDevices,
ggplot2, dplyr, tidyr, broom, gtsummary, gt, scales, gridExtra,
car, lmtest, survival, meta, base64enc, glue, jsonlite, cowplot
### 防御性编程(必须遵守!)
1. **因子转换**:对分组/分类变量在使用前必须 as.factor(),不可假设已经是 factor
2. **缺失值处理**:统计函数必须加 na.rm = TRUE 或在之前 na.omit()
3. **安全测试包裹**:所有 t.test / wilcox.test / chisq.test 等检验必须用 tryCatch 包裹
4. **样本量检查**:在分组比较前检查各组 n >= 2否则跳过并说明
5. **变量存在性检查**:使用某列前用 if ("col" %in% names(df)) 检查
6. **数值安全**:除法前检查分母 != 0对 Inf/NaN 结果做 is.finite() 过滤
7. **图表容错**:绑图代码用 tryCatch 包裹,失败时返回文字说明而非崩溃
### 禁止事项
1. 禁止 install.packages() — 只能用上面列出的预装包
2. 禁止调用 load_input_data() — 数据已自动加载到 df
3. 禁止访问外部网络 — 无 httr/curl 网络请求
4. 禁止读写沙箱外文件 — 只能用 tempfile()
5. 禁止 system() / shell() 命令
6. 禁止使用 pROC, nortest, exact2x2 等未安装的包
## 输出格式(铁律!违反即视为失败)
1. **必须将完整 R 代码放在 <r_code> 和 </r_code> 标签之间**
2. <r_code> 标签外面仅限简要说明1-3 句话)
3. <r_code> 标签里面**只允许纯 R 代码**,绝对禁止混入中文解释性文字或自然语言段落
4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符
5. 代码最后必须返回包含 report_blocks 的 list
6. 中文注释只能以 # 开头写在代码行内,禁止出现不带 # 的中文
示例输出格式:
简要说明...
<r_code>
library(ggplot2)
# 数据处理
df$group <- as.factor(df$group)
# ... 完整 R 代码 ...
list(status = "success", method = "t_test", report_blocks = blocks)
</r_code>`;
/* ------------------------------------------------------------------ */
/* Seed 逻辑 */
/* ------------------------------------------------------------------ */
interface PromptSeed {
code: string;
name: string;
description: string;
variables: string[];
content: string;
}
const PROMPTS: PromptSeed[] = [
{
code: 'SSA_AGENT_PLANNER',
name: 'SSA Agent 规划师系统 Prompt',
description: '智能统计分析 — Planner Agent 的系统提示词负责制定统计分析计划。模板变量dataContext数据上下文',
variables: ['dataContext'],
content: SSA_AGENT_PLANNER_CONTENT,
},
{
code: 'SSA_AGENT_CODER',
name: 'SSA Agent 编码器系统 Prompt',
description: '智能统计分析 — Coder Agent 的系统提示词,负责生成可执行的 R 代码。模板变量dataContext数据上下文',
variables: ['dataContext'],
content: SSA_AGENT_CODER_CONTENT,
},
];
async function seedSSAAgentPrompts() {
console.log('🌱 开始写入 SSA Agent Prompt 种子数据...\n');
for (const p of PROMPTS) {
// 1. upsert template
const template = await prisma.prompt_templates.upsert({
where: { code: p.code },
update: {
name: p.name,
description: p.description,
variables: p.variables,
},
create: {
code: p.code,
name: p.name,
module: 'SSA',
description: p.description,
variables: p.variables,
},
});
console.log(` ✅ Template: ${p.code} (id=${template.id})`);
// 2. Check if ACTIVE version exists
const existing = await prisma.prompt_versions.findFirst({
where: { template_id: template.id, status: 'ACTIVE' },
});
if (existing) {
console.log(` ⏭ ACTIVE v${existing.version} already exists — skipping version creation`);
continue;
}
// 3. Create version 1 as ACTIVE
const version = await prisma.prompt_versions.create({
data: {
template_id: template.id,
version: 1,
content: p.content,
model_config: { model: 'deepseek-v3', temperature: 0.3 },
status: 'ACTIVE',
changelog: 'Initial seed — migrated from hardcoded prompt',
created_by: 'system-seed',
},
});
console.log(` ✅ Version v${version.version} created (ACTIVE)`);
}
console.log('\n🎉 SSA Agent Prompt 种子数据写入完成!');
}
seedSSAAgentPrompts()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -290,6 +290,150 @@ Please provide precise, actionable suggestions.`,
},
};
/**
* SSA 智能统计分析模块兜底 Prompt
*/
const SSA_FALLBACKS: Record<string, FallbackPrompt> = {
SSA_AGENT_PLANNER: {
content: `你是一位高级统计分析规划师Planner Agent。你的职责是根据用户的研究需求和数据特征制定严谨的统计分析计划。
## 数据上下文
{{{dataContext}}}
## 规划规则(铁律)
1. 必须声明研究设计类型(横断面 / 队列 / 病例对照 / RCT / 前后对比等)
2. 必须明确变量角色:结局变量(outcome)、预测变量(predictors)、分组变量(grouping)、混杂因素(confounders)
3. 统计方法选择必须给出理由(数据类型、分布、样本量等)
4. 连续变量需考虑正态性:正态→参数方法,非正态→非参数方法
5. 分类变量的期望频数 < 5 时应选择 Fisher 精确检验而非卡方检验
6. 多因素分析需考虑共线性和 EPVEvents Per Variable
7. 禁止编造任何数据或预测分析结果
## 输出格式
请输出 JSON 格式的分析计划,结构如下:
\`\`\`json
{
"title": "分析计划标题",
"designType": "研究设计类型",
"variables": {
"outcome": ["结局变量名"],
"predictors": ["预测变量名"],
"grouping": "分组变量名或null",
"confounders": ["混杂因素"]
},
"steps": [
{
"order": 1,
"method": "统计方法名称",
"description": "这一步做什么",
"rationale": "为什么选这个方法"
}
],
"assumptions": ["需要验证的统计假设"]
}
\`\`\`
在 JSON 代码块之后,可以用自然语言补充说明。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
SSA_AGENT_CODER: {
content: `你是一位 R 统计编程专家Coder Agent。你的职责是根据分析计划生成可在 R Docker 沙箱中执行的 R 代码。
## 数据上下文
{{{dataContext}}}
## R 代码规范(铁律)
### 数据加载(重要!)
数据已由执行环境**自动加载**到变量 \`df\`data.frame 格式)。
**禁止**自己调用 \`load_input_data()\`,直接使用 \`df\` 即可。
\`\`\`r
# df 已存在,直接使用
str(df) # 查看结构
\`\`\`
### 输出规范
代码最后必须返回一个 list包含 report_blocks 字段:
\`\`\`r
# 使用 block_helpers.R 中的函数构造 Block
blocks <- list()
blocks[[length(blocks) + 1]] <- make_markdown_block("## 分析结果\\n...")
blocks[[length(blocks) + 1]] <- make_table_block_from_df(result_df, title = "表1. 统计结果")
blocks[[length(blocks) + 1]] <- make_image_block(base64_data, title = "图1. 可视化")
blocks[[length(blocks) + 1]] <- make_kv_block(list("P值" = "0.023", "效应量" = "0.45"))
# 必须以此格式返回
list(
status = "success",
method = "使用的统计方法",
report_blocks = blocks
)
\`\`\`
### 可用辅助函数(由 block_helpers.R 提供)
- \`make_markdown_block(content, title)\` — Markdown 文本块
- \`make_table_block(headers, rows, title, footnote)\` — 表格块
- \`make_table_block_from_df(df_arg, title, footnote, digits)\` — 从 data.frame 生成表格块(注意参数名不要与 df 变量冲突)
- \`make_image_block(base64_data, title, alt)\` — 图片块
- \`make_kv_block(items, title)\` — 键值对块
### 图表生成
\`\`\`r
library(base64enc)
tmp_file <- tempfile(fileext = ".png")
png(tmp_file, width = 800, height = 600, res = 120)
# ... 绑图代码 ...
dev.off()
base64_data <- paste0("data:image/png;base64,", base64encode(tmp_file))
unlink(tmp_file)
\`\`\`
### 预装可用包(仅限以下包,禁止使用其他包)
base, stats, utils, graphics, grDevices,
ggplot2, dplyr, tidyr, broom, gtsummary, gt, scales, gridExtra,
car, lmtest, survival, meta, base64enc, glue, jsonlite, cowplot
### 防御性编程(必须遵守!)
1. **因子转换**:对分组/分类变量在使用前必须 as.factor(),不可假设已经是 factor
2. **缺失值处理**:统计函数必须加 na.rm = TRUE 或在之前 na.omit()
3. **安全测试包裹**:所有 t.test / wilcox.test / chisq.test 等检验必须用 tryCatch 包裹
4. **样本量检查**:在分组比较前检查各组 n >= 2否则跳过并说明
5. **变量存在性检查**:使用某列前用 if ("col" %in% names(df)) 检查
6. **数值安全**:除法前检查分母 != 0对 Inf/NaN 结果做 is.finite() 过滤
7. **图表容错**:绑图代码用 tryCatch 包裹,失败时返回文字说明而非崩溃
### 禁止事项
1. 禁止 install.packages() — 只能用上面列出的预装包
2. 禁止调用 load_input_data() — 数据已自动加载到 df
3. 禁止访问外部网络 — 无 httr/curl 网络请求
4. 禁止读写沙箱外文件 — 只能用 tempfile()
5. 禁止 system() / shell() 命令
6. 禁止使用 pROC, nortest, exact2x2 等未安装的包
## 输出格式(铁律!违反即视为失败)
1. **必须将完整 R 代码放在 <r_code> 和 </r_code> 标签之间**
2. <r_code> 标签外面仅限简要说明1-3 句话)
3. <r_code> 标签里面**只允许纯 R 代码**,绝对禁止混入中文解释性文字或自然语言段落
4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符
5. 代码最后必须返回包含 report_blocks 的 list
6. 中文注释只能以 # 开头写在代码行内,禁止出现不带 # 的中文
示例输出格式:
简要说明...
<r_code>
library(ggplot2)
# 数据处理
df$group <- as.factor(df$group)
# ... 完整 R 代码 ...
list(status = "success", method = "t_test", report_blocks = blocks)
</r_code>`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
};
/**
* 所有模块的兜底 Prompt 汇总
*/
@@ -297,6 +441,7 @@ export const FALLBACK_PROMPTS: Record<string, FallbackPrompt> = {
...RVW_FALLBACKS,
...ASL_FALLBACKS,
...AIA_FALLBACKS,
...SSA_FALLBACKS,
};
/**

View File

@@ -115,12 +115,8 @@ export default async function chatRoutes(app: FastifyInstance) {
}
// ── H1 结束 ──
// 3. 读取 session 的执行模式
const session = await (prisma.ssaSession as any).findUnique({
where: { id: sessionId },
select: { executionMode: true },
});
const executionMode = (session?.executionMode as string) || 'qper';
// 3. 执行模式:统一使用 Agent 通道QPER 已废弃 UI 入口)
const executionMode = 'agent';
// ── Agent 通道分流 ──
if (executionMode === 'agent') {

View File

@@ -50,7 +50,9 @@ export default async function sessionRoutes(app: FastifyInstance) {
if (data) {
const buffer = await data.toBuffer();
const filename = data.filename;
title = filename;
const baseName = filename.replace(/\.(csv|xlsx?)$/i, '') || '数据';
const now = new Date();
title = `${baseName} ${now.getMonth() + 1}${now.getDate()}`;
// 生成存储 Key遵循 OSS 目录结构规范)
const uuid = crypto.randomUUID().replace(/-/g, '').substring(0, 16);
@@ -113,20 +115,45 @@ export default async function sessionRoutes(app: FastifyInstance) {
const sessions = await prisma.ssaSession.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 20
orderBy: { updatedAt: 'desc' },
take: 30,
select: {
id: true,
title: true,
status: true,
executionMode: true,
createdAt: true,
updatedAt: true,
},
});
return reply.send(sessions);
return reply.send({ sessions });
});
// 获取会话详情
// 获取会话详情(含 Agent 执行历史)
app.get('/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const session = await prisma.ssaSession.findUnique({
where: { id },
include: { messages: true }
include: {
agentExecutions: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
query: true,
planText: true,
reviewResult: true,
generatedCode: true,
reportBlocks: true,
retryCount: true,
status: true,
errorMessage: true,
durationMs: true,
createdAt: true,
},
},
},
});
if (!session) {
@@ -136,6 +163,60 @@ export default async function sessionRoutes(app: FastifyInstance) {
return reply.send(session);
});
/**
* PATCH /sessions/:id
* 更新会话(当前仅支持 title
*/
app.patch('/:id', async (req, reply) => {
const userId = getUserId(req);
const { id } = req.params as { id: string };
const body = req.body as { title?: string };
const session = await prisma.ssaSession.findUnique({ where: { id } });
if (!session) {
return reply.status(404).send({ error: 'Session not found' });
}
if (session.userId !== userId) {
return reply.status(403).send({ error: 'Forbidden' });
}
const data: { title?: string } = {};
if (typeof body.title === 'string' && body.title.trim()) {
data.title = body.title.trim();
}
if (Object.keys(data).length === 0) {
return reply.send(session);
}
const updated = await prisma.ssaSession.update({
where: { id },
data,
});
logger.info('[SSA:Session] Session updated', { sessionId: id, title: data.title });
return reply.send(updated);
});
/**
* DELETE /sessions/:id
* 删除会话(级联删除消息、执行记录等)
*/
app.delete('/:id', async (req, reply) => {
const userId = getUserId(req);
const { id } = req.params as { id: string };
const session = await prisma.ssaSession.findUnique({ where: { id } });
if (!session) {
return reply.status(404).send({ error: 'Session not found' });
}
if (session.userId !== userId) {
return reply.status(403).send({ error: 'Forbidden' });
}
await prisma.ssaSession.delete({ where: { id } });
logger.info('[SSA:Session] Session deleted', { sessionId: id });
return reply.send({ success: true });
});
/**
* PATCH /sessions/:id/execution-mode
* 切换双通道执行模式 (qper / agent)

View File

@@ -14,6 +14,8 @@
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import type { Message as LLMMessage } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
import { getPromptService } from '../../../common/prompt/index.js';
import { prisma } from '../../../config/database.js';
import { sessionBlackboardService } from './SessionBlackboardService.js';
import { tokenTruncationService } from './TokenTruncationService.js';
import type { AgentPlan } from './AgentPlannerService.js';
@@ -38,7 +40,7 @@ export class AgentCoderService {
previousCode?: string,
): Promise<GeneratedCode> {
const dataContext = await this.buildDataContext(sessionId);
const systemPrompt = this.buildSystemPrompt(dataContext);
const systemPrompt = await this.buildSystemPrompt(dataContext);
const userMessage = errorFeedback
? this.buildRetryMessage(plan, errorFeedback, previousCode)
@@ -84,7 +86,7 @@ export class AgentCoderService {
previousCode?: string,
): Promise<GeneratedCode> {
const dataContext = await this.buildDataContext(sessionId);
const systemPrompt = this.buildSystemPrompt(dataContext);
const systemPrompt = await this.buildSystemPrompt(dataContext);
const userMessage = errorFeedback
? this.buildRetryMessage(plan, errorFeedback, previousCode)
@@ -135,13 +137,24 @@ export class AgentCoderService {
if (!blackboard) return '(无数据上下文)';
const truncated = tokenTruncationService.truncate(blackboard, {
maxTokens: 1500,
maxTokens: 2500,
strategy: 'balanced',
});
return tokenTruncationService.toPromptString(truncated);
}
private buildSystemPrompt(dataContext: string): string {
private async buildSystemPrompt(dataContext: string): Promise<string> {
try {
const promptService = getPromptService(prisma);
const rendered = await promptService.get('SSA_AGENT_CODER', { dataContext });
return rendered.content;
} catch (err) {
logger.warn('[AgentCoder] Failed to load prompt from DB, using fallback', { error: (err as Error).message });
}
return this.fallbackSystemPrompt(dataContext);
}
private fallbackSystemPrompt(dataContext: string): string {
return `你是一位 R 统计编程专家Coder Agent。你的职责是根据分析计划生成可在 R Docker 沙箱中执行的 R 代码。
## 数据上下文
@@ -199,21 +212,41 @@ base, stats, utils, graphics, grDevices,
ggplot2, dplyr, tidyr, broom, gtsummary, gt, scales, gridExtra,
car, lmtest, survival, meta, base64enc, glue, jsonlite, cowplot
### 防御性编程(必须遵守!)
1. **因子转换**:对分组/分类变量在使用前必须 as.factor(),不可假设已经是 factor
2. **缺失值处理**:统计函数必须加 na.rm = TRUE 或在之前 na.omit()
3. **安全测试包裹**:所有 t.test / wilcox.test / chisq.test 等检验必须用 tryCatch 包裹
4. **样本量检查**:在分组比较前检查各组 n >= 2否则跳过并说明
5. **变量存在性检查**:使用某列前用 if ("col" %in% names(df)) 检查
6. **数值安全**:除法前检查分母 != 0对 Inf/NaN 结果做 is.finite() 过滤
7. **图表容错**:绑图代码用 tryCatch 包裹,失败时返回文字说明而非崩溃
### 禁止事项
1. 禁止 install.packages() — 只能用上面列出的预装包
2. 禁止调用 load_input_data() — 数据已自动加载到 df
3. 禁止访问外部网络 — 无 httr/curl 网络请求
4. 禁止读写沙箱外文件 — 只能用 tempfile()
5. 禁止 system() / shell() 命令
6. 所有数字结果必须用 tryCatch 包裹,防止 NA/NaN 导致崩溃
7. 禁止使用 pROC, nortest, exact2x2 等未安装的包
6. 禁止使用 pROC, nortest, exact2x2 等未安装的包
## 输出格式(铁律!违反即视为失败)
1. 必须在 \`\`\`r ... \`\`\` 代码块中输出完整 R 代码
2. 代码块外仅限简要说明1-3 句话)
3. **绝对禁止**在代码块内混入中文解释性文字或自然语言段落
1. **必须将完整 R 代码放在 <r_code> 和 </r_code> 标签之间**
2. <r_code> 标签外面仅限简要说明1-3 句话)
3. <r_code> 标签里面**只允许纯 R 代码**,绝对禁止混入中文解释性文字或自然语言段落
4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符
5. 代码最后必须返回包含 report_blocks 的 list`;
5. 代码最后必须返回包含 report_blocks 的 list
6. 中文注释只能以 # 开头写在代码行内,禁止出现不带 # 的中文
示例输出格式:
简要说明...
<r_code>
library(ggplot2)
# 数据处理
df$group <- as.factor(df$group)
# ... 完整 R 代码 ...
list(status = "success", method = "t_test", report_blocks = blocks)
</r_code>`;
}
private buildFirstMessage(plan: AgentPlan): string {
@@ -242,9 +275,9 @@ ${plan.assumptions.join('\n') || '无特殊假设'}
private buildRetryMessage(plan: AgentPlan, errorFeedback: string, previousCode?: string): string {
const codeSection = previousCode
? `## 上次失败的完整代码(供参考,请在此基础上修正后输出完整新代码)
\`\`\`r
<previous_code>
${previousCode}
\`\`\``
</previous_code>`
: '';
return `上一次生成的 R 代码执行失败。
@@ -252,9 +285,9 @@ ${previousCode}
${codeSection}
## 错误信息
\`\`\`
<error>
${errorFeedback}
\`\`\`
</error>
## 分析计划(不变)
- 标题:${plan.title}
@@ -263,23 +296,33 @@ ${errorFeedback}
- 分组变量:${plan.variables.grouping || '无'}
## 修复要求
1. **仔细分析上面的错误信息**,找到报错的根本原因
1. **仔细分析 <error> 中的错误信息**,找到报错的根本原因
2. 针对错误原因做精确修复,输出完整的、可直接执行的 R 代码
3. 对可能出错的关键步骤使用 tryCatch 包裹
4. 用 safe_test 模式包裹统计检验,处理 NA/NaN/Inf
5. 检查所有 library() 调用是否在预装包列表内
6. 保持 report_blocks 输出格式不变`;
6. 保持 report_blocks 输出格式不变
7. **必须将修正后的完整代码放在 <r_code>...</r_code> 标签中**`;
}
private parseCode(content: string): GeneratedCode {
const codeMatch = content.match(/```r\s*([\s\S]*?)```/)
// 三级提取XML 标签 > Markdown 代码块 > 启发式推断
const xmlMatch = content.match(/<r_code>([\s\S]*?)<\/r_code>/);
const mdMatch = content.match(/```r\s*([\s\S]*?)```/)
|| content.match(/```R\s*([\s\S]*?)```/)
|| content.match(/```\s*([\s\S]*?)```/);
let code: string;
if (codeMatch) {
code = codeMatch[1].trim();
let extractMethod: string;
if (xmlMatch) {
code = xmlMatch[1].trim();
extractMethod = 'xml_tag';
} else if (mdMatch) {
code = mdMatch[1].trim();
extractMethod = 'markdown_block';
} else {
// 启发式:检查是否有足够多的 R 代码特征行
const lines = content.split('\n');
const rLines = lines.filter(l => {
const t = l.trim();
@@ -289,9 +332,10 @@ ${errorFeedback}
});
if (rLines.length >= 3) {
code = content.trim();
extractMethod = 'heuristic';
} else {
throw new Error(
'LLM 返回内容中未找到有效的 R 代码块。请确保在 ```r ... ``` 中输出代码。'
'LLM 返回内容中未找到有效的 R 代码块。请确保在 <r_code>...</r_code> 标签中输出代码。'
+ ` (收到 ${content.length} 字符, 首 100 字: ${content.slice(0, 100)})`
);
}
@@ -301,6 +345,8 @@ ${errorFeedback}
throw new Error(`解析到的 R 代码过短 (${code.length} 字符),可能生成失败`);
}
logger.debug('[AgentCoder] Code extracted', { extractMethod, codeLength: code.length });
const packageRegex = /library\((\w+)\)/g;
const packages: string[] = [];
let match;
@@ -309,6 +355,7 @@ ${errorFeedback}
}
const explanation = content
.replace(/<r_code>[\s\S]*?<\/r_code>/g, '')
.replace(/```r[\s\S]*?```/gi, '')
.replace(/```[\s\S]*?```/g, '')
.trim()

View File

@@ -14,6 +14,8 @@
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import type { Message as LLMMessage } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
import { getPromptService } from '../../../common/prompt/index.js';
import { prisma } from '../../../config/database.js';
import { sessionBlackboardService } from './SessionBlackboardService.js';
import { tokenTruncationService } from './TokenTruncationService.js';
@@ -47,7 +49,7 @@ export class AgentPlannerService {
): Promise<AgentPlan> {
const dataContext = await this.buildDataContext(sessionId);
const systemPrompt = this.buildSystemPrompt(dataContext);
const systemPrompt = await this.buildSystemPrompt(dataContext);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
@@ -90,7 +92,18 @@ export class AgentPlannerService {
return tokenTruncationService.toPromptString(truncated);
}
private buildSystemPrompt(dataContext: string): string {
private async buildSystemPrompt(dataContext: string): Promise<string> {
try {
const promptService = getPromptService(prisma);
const rendered = await promptService.get('SSA_AGENT_PLANNER', { dataContext });
return rendered.content;
} catch (err) {
logger.warn('[AgentPlanner] Failed to load prompt from DB, using fallback', { error: (err as Error).message });
}
return this.fallbackSystemPrompt(dataContext);
}
private fallbackSystemPrompt(dataContext: string): string {
return `你是一位高级统计分析规划师Planner Agent。你的职责是根据用户的研究需求和数据特征制定严谨的统计分析计划。
## 数据上下文

View File

@@ -34,6 +34,7 @@ interface TruncatedContext {
variables: string;
pico: string;
report: string;
highFidelitySchema: string;
estimatedTokens: number;
}
@@ -61,12 +62,17 @@ export class TokenTruncationService {
const overview = this.formatOverview(blackboard.dataOverview, strategy);
const variables = this.formatVariables(blackboard.variableDictionary, strategy);
const report = this.formatReport(blackboard, strategy);
const highFidelitySchema = this.formatHighFidelitySchema(
blackboard.dataOverview,
blackboard.variableDictionary,
);
let ctx: TruncatedContext = {
pico,
overview,
variables,
report,
highFidelitySchema,
estimatedTokens: 0,
};
@@ -92,6 +98,7 @@ export class TokenTruncationService {
if (ctx.pico) parts.push(`## PICO 结构\n${ctx.pico}`);
if (ctx.overview) parts.push(`## 数据概览\n${ctx.overview}`);
if (ctx.highFidelitySchema) parts.push(`## 数据 Schema高保真\n${ctx.highFidelitySchema}`);
if (ctx.variables) parts.push(`## 变量列表\n${ctx.variables}`);
if (ctx.report) parts.push(`## 数据诊断摘要\n${ctx.report}`);
@@ -139,6 +146,47 @@ export class TokenTruncationService {
}).join('\n');
}
/**
* 高保真 Schema为 CoderAgent 生成包含列类型、样本值、缺失率的详细 Schema。
* 每列一行LLM 可据此精确使用 as.factor() / as.numeric()。
*/
formatHighFidelitySchema(overview: DataOverview | null, dict: VariableDictEntry[]): string {
if (!overview?.profile?.columns?.length) return '';
const cols = overview.profile.columns as any[];
const dictMap = new Map(dict.map(v => [v.name, v]));
const lines: string[] = ['列名 | R类型 | 缺失率 | 详情'];
lines.push('---|---|---|---');
for (const col of cols) {
if (col.isIdLike) continue;
const dictEntry = dictMap.get(col.name);
const confirmedType = dictEntry?.confirmedType ?? dictEntry?.inferredType ?? col.type;
const picoTag = dictEntry?.picoRole ? ` [${dictEntry.picoRole}]` : '';
const missingPct = col.missingRate != null ? `${(col.missingRate * 100).toFixed(1)}%` : '0%';
let detail = '';
if (col.type === 'numeric') {
const parts: string[] = [];
if (col.mean != null) parts.push(`M=${Number(col.mean).toFixed(2)}`);
if (col.std != null) parts.push(`SD=${Number(col.std).toFixed(2)}`);
if (col.min != null && col.max != null) parts.push(`[${col.min}, ${col.max}]`);
detail = parts.join(', ');
} else if (col.type === 'categorical' && col.topValues?.length) {
const levels = col.topValues.slice(0, 5).map((v: any) => `"${v.value}"(${v.count})`).join(', ');
detail = `${col.totalLevels ?? col.topValues.length}级: ${levels}`;
} else if (col.type === 'datetime') {
detail = col.dateRange || (col.minDate && col.maxDate ? `${col.minDate}~${col.maxDate}` : '');
}
lines.push(`${col.name}${picoTag} | ${confirmedType} | ${missingPct} | ${detail}`);
}
return lines.join('\n');
}
private formatReport(bb: SessionBlackboard, strategy: string): string {
const report = bb.dataOverview
? this.buildReportSummary(bb.dataOverview)
@@ -172,7 +220,8 @@ export class TokenTruncationService {
}
private estimateTokens(ctx: TruncatedContext): number {
const total = ctx.pico.length + ctx.overview.length + ctx.variables.length + ctx.report.length;
const total = ctx.pico.length + ctx.overview.length + ctx.variables.length
+ ctx.report.length + ctx.highFidelitySchema.length;
return Math.ceil(total / 2);
}
@@ -185,6 +234,7 @@ export class TokenTruncationService {
result.report = result.report.length > 300 ? result.report.slice(0, 300) + '...' : result.report;
// 激进模式下highFidelitySchema 已包含类型信息,可以简化 variables
let vars = bb.variableDictionary.filter(v => !v.isIdLike);
if (vars.length > 10) {
const picoVars = vars.filter(v => v.picoRole);
@@ -196,6 +246,12 @@ export class TokenTruncationService {
return `- ${v.name}: ${type}`;
}).join('\n');
// 如果还超限,截断高保真 Schema保留前 15 列)
if (this.estimateTokens(result) > maxTokens && result.highFidelitySchema) {
const schemaLines = result.highFidelitySchema.split('\n');
result.highFidelitySchema = schemaLines.slice(0, 17).join('\n') + '\n...';
}
result.estimatedTokens = this.estimateTokens(result);
return result;
}

View File

@@ -1,10 +1,11 @@
# AIclinicalresearch 系统当前状态与开发指南
> **文档版本:** v6.7
> **文档版本:** v6.8
> **创建日期:** 2025-11-28
> **维护者:** 开发团队
> **最后更新:** 2026-03-07
> **最后更新:** 2026-03-08
> **🎉 重大里程碑:**
> - **🆕 2026-03-08SSA 智能统计分析 Agent 模式 MVP 完成!** Agent 核心 Prompt 接入运营管理端PlannerAgent + CoderAgent 动态化 + 三级容灾)+ Phase 5A 防错护栏 + Prompt 全景盘点Agent 仅用 2 个 PromptQPER 11 个已归档)
> - **🆕 2026-03-07SSA Agent 通道体验优化 + Plan-and-Execute 架构设计完成!** 方案 B 左右职责分离 + 10 项 Bug 修复JWT 刷新/代码截断/重试流式/R Docker 结构化错误/进度同步/导出按钮)+ 分步执行架构评审通过(代码累加策略 + 5 项工程护栏)
> - **🆕 2026-03-05RVW V3.0 智能审稿 + ASL Deep Research 历史 + 系统稳定性增强!** RVW LLM 数据核查 + 临床评估维度 + 并行 Skill 故障隔离 + ASL 研究历史/删除 + DeepSearch S3 升级
> - **🆕 2026-03-01IIT 业务端 GCP 报表 + AI 时间线增强 + 多项 Bug 修复!** 4 张 GCP 标准报表(筛选入选/完整性/质疑跟踪/方案偏离)+ AI 工作流水详情展开 + 一键全量质控 + dimension_code/时区/通过率/D1 数据源修复
@@ -35,7 +36,8 @@
> - **2026-01-24Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
> - **2026-01-22OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
>
> **🆕 最新进展SSA Agent 体验优化 + RVW V3.0 2026-03-07**
> **🆕 最新进展SSA Agent MVP + RVW V3.0 2026-03-08**
> - ✅ **🆕 SSA Agent 模式 MVP 完成** — Agent 核心 Prompt 接入运营管理端(`SSA_AGENT_PLANNER` + `SSA_AGENT_CODER` 动态化)+ 三级容灾DB→缓存→fallback+ 种子脚本幂等写入 + Prompt 全景盘点Agent 2 个 / QPER 11 个归档)
> - ✅ **🆕 SSA Agent 通道体验优化12 文件, +931/-203 行)** — 方案 B 左右职责分离 + JWT 刷新 + 代码截断修复 + 重试流式生成 + R Docker 结构化错误20+ 模式)+ Prompt 铁律 + 进度同步 + 导出按钮恢复 + ExecutingProgress 动态 UI
> - ✅ **🆕 Plan-and-Execute 分步执行架构设计完成** — 代码累加策略 + 5 项工程护栏XML 标签/AST 预检/防御性 Prompt/高保真 Schema/错误分类短路)+ 3 份架构评审报告
> - ✅ **🆕 RVW V3.0 智能审稿** — LLM 数据核查 + 临床专业评估维度 + 并行 Skill 故障隔离partial_completed+ error_details JSONB
@@ -90,7 +92,7 @@
| **ASL** | AI智能文献 | 文献筛选、Deep Research、全文智能提取、SR图表、Meta分析 | ⭐⭐⭐⭐⭐ | 🎉 **V2.0 核心完成90%+ 🆕工具4+5完成** - SSE流式+瀑布流UI+HITL+Word导出+散装派发+PRISMA流程图+Meta分析引擎(R Docker) | **P0** |
| **DC** | 数据清洗整理 | ETL + 医学NER百万行级数据 | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能** | **P0** |
| **IIT** | IIT Manager Agent | CRA Agent - LLM Tool Use + 自驱动质控 + 统一驾驶舱 | ⭐⭐⭐⭐⭐ | 🎉 **V3.1完成 + GCP报表 + Bug修复** 质控引擎升级 + 4张GCP业务报表 + AI时间线增强 + 一键全量质控 | **P1-2** |
| **SSA** | 智能统计分析 | **QPER架构 + 双通道QPER + LLM Agent** + 四层七工具 + 对话层LLM | ⭐⭐⭐⭐⭐ | 🎉 **双通道架构 + Agent 体验优化完成** — QPER闭环 + Agent代码生成通道 + 方案B左右职责分离 + 10项Bug修复 + Plan-and-Execute架构设计E2E全通过 | **P1** |
| **SSA** | 智能统计分析 | **Agent 模式PlannerAgent + CoderAgent + R Docker** + QPER 备用 | ⭐⭐⭐⭐⭐ | 🎉 **Agent 模式 MVP 完成** — Prompt 运营管理化 + Phase 5A 护栏 + 体验优化 + Plan-and-Execute 架构设计,仅用 2 个核心 Prompt | **P1** |
| **ST** | 统计分析工具 | 126 个轻量化统计工具(旧系统 iframe 嵌入) | ⭐⭐⭐⭐ | ✅ **旧系统集成完成** — Token 注入 + Wrapper Bridge + E2E 验证通过 | P2 |
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V2.0 Week3完成85%** - 统计验证扩展+负号归一化+文件格式提示+用户体验优化 | P1 |
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成88%** - Prompt知识库集成+动态注入 | **P0** |
@@ -260,7 +262,7 @@
-**QPER 集成**:对话层直接调用 plan → execute → reportanalysis_plan SSE 事件传输
-**团队审查 12 条反馈全部落地**Phase II H1-H4、Phase III H1-H3+P1、Phase IV H1-H3+B1-B2
**下一步**Phase 5A-5CPlan-and-Execute 分步执行)→ Phase V-B反思编排→ Phase VI集成测试 + 可观测性)
**下一步**Phase 5B-5CPlan-and-Execute 分步执行)→ Phase V-B反思编排→ Phase VI集成测试 + 可观测性)
**相关文档**
- 开发计划:`docs/03-业务模块/SSA-智能统计分析/04-开发计划/11-智能对话与工具体系开发计划.md`
@@ -268,6 +270,21 @@
- 系统设计:`docs/03-业务模块/SSA-智能统计分析/00-系统设计/SSA-Pro 四层七工具实现机制详解.md`
- 架构评审:`docs/03-业务模块/SSA-智能统计分析/07-统计专家配置/架构委员会审查报告:分步执行架构.md`
#### ✅ SSA Agent 模式 MVP 完成 — Prompt 运营管理化 + Phase 5A 护栏2026-03-08
**Agent 核心 Prompt 接入运营管理端:**
-`AgentPlannerService.buildSystemPrompt()``PromptService.get('SSA_AGENT_PLANNER', { dataContext })`
-`AgentCoderService.buildSystemPrompt()``PromptService.get('SSA_AGENT_CODER', { dataContext })`
- ✅ 三级容灾:数据库 ACTIVE 版本 → 内存缓存5min TTL→ 代码 fallback`prompt.fallbacks.ts`
- ✅ 种子脚本 `prisma/seed-ssa-agent-prompts.ts` 幂等写入upsert + ACTIVE v1
**Prompt 全景盘点13 → 2 生效):**
| 通道 | 数量 | 当前状态 |
|------|------|---------|
| Agent 通道 | 2 个(`SSA_AGENT_PLANNER` + `SSA_AGENT_CODER` | ✅ 生效中,可通过运营管理端配置 |
| QPER 通道 | 11 个BASE_SYSTEM + INTENT_ROUTER + 6 意图 + QUERY + PICO + REFLECTION | ⏸ 不执行,保留备用 |
#### ✅ SSA Agent 通道体验优化 + Plan-and-Execute 架构设计2026-03-07
**Agent 通道体验优化完成12 文件, +931/-203 行):**

View File

@@ -1,13 +1,20 @@
# SSA智能统计分析模块 - 当前状态与开发指南
> **文档版本:** v4.1
> **文档版本:** v4.2
> **创建日期:** 2026-02-18
> **最后更新:** 2026-03-07
> **最后更新:** 2026-03-08
> **维护者:** 开发团队
> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 + Agent 通道体验优化完成**
> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 + Agent 通道体验优化 + Agent Prompt 运营管理化完成**
> **文档目的:** 快速了解SSA模块状态为新AI助手提供上下文
>
> **最新进展2026-03-07 Agent 通道体验优化 — 方案 B 左右职责分离 + 10 项 Bug 修复**
> **最新进展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`),任何一层失败自动降级
> - ✅ **种子数据脚本** — `prisma/seed-ssa-agent-prompts.ts` 幂等写入初始 Promptupsert template + ACTIVE v1
> - ✅ **Handlebars 模板变量** — 两个 Prompt 均使用 `{{{dataContext}}}` 三括号无转义渲染,运营可编辑模板内容但保留变量占位符
>
> **此前进展2026-03-07 Agent 通道体验优化 — 方案 B 左右职责分离 + 10 项 Bug 修复):**
> - ✅ **方案 B — 左右职责分离** — 左侧对话区仅输出简洁视线牵引提示,右侧工作区承载计划/代码/结果全部交互;双屏状态互斥同步(右侧操作→左侧追加审计消息);历史穿梭(点击左侧卡片→右侧切换对应任务)
> - ✅ **JWT Token 刷新机制** — 前端 `ensureFreshToken()` 在 API 调用前检查并刷新过期 Token解决 HTTP 401 问题
> - ✅ **代码截断修复** — LLM maxTokens 4000→8000 + CSS max-height 60vh + word-break 优化
@@ -194,7 +201,8 @@ AnalysisRecord {
| **双通道 Phase 3** | **前端集成SSE + AgentCodePanel + 确认流程)** | **~6h** | ✅ **已完成(三步确认 + 流式代码 + 7 种 SSE 事件)** | 2026-03-02 |
| **Agent 体验优化** | **方案 B 左右职责分离 + 10 项 Bug 修复** | **~8h** | ✅ **已完成12 文件, +931/-203 行)** | 2026-03-07 |
| **Plan-and-Execute 设计** | **分步执行架构设计(代码累加 + 工程护栏)** | **~4h** | ✅ **已完成(架构评审 + 三份评估报告)** | 2026-03-07 |
| **Phase 5A** | **CoderAgent 防错护栏XML 标签 + AST 预检 + 防御性 Prompt + 高保真 Schema** | **~6h** | 📋 待开始 | - |
| **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 V-B** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - |
@@ -223,6 +231,7 @@ AnalysisRecord {
| **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 体验优化** | 方案 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 体验测试通过(统计分析结果+图表正常) | ✅ |
---
@@ -350,6 +359,51 @@ npx tsx scripts/seed-ssa-pico-prompt.ts # Phase I: PICO 推断
npx tsx scripts/seed-ssa-phase2-prompts.ts # Phase II: 8 Prompt
npx tsx scripts/seed-ssa-phase3-prompts.ts # Phase III: SSA_METHOD_CONSULT
npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN
npx tsx prisma/seed-ssa-agent-prompts.ts # Agent: SSA_AGENT_PLANNER + SSA_AGENT_CODER
```
---
## 🧠 Prompt 全景盘点QPER vs Agent
> **结论:当前 Agent 模式仅使用 2 个 Prompt其余 11 个为 QPER 遗产。**
>
> 自 `chat.routes.ts` 硬编码 `executionMode = 'agent'` 后QPER 通道代码不再执行。
### Agent 通道 Prompt当前生效 ✅)
| # | Prompt Code | 服务 | 模板变量 | 用途 |
|---|------------|------|---------|------|
| 1 | `SSA_AGENT_PLANNER` | `AgentPlannerService` | `{{{dataContext}}}` | 规划师 System Prompt制定统计分析计划JSON 格式) |
| 2 | `SSA_AGENT_CODER` | `AgentCoderService` | `{{{dataContext}}}` | 编码器 System Prompt生成可执行 R 代码XML 标签输出) |
**管理方式:** 运营管理端 → Prompt 管理 → SSA 模块 → 在线编辑/灰度预览/版本管理
**容灾链路:** 数据库 ACTIVE 版本 → 内存缓存5min TTL→ 代码 fallback`prompt.fallbacks.ts`
### QPER 通道 Prompt当前不执行保留备用
| # | Prompt Code | 服务 | 用途 |
|---|------------|------|------|
| 3 | `SSA_BASE_SYSTEM` | `SystemPromptService` | QPER 对话基础角色定义 |
| 4 | `SSA_INTENT_ROUTER` | `IntentRouterService` | LLM 意图分类器6 种意图) |
| 5 | `SSA_INTENT_CHAT` | `SystemPromptService` | 普通聊天意图指令 |
| 6 | `SSA_INTENT_EXPLORE` | `SystemPromptService` | 数据探索意图指令 |
| 7 | `SSA_INTENT_CONSULT` | `SystemPromptService` | 方法咨询意图指令 |
| 8 | `SSA_INTENT_ANALYZE` | `SystemPromptService` | 执行分析意图指令 |
| 9 | `SSA_INTENT_DISCUSS` | `SystemPromptService` | 结果讨论意图指令 |
| 10 | `SSA_INTENT_FEEDBACK` | `SystemPromptService` | 改进反馈意图指令 |
| 11 | `SSA_QUERY_INTENT` | `QueryService` | Q 层 LLM 意图解析 |
| 12 | `SSA_PICO_INFERENCE` | `PicoInferenceService` | PICO 结构推断 |
| 13 | `SSA_REFLECTION` | `ReflectionService` | R 层论文级结论生成 |
### Agent 调用链(仅 2 个 Prompt
```
用户消息
→ ChatHandlerService.handleAgentMode()
→ AgentPlannerService.generatePlan() ← SSA_AGENT_PLANNER
→ AgentCoderService.generateCodeStream() ← SSA_AGENT_CODER
→ CodeRunnerService.executeCode() ← 纯 R 执行,无 Prompt
```
---
@@ -443,7 +497,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN
---
**文档版本:** v4.1
**最后更新:** 2026-03-07
**当前状态:** 🎉 QPER 主线闭环 + Phase I-IV + Phase V-A + 双通道架构 Phase 1-3 + Agent 体验优化已完成
**下一步:** Phase 5ACoderAgent 防错护栏)→ Phase 5B分步执行引擎→ Phase 5C前端分步展示
**文档版本:** 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反思编排

View File

@@ -0,0 +1,110 @@
# **架构优化方案R 代码的本地复现与导出包装 (Export Wrapper)**
**问题诊断:** 大模型在 Agent 管线中生成的 R 代码是高度依赖“平台沙箱环境”的。它缺失了数据读取操作 (df),且引用了平台专属的 UI 辅助函数 (make\_table\_block\_from\_df 等)。
**解决目标:** 用户点击“下载 R 代码”时,系统必须动态注入“本地兼容层”,使得代码可以在任何一台普通的 RStudio 中一键运行。
## **一、 根本解决方案:导出包装器 (Export Wrapper)**
当用户点击“下载 R 代码”时,前端不能仅仅把 Agent 生成的代码原样保存为 .R 文件。我们需要像编译器一样,将代码包裹在一个**标准的本地复现模板**中。
完整的导出文件应该由 3 个部分拼接而成:
1. **数据加载层 (Data Loading)**
2. **函数兼容层 (Polyfills / Mock Helpers)**
3. **Agent 生成的核心代码 (Core Logic)**
## **二、 包装器代码实现范例**
前端或后端在生成下载文件时,请使用以下字符串拼接逻辑:
// 伪代码:在前端 SSACodeModal 或后端导出 API 中实现
function generateDownloadableRCode(agentCode: string, fileName: string): string {
const headerAndPolyfills \= \`
\# \=====================================================================
\# SSA-Pro 智能统计分析 \- 本地复现脚本
\# \=====================================================================
\# 1\. 自动安装缺失的包 (本地复现安全保障)
required\_packages \<- c("dplyr", "gtsummary", "base64enc", "ggplot2")
new\_packages \<- required\_packages\[\!(required\_packages %in% installed.packages()\[,"Package"\])\]
if(length(new\_packages)) install.packages(new\_packages)
suppressPackageStartupMessages({
library(dplyr)
library(gtsummary)
library(base64enc)
library(ggplot2)
})
\# \=====================================================================
\# 2\. 数据读取 (请确保数据文件与本脚本在同一目录下)
\# \=====================================================================
\# 系统已将您的原始数据名填入,如果路径不同请手动修改:
file\_name \<- "${fileName}"
if (file.exists(file\_name)) {
if (grepl("\\\\\\\\.csv$", file\_name, ignore.case \= TRUE)) {
df \<- read.csv(file\_name, stringsAsFactors \= FALSE)
} else if (grepl("\\\\\\\\.xlsx?$", file\_name, ignore.case \= TRUE)) {
library(readxl)
df \<- read\_excel(file\_name)
}
} else {
\# 如果找不到文件,生成测试数据以防代码直接崩溃
warning(paste("找不到数据文件:", file\_name, "。将使用模拟数据进行演示。"))
df \<- data.frame(
root\_curve \= sample(c(1, 2), 100, replace \= TRUE),
Yqol \= sample(c(0, 1), 100, replace \= TRUE)
)
}
\# \=====================================================================
\# 3\. 平台 UI 辅助函数本地兼容层 (Polyfills)
\# 这使得平台专用的 make\_\*\_block 函数在本地控制台优雅地输出,而不报错
\# \=====================================================================
make\_markdown\_block \<- function(text) {
cat("\\\\n========================================\\\\n")
cat(text, "\\\\n")
}
make\_table\_block\_from\_df \<- function(data, title="", footnote="") {
cat("\\\\n---", title, "---\\\\n")
print(data)
if(footnote \!= "") cat("注:", footnote, "\\\\n")
return(list(type="table"))
}
make\_image\_block \<- function(base64\_data, title="", alt="") {
cat("\\\\n\[图形已生成:", title, "- 请查看 RStudio 的 Plots 面板\]\\\\n")
return(list(type="image"))
}
make\_kv\_block \<- function(items, title="") {
cat("\\\\n---", title, "---\\\\n")
print(unlist(items))
return(list(type="key\_value"))
}
\# \=====================================================================
\# 4\. 核心分析代码 (由 AI Agent 生成)
\# \=====================================================================
\`;
return headerAndPolyfills \+ "\\n" \+ agentCode;
}
## **三、 为什么必须这么做?(架构收益)**
1. **防患于未然的数据读取:** 我们不仅注入了 read.csv还加入了 file.exists 检查。如果医生把 R 脚本发给另一个没有原始数据的统计师,代码会自动生成一组 Mock模拟数据。这样 R 脚本无论如何都能跑通,极大提升了产品的专业感。
2. **优雅的 Polyfills (兼容层) 技术:**
这是前端工程(如适配老版本浏览器)最常用的技术。我们在脚本头部用普通的 print() 和 cat() 重写了 make\_table\_block\_from\_df 等函数。这样,大模型生成的复杂 UI 代码在本地 RStudio 中执行时,不仅**不会报错**,还会把结果**整齐地打印在本地控制台上**。
3. **保持 Agent Prompt 的纯净:**
我们**不需要**去修改 CoderAgent 的 System Prompt 让它“记得写读取文件的代码”。因为每次让 LLM 动态写数据读取逻辑它很容易因为编码UTF-8 vs GBK或文件路径错误而翻车。把这种死板的工作交给前端的字符串拼接Wrapper是最稳定、最省 Token 的做法。
## **四、 实施建议**
请前端团队接手此任务:
在 AgentCodePanel.tsx 或 SSACodeModal.tsx 中,找到处理\*\*“导出 R 脚本 (Export/Download .R)”\*\*的 onClick 函数。在生成 Blob 对象并触发浏览器下载之前,将原有的代码字符串通过上述包装器函数处理一遍即可。这只需半小时即可实现。

View File

@@ -18,6 +18,8 @@
| DB-1 | modules 表 seed 新增 ASL_SR 模块(系统综述项目) | `backend/scripts/seed-modules.js` | 高 | 部署后需执行 `node scripts/seed-modules.js`,并在运营管理端为目标用户/租户开通 |
| DB-2 | prompt_templates 表新增 RVW_DATA_VALIDATION + RVW_CLINICAL 两个 Prompt | `backend/scripts/migrate-rvw-prompts.ts` | 高 | 部署后需执行 `npx tsx scripts/migrate-rvw-prompts.ts`,运营管理端可配置修改 |
| DB-3 | ReviewTask 表新增 `error_details` JSONB 字段(存储 Skill 级失败详情) | `prisma/migrations/20260307_add_error_details_to_review_task/migration.sql` | 高 | 支持 partial_completed 状态,记录每个失败/超时 Skill 的名称和原因 |
| DB-4 | SSA execution_mode 默认值改为 `agent` + 已有 session 全部更新 | `prisma/migrations/20260308_default_agent_mode/migration.sql` | 高 | ALTER DEFAULT + UPDATE 旧数据QPER UI 入口已移除 |
| DB-5 | SSA Agent Prompt 种子数据SSA_AGENT_PLANNER / SSA_AGENT_CODER | `prisma/seed-ssa-agent-prompts.ts` | 高 | 部署后执行 `npx tsx prisma/seed-ssa-agent-prompts.ts`;幂等可重复执行 |
### 后端变更 (Node.js)
@@ -31,6 +33,8 @@
| BE-6 | RVW 稳定性增强SkillExecutor Promise.allSettled + partial_completed 状态 + errorDetails | `executor.ts`, `reviewWorker.ts`, `reviewService.ts`, `reviewController.ts`, `types/index.ts` | 重新构建镜像 | 并行 Skill 故障隔离,部分模块失败时仍返回成功模块结果,新增 `partial_completed` 任务状态 |
| BE-7 | DataForensicsSkill LLM 核查增加独立 60s 超时 | `DataForensicsSkill.ts` | 重新构建镜像 | LLM 核查超时不阻塞整体 Skillgraceful 降级为纯规则验证 |
| BE-8 | SSA Agent 通道体验优化(方案 B 左右职责分离 + 10 项 Bug 修复) | `ChatHandlerService.ts`, `AgentCoderService.ts`, `chat.routes.ts` | 重新构建镜像 | 视线牵引 Prompt + maxTokens 8000 + 重试流式生成 + consoleOutput 类型防御 + Prompt 铁律 + parseCode 健壮化 |
| BE-9 | Phase 5ACoderAgent 防错护栏4 项改动) | `AgentCoderService.ts`, `TokenTruncationService.ts`, `chat.routes.ts` | 重新构建镜像 | XML 标签提取 + 防御性编程 Prompt + 高保真 Schema 注入 + token 配额 2500 + 后端强制 Agent 模式 |
| BE-10 | SSA Agent 核心 Prompt 接入运营管理端PlannerAgent + CoderAgent | `AgentPlannerService.ts`, `AgentCoderService.ts`, `prompt.fallbacks.ts` | 重新构建镜像 | 硬编码 → `PromptService.get()` 动态加载三级容灾DB → 缓存 → fallback需先完成 DB-5 |
### 前端变更
@@ -43,6 +47,7 @@
| FE-5 | RVW 新增临床专业评估 Tab + Agent 选择项 | `ClinicalReport.tsx`(新), `AgentModal.tsx`, `TaskDetail.tsx`, `rvw/types/index.ts` | 重新构建镜像 | 共 4 个 Tab稿约规范性/方法学/数据验证/临床评估Word 导出包含临床评估章节 |
| FE-6 | RVW 前端支持 partial_completed 状态(部分完成) | `TaskDetail.tsx`, `TaskTable.tsx`, `rvw/types/index.ts` | 重新构建镜像 | 琥珀色警告横幅展示失败模块详情,列表页显示"部分完成"标签,支持查看已完成模块的报告 |
| FE-7 | SSA Agent 通道体验优化(方案 B + 动态 UI | `AgentCodePanel.tsx`, `SSAChatPane.tsx`, `SSAWorkspacePane.tsx`, `SSACodeModal.tsx`, `useSSAChat.ts`, `ssaStore.ts`, `ssa.css` | 重新构建镜像 | 左右职责分离 + JWT 刷新 + 重试代码展示 + 错误信息展示 + 进度条同步 + 导出/查看代码按钮恢复 + ExecutingProgress 组件 |
| FE-8 | SSA 默认 Agent 模式 + 查看代码修复 + 分析历史卡片 | `SSAChatPane.tsx`, `SSAWorkspacePane.tsx`, `useSSAChat.ts`, `ssaStore.ts` | 重新构建镜像 | 移除 ModeToggle + 默认 agent + 查看代码走 Modal + 分析完成后对话插入可点击结果卡片 + ChatIntentType 扩展 system |
### Python 微服务变更
@@ -56,6 +61,7 @@
|---|---------|---------|---------|------|
| R-1 | 新增 POST /api/v1/execute-code 端点Agent 通道任意 R 代码执行) | `plumber.R` | 重新构建镜像 | 含超时 + 沙箱限制 |
| R-2 | Agent 结构化错误处理增强20+ 模式匹配 + format_agent_error | `plumber.R`, `utils/error_codes.R` | 重新构建镜像 | withCallingHandlers 捕获 warnings/messages + 行号提取 + 错误分类 + 修复建议 |
| R-3 | AST 语法预检parse() 前置于 eval() | `plumber.R` | 重新构建镜像 | 语法错误秒级返回 E_SYNTAX + 行号 + 上下文代码,不进入沙箱执行 |
### 环境变量 / 配置变更

View File

@@ -69,7 +69,63 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
);
}
const { status, planText, planSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution;
const { status, planText, planSteps: rawPlanSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution;
// 防御性:从 planText JSON 解析步骤(支持 steps / plan.steps绝不展示原始 JSON
const planSteps = React.useMemo(() => {
if (rawPlanSteps && rawPlanSteps.length > 0) return rawPlanSteps;
if (!planText) return undefined;
// 尝试直接 parse或去除 ```json 包裹后 parse
const tryParse = (text: string) => {
try { return JSON.parse(text); } catch { return null; }
};
let parsed = tryParse(planText);
if (!parsed) {
const m = planText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) parsed = tryParse(m[1].trim());
}
if (!parsed) return undefined;
const stepsArray = Array.isArray(parsed?.steps)
? parsed.steps
: Array.isArray(parsed?.plan?.steps)
? parsed.plan.steps
: null;
if (stepsArray?.length) {
return stepsArray.map((s: any) => ({
order: s.order ?? 0,
method: s.method ?? '',
description: s.description ?? '',
rationale: s.rationale,
}));
}
return undefined;
}, [rawPlanSteps, planText]);
// 解析计划的标题/研究类型等元信息
const planMeta = React.useMemo(() => {
if (!planText) return null;
const tryParse = (text: string) => {
try { return JSON.parse(text); } catch { return null; }
};
let parsed = tryParse(planText);
if (!parsed) {
const m = planText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) parsed = tryParse(m[1].trim());
}
if (!parsed) return null;
return {
title: parsed.title as string | undefined,
designType: parsed.designType as string | undefined,
variables: parsed.variables as {
outcome?: string[];
predictors?: string[];
grouping?: string | null;
confounders?: string[];
} | undefined,
};
}, [planText]);
const isStreamingCode = status === 'coding' && !!partialCode;
const displayCode = isStreamingCode ? partialCode : (generatedCode || partialCode);
@@ -97,19 +153,48 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
<span className="badge-done"></span>
)}
</div>
{/* 计划元信息(标题、研究类型、变量) */}
{planMeta && (
<div className="agent-plan-meta">
{planMeta.title && <div className="plan-meta-title">{planMeta.title}</div>}
<div className="plan-meta-tags">
{planMeta.designType && <span className="plan-tag design">{planMeta.designType}</span>}
{planMeta.variables?.outcome?.map(v => (
<span key={v} className="plan-tag outcome">: {v}</span>
))}
{planMeta.variables?.predictors?.map(v => (
<span key={v} className="plan-tag predictor">: {v}</span>
))}
{planMeta.variables?.grouping && (
<span className="plan-tag grouping">: {planMeta.variables.grouping}</span>
)}
{planMeta.variables?.confounders?.map(v => (
<span key={v} className="plan-tag confounder">: {v}</span>
))}
</div>
</div>
)}
{/* 分析步骤(友好展示,不展示原始 JSON */}
{planSteps && planSteps.length > 0 && (
<div className="agent-plan-steps">
{planSteps.map((s, i) => (
<div key={i} className="plan-step-item">
<span className="step-num">{s.order}</span>
<div className="plan-step-body">
<span className="step-method">{s.method}</span>
<span className="step-desc">{s.description}</span>
{s.rationale && <span className="step-rationale">{s.rationale}</span>}
</div>
</div>
))}
</div>
)}
{planText && !planSteps?.length && (
<div className="agent-plan-text">{planText}</div>
{planText && (!planSteps || planSteps.length === 0) && (
<div className="agent-plan-unparseable">
</div>
)}
{/* 计划确认操作按钮 */}

View File

@@ -43,7 +43,6 @@ import { ClarificationCard } from './ClarificationCard';
import { AskUserCard } from './AskUserCard';
import type { AskUserResponseData } from './AskUserCard';
import { ThinkingBlock } from '@/shared/components/Chat';
import { ModeToggle } from './ModeToggle';
import type { ClarificationCardData, IntentResult } from '../types';
export const SSAChatPane: React.FC = () => {
@@ -104,7 +103,7 @@ export const SSAChatPane: React.FC = () => {
}, [currentSession?.id, loadHistory, clearMessages]);
// 方案 B: 注册 agentActionHandler让右侧工作区按钮能触发 Agent 操作
const { setAgentActionHandler, selectAgentExecution, agentExecutionHistory } = useSSAStore();
const { setAgentActionHandler, selectAgentExecution } = useSSAStore();
useEffect(() => {
if (currentSession?.id) {
setAgentActionHandler((action: string) =>
@@ -299,7 +298,6 @@ export const SSAChatPane: React.FC = () => {
</span>
</div>
<div className="chat-header-right">
{currentSession && <ModeToggle />}
<EngineStatus
isExecuting={isExecuting}
isLoading={isLoading || isPlanLoading}
@@ -390,13 +388,38 @@ export const SSAChatPane: React.FC = () => {
{/* Phase II: 流式对话消息(来自 useSSAChat */}
{chatMessages.map((msg: ChatMessage) => {
const isSystemAudit = (msg as any).intent === 'system';
const isSystemAudit = msg.intent === 'system';
// 系统审计消息(方案 B右侧操作的审计纪要
if (isSystemAudit) {
return (
<div key={msg.id} className="message message-system slide-up">
<div className="system-audit-msg">{msg.content}</div>
{msg.executionId && (
<button
className="sap-card agent-result-card"
onClick={() => {
selectAgentExecution(msg.executionId!);
setWorkspaceOpen(true);
}}
>
<div className="sap-card-left">
<div className="sap-card-icon">
<BarChart2 size={16} />
</div>
<div className="sap-card-content">
<div className="sap-card-title">
<span className="sap-card-badge"></span>
</div>
<div className="sap-card-hint">
{msg.executionQuery || '点击打开工作区查看详情'}
</div>
</div>
</div>
<ArrowRight size={16} className="sap-card-arrow" />
</button>
)}
</div>
);
}
@@ -448,27 +471,6 @@ export const SSAChatPane: React.FC = () => {
);
})}
{/* 时光机卡片Agent 执行历史(点击可切换右侧工作区) */}
{agentExecutionHistory.filter(e => e.status === 'completed').length > 1 && (
<div className="agent-history-cards slide-up">
{agentExecutionHistory
.filter(e => e.status === 'completed')
.slice(0, -1)
.map(exec => (
<button
key={exec.id}
className="agent-history-card"
onClick={() => selectAgentExecution(exec.id)}
>
<BarChart2 size={14} />
<span className="agent-history-query">{exec.query}</span>
<span className="agent-history-badge"></span>
<ArrowRight size={12} />
</button>
))}
</div>
)}
{/* 数据画像生成中指示器 */}
{dataProfileLoading && (
<div className="message message-ai slide-up">
@@ -678,6 +680,7 @@ const INTENT_LABELS: Record<ChatIntentType, string> = {
analyze: '统计分析',
discuss: '结果讨论',
feedback: '结果改进',
system: '系统',
};
const EngineStatus: React.FC<EngineStatusProps> = ({

View File

@@ -1,12 +1,154 @@
/**
* SSACodeModal - R 代码模态框 (Unified Record Architecture)
*
* 从 currentRecord.steps 聚合所有步骤的可复现代码。
* 导出时自动包裹 Export Wrapper
* 1. 数据加载层read.csv / read_excel + 文件名自动填入)
* 2. 平台辅助函数 Polyfillmake_*_block → 本地 print / 保存 PNG
* 3. Agent 生成的核心分析代码
*
* 在线查看显示原始代码;下载/复制时注入 Wrapper。
*/
import React, { useEffect, useState } from 'react';
import { X, Download, Loader2 } from 'lucide-react';
import React, { useEffect, useState, useMemo } from 'react';
import { X, Download, Copy, Loader2 } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
/**
* 从 R 代码中提取所有 library(xxx) 调用的包名
*/
function extractPackages(code: string): string[] {
const pkgs = new Set<string>();
const re = /library\((\w+)\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(code)) !== null) pkgs.add(m[1]);
return [...pkgs];
}
/**
* 生成本地复现 Wrapper数据加载 + Polyfill + 包检查
*/
function buildExportWrapper(agentCode: string, fileName: string, query: string): string {
const packages = extractPackages(agentCode);
const pkgVector = packages.map(p => `"${p}"`).join(', ');
const dataLoader = `# =====================================================================
# SSA-Pro 智能统计分析 — 本地复现脚本
# 分析任务: ${query}
# 导出时间: ${new Date().toLocaleString('zh-CN')}
# =====================================================================
# 使用说明:
# 1. 将本脚本与数据文件放在同一目录
# 2. 在 RStudio 中打开,设置工作目录为脚本所在目录
# 3. 全选运行 (Ctrl+A → Ctrl+Enter)
# 4. 图表将自动保存为 PNG 文件到当前目录
# =====================================================================
# ── 1. 环境检查:缺失包提示 ──
required_packages <- c(${pkgVector})
missing_packages <- required_packages[!(required_packages %in% installed.packages()[,"Package"])]
if (length(missing_packages) > 0) {
stop(paste0(
"缺少以下 R 包,请先安装:\\n ",
paste(missing_packages, collapse = ", "),
"\\n\\n运行以下命令安装:\\n install.packages(c(",
paste0('"', missing_packages, '"', collapse = ", "), "))"
))
}
# ── 2. 数据加载(请确保数据文件与本脚本在同一目录) ──
.DATA_FILE <- "${fileName}"
if (!file.exists(.DATA_FILE)) {
stop(paste0(
"找不到数据文件: ", .DATA_FILE, "\\n",
"请将数据文件复制到脚本所在目录,或修改上方 .DATA_FILE 变量为正确路径。"
))
}
if (grepl("\\\\.csv$", .DATA_FILE, ignore.case = TRUE)) {
df <- read.csv(.DATA_FILE, stringsAsFactors = FALSE, fileEncoding = "UTF-8")
} else if (grepl("\\\\.xlsx?$", .DATA_FILE, ignore.case = TRUE)) {
if (!requireNamespace("readxl", quietly = TRUE)) {
stop("读取 Excel 文件需要 readxl 包,请运行: install.packages(\\"readxl\\")")
}
df <- readxl::read_excel(.DATA_FILE)
df <- as.data.frame(df)
} else {
stop(paste0("不支持的文件格式: ", .DATA_FILE, "(仅支持 .csv / .xlsx"))
}
cat(paste0("✓ 数据已加载: ", nrow(df), " 行 × ", ncol(df), " 列\\n"))`;
const polyfills = `
# ── 3. 平台辅助函数 — 本地兼容层 (Polyfill) ──
# 以下函数在平台沙箱中用于构建 UI 报告块,
# 本地运行时替换为控制台输出 + 图片文件保存。
make_markdown_block <- function(content, title = NULL) {
cat("\\n")
if (!is.null(title)) cat("【", title, "】\\n")
cat(content, "\\n")
invisible(list(type = "markdown", content = content))
}
make_table_block <- function(headers, rows, title = NULL, footnote = NULL) {
cat("\\n")
if (!is.null(title)) cat("── ", title, " ──\\n")
mat <- do.call(rbind, rows)
colnames(mat) <- headers
print(as.data.frame(mat, stringsAsFactors = FALSE))
if (!is.null(footnote)) cat("注: ", footnote, "\\n")
invisible(list(type = "table"))
}
make_table_block_from_df <- function(df_arg, title = NULL, footnote = NULL, digits = NULL) {
cat("\\n")
if (!is.null(title)) cat("── ", title, " ──\\n")
if (!is.null(digits)) {
nums <- sapply(df_arg, is.numeric)
df_arg[nums] <- lapply(df_arg[nums], round, digits = digits)
}
print(df_arg, row.names = FALSE)
if (!is.null(footnote)) cat("注: ", footnote, "\\n")
invisible(list(type = "table"))
}
make_image_block <- function(base64_data, title = "", alt = "") {
# 将 base64 图片数据解码并保存为本地 PNG 文件
tryCatch({
raw_b64 <- sub("^data:image/[^;]+;base64,", "", base64_data)
safe_name <- gsub("[^a-zA-Z0-9_\\u4e00-\\u9fa5]", "_", title)
if (nchar(safe_name) == 0 || nchar(safe_name) > 60) safe_name <- "plot"
out_file <- paste0(safe_name, ".png")
# 避免同名覆盖
counter <- 1
while (file.exists(out_file)) {
out_file <- paste0(safe_name, "_", counter, ".png")
counter <- counter + 1
}
writeBin(base64enc::base64decode(raw_b64), out_file)
cat(paste0("✓ 图表已保存: ", out_file, "\\n"))
}, error = function(e) {
cat(paste0("✗ 图表保存失败: ", e$message, "\\n"))
})
invisible(list(type = "image"))
}
make_kv_block <- function(items, title = NULL) {
cat("\\n")
if (!is.null(title)) cat("── ", title, " ──\\n")
for (nm in names(items)) {
cat(sprintf(" %s: %s\\n", nm, items[[nm]]))
}
invisible(list(type = "key_value"))
}`;
const coreSection = `
# =====================================================================
# 4. 核心分析代码(由 AI Agent 生成,无需修改)
# =====================================================================`;
return [dataLoader, polyfills, coreSection, agentCode].join('\n\n');
}
export const SSACodeModal: React.FC = () => {
const {
codeModalVisible,
@@ -16,9 +158,10 @@ export const SSACodeModal: React.FC = () => {
analysisHistory,
executionMode,
agentExecution,
mountedFile,
} = useSSAStore();
const [code, setCode] = useState<string>('');
const [rawCode, setRawCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const record = currentRecordId
@@ -30,8 +173,7 @@ export const SSACodeModal: React.FC = () => {
setIsLoading(true);
try {
if (executionMode === 'agent' && agentExecution?.generatedCode) {
const header = `# ========================================\n# Agent 生成的 R 代码\n# 分析任务: ${agentExecution.query || '统计分析'}\n# ========================================\n`;
setCode(header + agentExecution.generatedCode);
setRawCode(agentExecution.generatedCode);
} else {
const steps = record?.steps ?? [];
const successSteps = steps.filter(
@@ -45,9 +187,9 @@ export const SSACodeModal: React.FC = () => {
return header + (stepCode || '# 该步骤暂无可用代码');
})
.join('\n\n');
setCode(allCode);
setRawCode(allCode);
} else {
setCode('# 暂无可用代码\n# 请先执行分析');
setRawCode('');
}
}
} finally {
@@ -55,6 +197,22 @@ export const SSACodeModal: React.FC = () => {
}
}, [codeModalVisible, record, executionMode, agentExecution]);
const fileName = mountedFile?.name || 'data.csv';
const query = agentExecution?.query || record?.query || '统计分析';
// 带 Wrapper 的完整导出代码(用于下载和复制)
const exportCode = useMemo(() => {
if (!rawCode) return '# 暂无可用代码\n# 请先执行分析';
if (executionMode === 'agent') {
return buildExportWrapper(rawCode, fileName, query);
}
// QPER 模式:原样输出(已含 reproducible_code
return rawCode;
}, [rawCode, executionMode, fileName, query]);
// 在线预览:显示带 Wrapper 的完整代码
const displayCode = exportCode;
if (!codeModalVisible) return null;
const handleClose = () => setCodeModalVisible(false);
@@ -74,14 +232,14 @@ export const SSACodeModal: React.FC = () => {
const handleDownload = () => {
try {
const blob = new Blob([code], { type: 'text/plain' });
const blob = new Blob([exportCode], { type: 'text/plain; charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = generateFilename();
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
addToast('R 脚本已下载(含本地复现环境)', 'success');
handleClose();
} catch {
addToast('下载失败', 'error');
@@ -89,8 +247,8 @@ export const SSACodeModal: React.FC = () => {
};
const handleCopy = () => {
navigator.clipboard.writeText(code);
addToast('代码已复制', 'success');
navigator.clipboard.writeText(exportCode);
addToast('代码已复制(含本地复现环境)', 'success');
};
return (
@@ -114,16 +272,17 @@ export const SSACodeModal: React.FC = () => {
</div>
) : (
<pre className="code-block">
<code>{code}</code>
<code>{displayCode}</code>
</pre>
)}
</div>
<footer className="code-modal-footer">
<button className="copy-btn" onClick={handleCopy} disabled={isLoading}>
<button className="copy-btn" onClick={handleCopy} disabled={isLoading || !rawCode}>
<Copy size={14} />
</button>
<button className="download-btn" onClick={handleDownload} disabled={isLoading}>
<button className="download-btn" onClick={handleDownload} disabled={isLoading || !rawCode}>
<Download size={14} />
.R
</button>

View File

@@ -6,12 +6,14 @@
* - Logo + 新建按钮
* - 历史记录列表(展开时显示)
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Menu,
BarChart3,
Plus,
MessageSquare
MessageSquare,
Pencil,
Trash2,
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import apiClient from '@/common/api/axios';
@@ -19,8 +21,9 @@ import type { SSASession } from '../types';
interface HistoryItem {
id: string;
title: string;
status: 'active' | 'completed' | 'archived';
title: string | null;
status: string;
updatedAt: string;
}
export const SSASidebar: React.FC = () => {
@@ -29,14 +32,22 @@ export const SSASidebar: React.FC = () => {
setSidebarExpanded,
currentSession,
hydrateFromHistory,
setCurrentSession,
reset
} = useSSAStore();
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
useEffect(() => {
if (sidebarExpanded) {
fetchHistory();
}, [currentSession?.id]);
useEffect(() => {
if (sidebarExpanded && historyItems.length === 0) {
fetchHistory();
}
}, [sidebarExpanded]);
@@ -62,9 +73,8 @@ export const SSASidebar: React.FC = () => {
reset();
};
const handleSelectSession = async (sessionId: string) => {
const handleSelectSession = useCallback(async (sessionId: string) => {
if (sessionId === currentSession?.id) return;
try {
const response = await apiClient.get(`/api/v1/ssa/sessions/${sessionId}`);
const session: SSASession = response.data;
@@ -72,7 +82,57 @@ export const SSASidebar: React.FC = () => {
} catch (error) {
console.error('Failed to load session:', error);
}
};
}, [currentSession?.id, hydrateFromHistory]);
const handleRename = useCallback((item: HistoryItem) => {
setEditingId(item.id);
setEditingTitle((item.title || '未命名分析').replace(/\.(csv|xlsx?)$/i, ''));
}, []);
const saveRename = useCallback(async () => {
if (!editingId || !editingTitle.trim()) {
setEditingId(null);
return;
}
try {
await apiClient.patch(`/api/v1/ssa/sessions/${editingId}`, {
title: editingTitle.trim(),
});
setHistoryItems((prev) =>
prev.map((s) => (s.id === editingId ? { ...s, title: editingTitle.trim() } : s)),
);
if (currentSession?.id === editingId) {
setCurrentSession({ ...currentSession, title: editingTitle.trim() } as SSASession);
}
} catch (e) {
console.error('Failed to rename session:', e);
} finally {
setEditingId(null);
}
}, [editingId, editingTitle, currentSession, setCurrentSession]);
const handleDelete = useCallback(async (sessionId: string) => {
setDeleteConfirmId(sessionId);
}, []);
const confirmDelete = useCallback(async () => {
if (!deleteConfirmId) return;
try {
await apiClient.delete(`/api/v1/ssa/sessions/${deleteConfirmId}`);
setHistoryItems((prev) => prev.filter((s) => s.id !== deleteConfirmId));
if (currentSession?.id === deleteConfirmId) {
reset();
}
} catch (e) {
console.error('Failed to delete session:', e);
} finally {
setDeleteConfirmId(null);
}
}, [deleteConfirmId, currentSession?.id, reset]);
const cancelDelete = useCallback(() => {
setDeleteConfirmId(null);
}, []);
return (
<aside className={`ssa-sidebar ${sidebarExpanded ? 'expanded' : ''}`}>
@@ -116,16 +176,79 @@ export const SSASidebar: React.FC = () => {
) : historyItems.length === 0 ? (
<div className="history-empty"></div>
) : (
historyItems.map((item) => (
<button
<>
{historyItems.map((item) => {
const isActive = item.id === currentSession?.id;
const isEditing = editingId === item.id;
const isDeleting = deleteConfirmId === item.id;
const displayTitle = (item.title || '未命名分析').replace(/\.(csv|xlsx?)$/i, '');
if (isEditing) {
return (
<div key={item.id} className="history-item history-item-edit">
<input
className="history-item-input"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveRename();
if (e.key === 'Escape') setEditingId(null);
}}
onBlur={saveRename}
autoFocus
aria-label="编辑名称"
/>
</div>
);
}
if (isDeleting) {
return (
<div key={item.id} className="history-delete-confirm">
<span>{displayTitle}</span>
<div className="history-delete-btns">
<button type="button" onClick={cancelDelete}></button>
<button type="button" className="danger" onClick={confirmDelete}></button>
</div>
</div>
);
}
return (
<div
key={item.id}
className={`history-item ${item.id === currentSession?.id ? 'active' : ''}`}
className={`history-item-wrapper ${isActive ? 'active' : ''}`}
>
<button
className="history-item"
onClick={() => handleSelectSession(item.id)}
title={item.title || '未命名分析'}
>
<MessageSquare size={14} className="history-item-icon" />
<span className="history-item-title">{item.title}</span>
<span className="history-item-title">{displayTitle}</span>
</button>
))
<div className="history-item-actions">
<button
type="button"
className="history-action-btn"
onClick={(e) => { e.stopPropagation(); handleRename(item); }}
title="重命名"
>
<Pencil size={12} />
</button>
<button
type="button"
className="history-action-btn danger"
onClick={(e) => { e.stopPropagation(); handleDelete(item.id); }}
title="删除"
>
<Trash2 size={12} />
</button>
</div>
</div>
);
})}
</>
)}
</div>
</div>

View File

@@ -230,14 +230,8 @@ export const SSAWorkspacePane: React.FC = () => {
}
};
const [showAgentCode, setShowAgentCode] = useState(false);
const handleExportCode = () => {
if (executionMode === 'agent' && agentExecution?.generatedCode) {
setShowAgentCode(prev => !prev);
} else {
setCodeModalVisible(true);
}
};
const scrollToSection = (section: 'sap' | 'execution' | 'result') => {

View File

@@ -23,7 +23,7 @@ import type { WorkflowPlan } from '../types';
// Types
// ────────────────────────────────────────────
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback';
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback' | 'system';
export interface ChatMessage {
id: string;
@@ -33,6 +33,10 @@ export interface ChatMessage {
intent?: ChatIntentType;
status?: 'complete' | 'generating' | 'error';
createdAt: string;
/** Agent 执行 ID用于在对话中渲染可点击的结果卡片 */
executionId?: string;
/** Agent 执行的查询摘要 */
executionQuery?: string;
}
export interface IntentMeta {
@@ -134,20 +138,45 @@ export function useSSAChat(): UseSSAChatReturn {
if (!resp.ok) return;
const data = await resp.json();
const loaded: ChatMessage[] = [];
if (data.messages?.length > 0) {
const loaded: ChatMessage[] = data.messages
loaded.push(
...data.messages
.filter((m: any) => m.status !== 'generating')
.map((m: any) => ({
id: m.id,
role: m.role,
role: m.role as 'user' | 'assistant',
content: m.content || '',
thinking: m.thinkingContent,
intent: m.intent,
status: m.status || 'complete',
status: (m.status || 'complete') as 'complete' | 'generating' | 'error',
createdAt: m.createdAt,
}));
setChatMessages(loaded);
executionId: m.executionId,
executionQuery: m.executionQuery,
})),
);
}
// 从 store 中的 agentExecutionHistory 注入结果卡片
const { agentExecutionHistory } = useSSAStore.getState();
for (const exec of agentExecutionHistory) {
if (exec.status === 'completed') {
loaded.push({
id: `exec-card-${exec.id}`,
role: 'assistant',
content: `✅ 分析完成:${exec.query}`,
status: 'complete',
intent: 'system',
executionId: exec.id,
executionQuery: exec.query,
createdAt: exec.createdAt || new Date().toISOString(),
});
}
}
loaded.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
setChatMessages(loaded);
} catch (err) {
console.warn('[useSSAChat] Failed to load history:', err);
}
@@ -329,13 +358,27 @@ export function useSSAChat(): UseSSAChatReturn {
}
if (parsed.type === 'code_result') {
const { updateAgentExecution } = useSSAStore.getState();
const { updateAgentExecution, agentExecution: curExec } = useSSAStore.getState();
updateAgentExecution({
reportBlocks: parsed.reportBlocks,
generatedCode: parsed.code || useSSAStore.getState().agentExecution?.generatedCode,
generatedCode: parsed.code || curExec?.generatedCode,
status: 'completed',
durationMs: parsed.durationMs,
});
// 在对话中插入可点击的结果卡片
const execId = curExec?.id;
const execQuery = curExec?.query || content;
setChatMessages(prev => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `✅ 分析完成:${execQuery}`,
status: 'complete' as const,
intent: 'system',
executionId: execId,
executionQuery: execQuery,
createdAt: new Date().toISOString(),
}]);
continue;
}
@@ -448,7 +491,7 @@ export function useSSAChat(): UseSSAChatReturn {
role: 'assistant' as const,
content: auditContent,
status: 'complete' as const,
intent: 'system' as any,
intent: 'system',
createdAt: new Date().toISOString(),
}]);

View File

@@ -161,7 +161,7 @@ const initialState = {
dataProfileModalVisible: false,
workflowPlanLoading: false,
hasUnsavedPlanChanges: false,
executionMode: 'qper' as ExecutionMode,
executionMode: 'agent' as ExecutionMode,
agentExecution: null as AgentExecutionRecord | null,
agentExecutionHistory: [] as AgentExecutionRecord[],
agentActionHandler: null as ((action: string) => Promise<void>) | null,
@@ -234,10 +234,66 @@ export const useSSAStore = create<SSAState>((set) => ({
})),
hydrateFromHistory: (session) => {
// 从后端 agentExecutions 恢复 Agent 执行历史
const rawExecs = (session as any).agentExecutions as any[] | undefined;
const agentHistory = (rawExecs || []).map((e: any) => {
// 优先从 reviewResult结构化 JSON提取 planSteps其次 planText
let planSteps: Array<{ order: number; method: string; description: string; rationale?: string }> | undefined;
let planMeta: { title?: string; designType?: string } | undefined;
const structured = e.reviewResult;
if (structured) {
const stepsArray = Array.isArray(structured.steps) ? structured.steps : structured?.plan?.steps;
if (Array.isArray(stepsArray)) {
planSteps = stepsArray.map((s: any) => ({
order: s.order,
method: s.method,
description: s.description,
rationale: s.rationale,
}));
}
planMeta = { title: structured.title, designType: structured.designType };
}
if (!planSteps?.length && e.planText) {
try {
const parsed = JSON.parse(e.planText);
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,
}));
}
if (!planMeta) planMeta = { title: parsed.title, designType: parsed.designType };
} catch { /* not JSON */ }
}
return {
id: e.id,
sessionId: session.id,
query: e.query,
planText: structured ? JSON.stringify(structured) : e.planText,
planSteps,
generatedCode: e.generatedCode,
reportBlocks: e.reportBlocks,
retryCount: e.retryCount || 0,
status: e.status,
errorMessage: e.errorMessage,
durationMs: e.durationMs,
createdAt: e.createdAt,
};
});
// 找到最新的已完成执行作为当前展示
const latestCompleted = [...agentHistory].reverse().find(e => e.status === 'completed');
set({
currentSession: session,
workspaceOpen: false,
activePane: 'empty',
workspaceOpen: !!latestCompleted,
activePane: latestCompleted ? 'result' : 'empty',
executionMode: 'agent',
agentExecutionHistory: agentHistory,
agentExecution: latestCompleted || null,
});
if (session.dataSchema) {
set({

View File

@@ -293,6 +293,118 @@
text-overflow: ellipsis;
}
/* 历史项容器:左侧按钮 + 右侧操作图标 */
.history-item-wrapper {
position: relative;
display: flex;
align-items: center;
border-radius: 8px;
}
.history-item-wrapper .history-item {
flex: 1;
min-width: 0;
padding-right: 4px;
}
.history-item-wrapper.active .history-item {
background-color: white;
border: 1px solid #e2e8f0;
color: #1d4ed8;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
/* 行内操作图标hover 时显示) */
.history-item-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
position: absolute;
right: 4px;
background: linear-gradient(to right, transparent, #f1f5f9 30%);
padding-left: 16px;
padding-right: 4px;
}
.history-item-wrapper:hover .history-item-actions {
opacity: 1;
}
.history-item-wrapper.active .history-item-actions {
background: linear-gradient(to right, transparent, white 30%);
}
.history-action-btn {
padding: 4px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.history-action-btn:hover {
color: #475569;
background: #e2e8f0;
}
.history-action-btn.danger:hover {
color: #dc2626;
background: #fef2f2;
}
/* 重命名:行内编辑 */
.history-item-edit {
padding: 6px 12px;
border-radius: 8px;
background: #f8fafc;
}
.history-item-input {
width: 100%;
padding: 6px 8px;
font-size: 14px;
border: 1px solid #e2e8f0;
border-radius: 6px;
outline: none;
}
.history-item-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 删除确认(替代原位置) */
.history-delete-confirm {
padding: 10px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 13px;
color: #991b1b;
}
.history-delete-btns {
margin-top: 8px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.history-delete-btns button {
padding: 5px 12px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: white;
color: #334155;
cursor: pointer;
}
.history-delete-btns button.danger {
background: #dc2626;
border-color: #dc2626;
color: white;
}
.history-delete-btns button.danger:hover {
background: #b91c1c;
}
/* ========================================== */
/* 主容器 */
/* ========================================== */

View File

@@ -1405,10 +1405,34 @@
min-width: 60px;
}
.plan-step-item .plan-step-body {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.plan-step-item .step-desc {
color: #94a3b8;
}
.plan-step-item .step-rationale {
font-size: 11px;
color: #64748b;
font-style: italic;
margin-top: 2px;
}
.agent-plan-unparseable {
padding: 12px 16px;
font-size: 13px;
color: #94a3b8;
background: rgba(100, 116, 139, 0.15);
border-radius: 8px;
margin: 0 16px 12px;
}
.agent-plan-text {
padding: 4px 16px 12px;
font-size: 13px;
@@ -1417,6 +1441,61 @@
white-space: pre-wrap;
}
/* 计划元信息 */
.agent-plan-meta {
padding: 8px 16px 4px;
}
.plan-meta-title {
font-size: 14px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 8px;
line-height: 1.4;
}
.plan-meta-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.plan-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.6;
}
.plan-tag.design {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
}
.plan-tag.outcome {
background: rgba(244, 63, 94, 0.15);
color: #fb7185;
}
.plan-tag.predictor {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.plan-tag.grouping {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
}
.plan-tag.confounder {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
/* 代码区域 */
.agent-code-body {
padding: 12px 16px;

View File

@@ -391,7 +391,7 @@ export interface AgentExecutionRecord {
sessionId: string;
query: string;
planText?: string;
planSteps?: Array<{ order: number; method: string; description: string }>;
planSteps?: Array<{ order: number; method: string; description: string; rationale?: string }>;
generatedCode?: string;
partialCode?: string;
reportBlocks?: ReportBlock[];
@@ -399,6 +399,7 @@ export interface AgentExecutionRecord {
status: AgentExecutionStatus;
errorMessage?: string;
durationMs?: number;
createdAt?: string;
}
/** SSE 消息 */

View File

@@ -192,6 +192,38 @@ function(req) {
message(glue::glue("[ExecuteCode] session={session_id}, code_length={nchar(code)}, timeout={timeout_sec}s"))
# ── AST 语法预检parse() 先于 eval(),快速捕获语法错误 ──
ast_check <- tryCatch({
parse(text = code)
NULL
}, error = function(e) {
e$message
})
if (!is.null(ast_check)) {
line_match <- regmatches(ast_check, regexpr("\\d+:\\d+", ast_check))
error_line <- if (length(line_match) > 0) as.integer(sub(":.*", "", line_match)) else NULL
code_lines <- strsplit(code, "\n")[[1]]
context_lines <- if (!is.null(error_line) && error_line > 0 && error_line <= length(code_lines)) {
start_l <- max(1, error_line - 2)
end_l <- min(length(code_lines), error_line + 2)
paste(sprintf("%3d| %s", start_l:end_l, code_lines[start_l:end_l]), collapse = "\n")
} else NULL
return(list(
status = "error",
error_code = "E_SYNTAX",
error_type = "syntax",
message = paste0("R 语法错误(代码无法解析): ", ast_check),
user_hint = "代码存在语法错误,请检查括号/引号是否匹配、运算符是否正确",
error_line = error_line,
error_context = context_lines,
console_output = list(),
duration_ms = 0
))
}
sandbox_env <- new.env(parent = globalenv())
if (!is.null(session_id) && nchar(session_id) > 0) {