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:
@@ -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';
|
||||
@@ -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 生成的 DataProfile(Phase 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")
|
||||
|
||||
238
backend/prisma/seed-ssa-agent-prompts.ts
Normal file
238
backend/prisma/seed-ssa-agent-prompts.ts
Normal 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. 多因素分析需考虑共线性和 EPV(Events 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());
|
||||
@@ -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. 多因素分析需考虑共线性和 EPV(Events 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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)。你的职责是根据用户的研究需求和数据特征,制定严谨的统计分析计划。
|
||||
|
||||
## 数据上下文
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# AIclinicalresearch 系统当前状态与开发指南
|
||||
|
||||
> **文档版本:** v6.7
|
||||
> **文档版本:** v6.8
|
||||
> **创建日期:** 2025-11-28
|
||||
> **维护者:** 开发团队
|
||||
> **最后更新:** 2026-03-07
|
||||
> **最后更新:** 2026-03-08
|
||||
> **🎉 重大里程碑:**
|
||||
> - **🆕 2026-03-08:SSA 智能统计分析 Agent 模式 MVP 完成!** Agent 核心 Prompt 接入运营管理端(PlannerAgent + CoderAgent 动态化 + 三级容灾)+ Phase 5A 防错护栏 + Prompt 全景盘点(Agent 仅用 2 个 Prompt,QPER 11 个已归档)
|
||||
> - **🆕 2026-03-07:SSA Agent 通道体验优化 + Plan-and-Execute 架构设计完成!** 方案 B 左右职责分离 + 10 项 Bug 修复(JWT 刷新/代码截断/重试流式/R Docker 结构化错误/进度同步/导出按钮)+ 分步执行架构评审通过(代码累加策略 + 5 项工程护栏)
|
||||
> - **🆕 2026-03-05:RVW V3.0 智能审稿 + ASL Deep Research 历史 + 系统稳定性增强!** RVW LLM 数据核查 + 临床评估维度 + 并行 Skill 故障隔离 + ASL 研究历史/删除 + DeepSearch S3 升级
|
||||
> - **🆕 2026-03-01:IIT 业务端 GCP 报表 + AI 时间线增强 + 多项 Bug 修复!** 4 张 GCP 标准报表(筛选入选/完整性/质疑跟踪/方案偏离)+ AI 工作流水详情展开 + 一键全量质控 + dimension_code/时区/通过率/D1 数据源修复
|
||||
@@ -35,7 +36,8 @@
|
||||
> - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
|
||||
> - **2026-01-22:OSS 存储集成完成!** 阿里云 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 → report,analysis_plan SSE 事件传输
|
||||
- ✅ **团队审查 12 条反馈全部落地**:Phase II H1-H4、Phase III H1-H3+P1、Phase IV H1-H3+B1-B2
|
||||
|
||||
**下一步**:Phase 5A-5C(Plan-and-Execute 分步执行)→ Phase V-B(反思编排)→ Phase VI(集成测试 + 可观测性)
|
||||
**下一步**:Phase 5B-5C(Plan-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 行):**
|
||||
|
||||
@@ -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` 幂等写入初始 Prompt(upsert 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 5A(CoderAgent 防错护栏)→ 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(反思编排)
|
||||
|
||||
110
docs/03-业务模块/SSA-智能统计分析/07-统计专家配置/R 代码本地复现导出方案.md
Normal file
110
docs/03-业务模块/SSA-智能统计分析/07-统计专家配置/R 代码本地复现导出方案.md
Normal 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 对象并触发浏览器下载之前,将原有的代码字符串通过上述包装器函数处理一遍即可。这只需半小时即可实现。
|
||||
@@ -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 核查超时不阻塞整体 Skill,graceful 降级为纯规则验证 |
|
||||
| BE-8 | SSA Agent 通道体验优化(方案 B 左右职责分离 + 10 项 Bug 修复) | `ChatHandlerService.ts`, `AgentCoderService.ts`, `chat.routes.ts` | 重新构建镜像 | 视线牵引 Prompt + maxTokens 8000 + 重试流式生成 + consoleOutput 类型防御 + Prompt 铁律 + parseCode 健壮化 |
|
||||
| BE-9 | Phase 5A:CoderAgent 防错护栏(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 + 行号 + 上下文代码,不进入沙箱执行 |
|
||||
|
||||
### 环境变量 / 配置变更
|
||||
|
||||
|
||||
@@ -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>
|
||||
<span className="step-method">{s.method}</span>
|
||||
<span className="step-desc">{s.description}</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>
|
||||
)}
|
||||
|
||||
{/* 计划确认操作按钮 */}
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -1,12 +1,154 @@
|
||||
/**
|
||||
* SSACodeModal - R 代码模态框 (Unified Record Architecture)
|
||||
*
|
||||
* 从 currentRecord.steps 聚合所有步骤的可复现代码。
|
||||
* 导出时自动包裹 Export Wrapper:
|
||||
* 1. 数据加载层(read.csv / read_excel + 文件名自动填入)
|
||||
* 2. 平台辅助函数 Polyfill(make_*_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>
|
||||
|
||||
@@ -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
|
||||
key={item.id}
|
||||
className={`history-item ${item.id === currentSession?.id ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSession(item.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="history-item-icon" />
|
||||
<span className="history-item-title">{item.title}</span>
|
||||
</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-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">{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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
setCodeModalVisible(true);
|
||||
};
|
||||
|
||||
const scrollToSection = (section: 'sap' | 'execution' | 'result') => {
|
||||
|
||||
@@ -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
|
||||
.filter((m: any) => m.status !== 'generating')
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
thinking: m.thinkingContent,
|
||||
intent: m.intent,
|
||||
status: m.status || 'complete',
|
||||
createdAt: m.createdAt,
|
||||
}));
|
||||
setChatMessages(loaded);
|
||||
loaded.push(
|
||||
...data.messages
|
||||
.filter((m: any) => m.status !== 'generating')
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content || '',
|
||||
thinking: m.thinkingContent,
|
||||
intent: m.intent,
|
||||
status: (m.status || 'complete') as 'complete' | 'generating' | 'error',
|
||||
createdAt: m.createdAt,
|
||||
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(),
|
||||
}]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
/* 主容器 */
|
||||
/* ========================================== */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 消息 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user