diff --git a/backend/prisma/migrations/20260308_default_agent_mode/migration.sql b/backend/prisma/migrations/20260308_default_agent_mode/migration.sql new file mode 100644 index 00000000..af9cba97 --- /dev/null +++ b/backend/prisma/migrations/20260308_default_agent_mode/migration.sql @@ -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'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5ab6e08f..e782df6a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") diff --git a/backend/prisma/seed-ssa-agent-prompts.ts b/backend/prisma/seed-ssa-agent-prompts.ts new file mode 100644 index 00000000..bf14b7cd --- /dev/null +++ b/backend/prisma/seed-ssa-agent-prompts.ts @@ -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 代码放在 标签之间** +2. 标签外面仅限简要说明(1-3 句话) +3. 标签里面**只允许纯 R 代码**,绝对禁止混入中文解释性文字或自然语言段落 +4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符 +5. 代码最后必须返回包含 report_blocks 的 list +6. 中文注释只能以 # 开头写在代码行内,禁止出现不带 # 的中文 + +示例输出格式: +简要说明... + + +library(ggplot2) +# 数据处理 +df$group <- as.factor(df$group) +# ... 完整 R 代码 ... +list(status = "success", method = "t_test", report_blocks = blocks) +`; + +/* ------------------------------------------------------------------ */ +/* 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()); diff --git a/backend/src/common/prompt/prompt.fallbacks.ts b/backend/src/common/prompt/prompt.fallbacks.ts index 46c28961..b8bcb23a 100644 --- a/backend/src/common/prompt/prompt.fallbacks.ts +++ b/backend/src/common/prompt/prompt.fallbacks.ts @@ -290,6 +290,150 @@ Please provide precise, actionable suggestions.`, }, }; +/** + * SSA 智能统计分析模块兜底 Prompt + */ +const SSA_FALLBACKS: Record = { + 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 代码放在 标签之间** +2. 标签外面仅限简要说明(1-3 句话) +3. 标签里面**只允许纯 R 代码**,绝对禁止混入中文解释性文字或自然语言段落 +4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符 +5. 代码最后必须返回包含 report_blocks 的 list +6. 中文注释只能以 # 开头写在代码行内,禁止出现不带 # 的中文 + +示例输出格式: +简要说明... + + +library(ggplot2) +# 数据处理 +df$group <- as.factor(df$group) +# ... 完整 R 代码 ... +list(status = "success", method = "t_test", report_blocks = blocks) +`, + modelConfig: { model: 'deepseek-v3', temperature: 0.3 }, + }, +}; + /** * 所有模块的兜底 Prompt 汇总 */ @@ -297,6 +441,7 @@ export const FALLBACK_PROMPTS: Record = { ...RVW_FALLBACKS, ...ASL_FALLBACKS, ...AIA_FALLBACKS, + ...SSA_FALLBACKS, }; /** diff --git a/backend/src/modules/ssa/routes/chat.routes.ts b/backend/src/modules/ssa/routes/chat.routes.ts index 51a101ea..e7ea6ffb 100644 --- a/backend/src/modules/ssa/routes/chat.routes.ts +++ b/backend/src/modules/ssa/routes/chat.routes.ts @@ -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') { diff --git a/backend/src/modules/ssa/routes/session.routes.ts b/backend/src/modules/ssa/routes/session.routes.ts index bee9b97f..5bc55635 100644 --- a/backend/src/modules/ssa/routes/session.routes.ts +++ b/backend/src/modules/ssa/routes/session.routes.ts @@ -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) diff --git a/backend/src/modules/ssa/services/AgentCoderService.ts b/backend/src/modules/ssa/services/AgentCoderService.ts index 785dda28..1a7e84f1 100644 --- a/backend/src/modules/ssa/services/AgentCoderService.ts +++ b/backend/src/modules/ssa/services/AgentCoderService.ts @@ -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 { 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 { 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 { + 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 代码放在 标签之间** +2. 标签外面仅限简要说明(1-3 句话) +3. 标签里面**只允许纯 R 代码**,绝对禁止混入中文解释性文字或自然语言段落 4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符 -5. 代码最后必须返回包含 report_blocks 的 list`; +5. 代码最后必须返回包含 report_blocks 的 list +6. 中文注释只能以 # 开头写在代码行内,禁止出现不带 # 的中文 + +示例输出格式: +简要说明... + + +library(ggplot2) +# 数据处理 +df$group <- as.factor(df$group) +# ... 完整 R 代码 ... +list(status = "success", method = "t_test", report_blocks = blocks) +`; } 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 + ${previousCode} -\`\`\`` +` : ''; return `上一次生成的 R 代码执行失败。 @@ -252,9 +285,9 @@ ${previousCode} ${codeSection} ## 错误信息 -\`\`\` + ${errorFeedback} -\`\`\` + ## 分析计划(不变) - 标题:${plan.title} @@ -263,23 +296,33 @@ ${errorFeedback} - 分组变量:${plan.variables.grouping || '无'} ## 修复要求 -1. **仔细分析上面的错误信息**,找到报错的根本原因 +1. **仔细分析 中的错误信息**,找到报错的根本原因 2. 针对错误原因做精确修复,输出完整的、可直接执行的 R 代码 3. 对可能出错的关键步骤使用 tryCatch 包裹 4. 用 safe_test 模式包裹统计检验,处理 NA/NaN/Inf 5. 检查所有 library() 调用是否在预装包列表内 -6. 保持 report_blocks 输出格式不变`; +6. 保持 report_blocks 输出格式不变 +7. **必须将修正后的完整代码放在 ... 标签中**`; } private parseCode(content: string): GeneratedCode { - const codeMatch = content.match(/```r\s*([\s\S]*?)```/) + // 三级提取:XML 标签 > Markdown 代码块 > 启发式推断 + const xmlMatch = content.match(/([\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 代码块。请确保在 ... 标签中输出代码。' + ` (收到 ${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(/[\s\S]*?<\/r_code>/g, '') .replace(/```r[\s\S]*?```/gi, '') .replace(/```[\s\S]*?```/g, '') .trim() diff --git a/backend/src/modules/ssa/services/AgentPlannerService.ts b/backend/src/modules/ssa/services/AgentPlannerService.ts index 2968ffb6..ccc0639e 100644 --- a/backend/src/modules/ssa/services/AgentPlannerService.ts +++ b/backend/src/modules/ssa/services/AgentPlannerService.ts @@ -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 { 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 { + 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)。你的职责是根据用户的研究需求和数据特征,制定严谨的统计分析计划。 ## 数据上下文 diff --git a/backend/src/modules/ssa/services/TokenTruncationService.ts b/backend/src/modules/ssa/services/TokenTruncationService.ts index cff6d7d4..aff664f9 100644 --- a/backend/src/modules/ssa/services/TokenTruncationService.ts +++ b/backend/src/modules/ssa/services/TokenTruncationService.ts @@ -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; } diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 8011c04b..721b363f 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -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 行):** diff --git a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md index d393761b..a71de2b6 100644 --- a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md @@ -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(反思编排) diff --git a/docs/03-业务模块/SSA-智能统计分析/07-统计专家配置/R 代码本地复现导出方案.md b/docs/03-业务模块/SSA-智能统计分析/07-统计专家配置/R 代码本地复现导出方案.md new file mode 100644 index 00000000..049e96bf --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/07-统计专家配置/R 代码本地复现导出方案.md @@ -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 对象并触发浏览器下载之前,将原有的代码字符串通过上述包装器函数处理一遍即可。这只需半小时即可实现。 \ No newline at end of file diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index 38a74711..c899cf46 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -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 + 行号 + 上下文代码,不进入沙箱执行 | ### 环境变量 / 配置变更 diff --git a/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx b/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx index fad48955..c437226d 100644 --- a/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx +++ b/frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx @@ -69,7 +69,63 @@ export const AgentCodePanel: React.FC = ({ 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 = ({ onAction, action 已确认 )} + + {/* 计划元信息(标题、研究类型、变量) */} + {planMeta && ( +
+ {planMeta.title &&
{planMeta.title}
} +
+ {planMeta.designType && {planMeta.designType}} + {planMeta.variables?.outcome?.map(v => ( + 结局: {v} + ))} + {planMeta.variables?.predictors?.map(v => ( + 预测: {v} + ))} + {planMeta.variables?.grouping && ( + 分组: {planMeta.variables.grouping} + )} + {planMeta.variables?.confounders?.map(v => ( + 混杂: {v} + ))} +
+
+ )} + + {/* 分析步骤(友好展示,不展示原始 JSON) */} {planSteps && planSteps.length > 0 && (
{planSteps.map((s, i) => (
{s.order} - {s.method} - {s.description} +
+ {s.method} + {s.description} + {s.rationale && {s.rationale}} +
))}
)} - {planText && !planSteps?.length && ( -
{planText}
+ {planText && (!planSteps || planSteps.length === 0) && ( +
+ 计划内容无法解析为步骤,请重试或重新生成分析计划。 +
)} {/* 计划确认操作按钮 */} diff --git a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx index 6b40a259..5989847e 100644 --- a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx @@ -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 = () => {
- {currentSession && } { {/* Phase II: 流式对话消息(来自 useSSAChat) */} {chatMessages.map((msg: ChatMessage) => { - const isSystemAudit = (msg as any).intent === 'system'; + const isSystemAudit = msg.intent === 'system'; // 系统审计消息(方案 B:右侧操作的审计纪要) if (isSystemAudit) { return (
{msg.content}
+ {msg.executionId && ( + + )}
); } @@ -448,27 +471,6 @@ export const SSAChatPane: React.FC = () => { ); })} - {/* 时光机卡片:Agent 执行历史(点击可切换右侧工作区) */} - {agentExecutionHistory.filter(e => e.status === 'completed').length > 1 && ( -
- {agentExecutionHistory - .filter(e => e.status === 'completed') - .slice(0, -1) - .map(exec => ( - - ))} -
- )} - {/* 数据画像生成中指示器 */} {dataProfileLoading && (
@@ -678,6 +680,7 @@ const INTENT_LABELS: Record = { analyze: '统计分析', discuss: '结果讨论', feedback: '结果改进', + system: '系统', }; const EngineStatus: React.FC = ({ diff --git a/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx b/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx index f099a344..beb13e5e 100644 --- a/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx +++ b/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx @@ -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(); + 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(''); + const [rawCode, setRawCode] = useState(''); 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 = () => {
) : (
-              {code}
+              {displayCode}
             
)}